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/components/auth/OidcOAuthSection.vue
0 → 100644
View file @
a04ae28a
<
template
>
<div
class=
"space-y-4"
>
<button
type=
"button"
:disabled=
"disabled"
class=
"btn btn-secondary w-full"
@
click=
"startLogin"
>
<span
class=
"mr-2 inline-flex h-5 w-5 items-center justify-center rounded-full bg-primary-100 text-xs font-semibold text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
>
{{
providerInitial
}}
</span>
{{
t
(
'
auth.oidc.signIn
'
,
{
providerName
:
normalizedProviderName
}
)
}}
<
/button
>
<
div
v
-
if
=
"
showDivider
"
class
=
"
flex items-center gap-3
"
>
<
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
"
>
{{
t
(
'
auth.oauthOrContinue
'
)
}}
<
/span
>
<
div
class
=
"
h-px flex-1 bg-gray-200 dark:bg-dark-700
"
><
/div
>
<
/div
>
<
/div
>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
computed
}
from
'
vue
'
import
{
useRoute
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
const
props
=
withDefaults
(
defineProps
<
{
disabled
?:
boolean
providerName
?:
string
showDivider
?:
boolean
}
>
(),
{
providerName
:
'
OIDC
'
,
showDivider
:
true
}
)
const
route
=
useRoute
()
const
{
t
}
=
useI18n
()
const
normalizedProviderName
=
computed
(()
=>
{
const
name
=
props
.
providerName
?.
trim
()
return
name
||
'
OIDC
'
}
)
const
providerInitial
=
computed
(()
=>
normalizedProviderName
.
value
.
charAt
(
0
).
toUpperCase
()
||
'
O
'
)
function
startLogin
():
void
{
const
redirectTo
=
(
route
.
query
.
redirect
as
string
)
||
'
/dashboard
'
const
apiBase
=
(
import
.
meta
.
env
.
VITE_API_BASE_URL
as
string
|
undefined
)
||
'
/api/v1
'
const
normalized
=
apiBase
.
replace
(
/
\/
$/
,
''
)
const
startURL
=
`${normalized
}
/auth/oauth/oidc/start?redirect=${encodeURIComponent(redirectTo)
}
`
window
.
location
.
href
=
startURL
}
<
/script
>
frontend/src/components/common/DataTable.vue
View file @
a04ae28a
...
@@ -68,7 +68,7 @@
...
@@ -68,7 +68,7 @@
'is-scrollable': isScrollable
'is-scrollable': isScrollable
}"
}"
>
>
<table
class=
"
min-
w-full divide-y divide-gray-200 dark:divide-dark-700"
>
<table
class=
"w-full
min-w-max
divide-y divide-gray-200 dark:divide-dark-700"
>
<thead
class=
"table-header bg-gray-50 dark:bg-dark-800"
>
<thead
class=
"table-header bg-gray-50 dark:bg-dark-800"
>
<tr>
<tr>
<th
<th
...
@@ -797,3 +797,62 @@ tbody tr:hover .sticky-col {
...
@@ -797,3 +797,62 @@ tbody tr:hover .sticky-col {
background
:
linear-gradient
(
to
left
,
rgba
(
0
,
0
,
0
,
0.2
),
transparent
);
background
:
linear-gradient
(
to
left
,
rgba
(
0
,
0
,
0
,
0.2
),
transparent
);
}
}
</
style
>
</
style
>
<
style
>
/* ==========================================================================
终极悬浮滚动条防丢器 (Sledgehammer Override)
绕过 style.css 中 `* { scrollbar-color: transparent }` 的全局悬停隐身诅咒!
========================================================================== */
/* 1. 废除全局针对所有元素的 scrollbar-width 设定,拿回 Chrome/Safari 下 Webkit 滚动条规则的控制权! */
.table-wrapper
{
scrollbar-width
:
auto
!important
;
/* 阻止 Chrome 121 退化到原生 Mac 闪隐滚动条 */
}
/* 2. 重写 Webkit 滚动层,全部加上 !important 强制覆盖透明悬停陷阱 */
.table-wrapper
::-webkit-scrollbar
{
height
:
12px
!important
;
width
:
12px
!important
;
display
:
block
!important
;
background-color
:
transparent
!important
;
}
.table-wrapper
::-webkit-scrollbar-track
{
background-color
:
rgba
(
0
,
0
,
0
,
0.03
)
!important
;
border-radius
:
6px
!important
;
margin
:
0
4px
!important
;
}
.dark
.table-wrapper
::-webkit-scrollbar-track
{
background-color
:
rgba
(
255
,
255
,
255
,
0.05
)
!important
;
}
/* 常驻、不透明的滑块,无视鼠标是否 hover 都在那! */
.table-wrapper
::-webkit-scrollbar-thumb
{
background-color
:
rgba
(
107
,
114
,
128
,
0.75
)
!important
;
border-radius
:
6px
!important
;
border
:
2px
solid
transparent
!important
;
background-clip
:
padding-box
!important
;
-webkit-appearance
:
none
!important
;
}
.table-wrapper
::-webkit-scrollbar-thumb:hover
{
background-color
:
rgba
(
75
,
85
,
99
,
0.9
)
!important
;
}
.dark
.table-wrapper
::-webkit-scrollbar-thumb
{
background-color
:
rgba
(
156
,
163
,
175
,
0.75
)
!important
;
}
.dark
.table-wrapper
::-webkit-scrollbar-thumb:hover
{
background-color
:
rgba
(
209
,
213
,
219
,
0.9
)
!important
;
}
/* 3. 仅给真正的 Firefox 留的后路 */
@supports
(
-moz-appearance
:
none
)
{
.table-wrapper
{
scrollbar-width
:
thin
!important
;
scrollbar-color
:
rgba
(
156
,
163
,
175
,
0.5
)
rgba
(
0
,
0
,
0
,
0.03
)
!important
;
}
.dark
.table-wrapper
{
scrollbar-color
:
rgba
(
75
,
85
,
99
,
0.5
)
rgba
(
255
,
255
,
255
,
0.05
)
!important
;
}
}
</
style
>
frontend/src/components/common/Pagination.vue
View file @
a04ae28a
...
@@ -122,7 +122,7 @@ import { computed, ref } from 'vue'
...
@@ -122,7 +122,7 @@ import { computed, ref } from 'vue'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useI18n
}
from
'
vue-i18n
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
Select
from
'
./Select.vue
'
import
Select
from
'
./Select.vue
'
import
{
s
et
PersistedPageSize
}
from
'
@/composables/usePersistedPageSize
'
import
{
g
et
ConfiguredTablePageSizeOptions
,
normalizeTablePageSize
}
from
'
@/utils/tablePreferences
'
const
{
t
}
=
useI18n
()
const
{
t
}
=
useI18n
()
...
@@ -141,7 +141,7 @@ interface Emits {
...
@@ -141,7 +141,7 @@ interface Emits {
}
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
pageSizeOptions
:
()
=>
[
10
,
20
,
50
,
100
]
,
pageSizeOptions
:
()
=>
getConfiguredTablePageSizeOptions
()
,
showPageSizeSelector
:
true
,
showPageSizeSelector
:
true
,
showJump
:
false
showJump
:
false
}
)
}
)
...
@@ -161,7 +161,14 @@ const toItem = computed(() => {
...
@@ -161,7 +161,14 @@ const toItem = computed(() => {
}
)
}
)
const
pageSizeSelectOptions
=
computed
(()
=>
{
const
pageSizeSelectOptions
=
computed
(()
=>
{
return
props
.
pageSizeOptions
.
map
((
size
)
=>
({
const
options
=
Array
.
from
(
new
Set
([
...
getConfiguredTablePageSizeOptions
(),
normalizeTablePageSize
(
props
.
pageSize
)
])
).
sort
((
a
,
b
)
=>
a
-
b
)
return
options
.
map
((
size
)
=>
({
value
:
size
,
value
:
size
,
label
:
String
(
size
)
label
:
String
(
size
)
}
))
}
))
...
@@ -216,8 +223,7 @@ const goToPage = (newPage: number) => {
...
@@ -216,8 +223,7 @@ const goToPage = (newPage: number) => {
const
handlePageSizeChange
=
(
value
:
string
|
number
|
boolean
|
null
)
=>
{
const
handlePageSizeChange
=
(
value
:
string
|
number
|
boolean
|
null
)
=>
{
if
(
value
===
null
||
typeof
value
===
'
boolean
'
)
return
if
(
value
===
null
||
typeof
value
===
'
boolean
'
)
return
const
newPageSize
=
typeof
value
===
'
string
'
?
parseInt
(
value
)
:
value
const
newPageSize
=
normalizeTablePageSize
(
typeof
value
===
'
string
'
?
parseInt
(
value
,
10
)
:
value
)
setPersistedPageSize
(
newPageSize
)
emit
(
'
update:pageSize
'
,
newPageSize
)
emit
(
'
update:pageSize
'
,
newPageSize
)
}
}
...
...
frontend/src/components/layout/AppSidebar.vue
View file @
a04ae28a
...
@@ -7,20 +7,18 @@
...
@@ -7,20 +7,18 @@
]"
]"
>
>
<!-- Logo/Brand -->
<!-- Logo/Brand -->
<div
class=
"sidebar-header"
>
<div
class=
"sidebar-header"
:class=
"
{ 'sidebar-header-collapsed': sidebarCollapsed }"
>
<!-- Custom Logo or Default Logo -->
<!-- Custom Logo or Default Logo -->
<div
class=
"flex h-9 w-9 items-center justify-center overflow-hidden rounded-xl shadow-glow"
>
<div
class=
"
sidebar-logo
flex h-9 w-9 items-center justify-center overflow-hidden rounded-xl shadow-glow"
>
<img
v-if=
"settingsLoaded"
:src=
"siteLogo || '/logo.png'"
alt=
"Logo"
class=
"h-full w-full object-contain"
/>
<img
v-if=
"settingsLoaded"
:src=
"siteLogo || '/logo.png'"
alt=
"Logo"
class=
"h-full w-full object-contain"
/>
</div>
</div>
<transition
name=
"fade"
>
<div
class=
"sidebar-brand"
:class=
"
{ 'sidebar-brand-collapsed': sidebarCollapsed }" :aria-hidden="sidebarCollapsed ? 'true' : 'false'">
<div
v-if=
"!sidebarCollapsed"
class=
"flex flex-col"
>
<span
class=
"sidebar-brand-title text-lg font-bold text-white"
>
<span
class=
"text-lg font-bold text-white"
>
{{
siteName
}}
{{
siteName
}}
</span>
</span>
<!-- Version Badge -->
<!-- Version Badge -->
<VersionBadge
:version=
"siteVersion"
/>
<!--
<VersionBadge
:version=
"siteVersion"
/>
-->
</div>
</div>
</transition>
</div>
</div>
<!-- Navigation -->
<!-- Navigation -->
...
@@ -29,54 +27,93 @@
...
@@ -29,54 +27,93 @@
<template
v-if=
"isAdmin"
>
<template
v-if=
"isAdmin"
>
<!-- Admin Section -->
<!-- Admin Section -->
<div
class=
"sidebar-section"
>
<div
class=
"sidebar-section"
>
<router-link
<template
v-for=
"item in adminNavItems"
:key=
"item.path"
>
v-for=
"item in adminNavItems"
<!-- Collapsible group (has children) -->
:key=
"item.path"
<template
v-if=
"item.children?.length"
>
:to=
"item.path"
<button
class=
"sidebar-link mb-1"
type=
"button"
:class=
"
{ 'sidebar-link-active': isActive(item.path) }"
class=
"sidebar-link mb-1 w-full"
:title="sidebarCollapsed ? item.label : undefined"
:class=
"
{
:id="
'sidebar-link-active': isGroupActive(item)
&&
!isGroupExpanded(item),
item.path === '/admin/accounts'
'sidebar-link-collapsed': sidebarCollapsed
? 'sidebar-channel-manage'
}"
: item.path === '/admin/groups'
:title="sidebarCollapsed ? item.label : undefined"
? 'sidebar-group-manage'
@click="sidebarCollapsed ? undefined : toggleGroup(item)"
: item.path === '/admin/redeem'
>
? 'sidebar-wallet'
<component
:is=
"item.icon"
class=
"h-5 w-5 flex-shrink-0"
/>
: undefined
<span
"
class=
"sidebar-label sidebar-label-flex"
@click="handleMenuItemClick(item.path)"
:class=
"
{ 'sidebar-label-collapsed': sidebarCollapsed }"
>
:aria-hidden="sidebarCollapsed ? 'true' : 'false'"
<span
v-if=
"item.iconSvg"
class=
"h-5 w-5 flex-shrink-0 sidebar-svg-icon"
v-html=
"sanitizeSvg(item.iconSvg)"
></span>
>
<component
v-else
:is=
"item.icon"
class=
"h-5 w-5 flex-shrink-0"
/>
<span
class=
"min-w-0 truncate"
>
{{
item
.
label
}}
</span>
<transition
name=
"fade"
>
<ChevronDownIcon
<span
v-if=
"!sidebarCollapsed"
>
{{
item
.
label
}}
</span>
class=
"h-4 w-4 flex-shrink-0 transition-transform duration-200"
</transition>
:class=
"isGroupExpanded(item) ? 'rotate-180' : ''"
</router-link>
/>
</span>
</button>
<!-- Children -->
<div
v-if=
"!sidebarCollapsed && isGroupExpanded(item)"
class=
"mb-1 ml-4 border-l border-gray-200 pl-2 dark:border-dark-600"
>
<router-link
v-for=
"child in item.children"
:key=
"child.path"
:to=
"child.path"
class=
"sidebar-link mb-0.5 py-1.5 text-sm"
:class=
"
{ 'sidebar-link-active': route.path === child.path }"
@click="handleMenuItemClick(child.path)"
>
<component
:is=
"child.icon"
class=
"h-4 w-4 flex-shrink-0"
/>
<span>
{{
child
.
label
}}
</span>
</router-link>
</div>
</
template
>
<!-- Normal item (no children) -->
<router-link
v-else
:to=
"item.path"
class=
"sidebar-link mb-1"
:class=
"{ 'sidebar-link-active': isActive(item.path), 'sidebar-link-collapsed': sidebarCollapsed }"
:title=
"sidebarCollapsed ? item.label : undefined"
:id=
"
item.path === '/admin/accounts'
? 'sidebar-channel-manage'
: item.path === '/admin/groups'
? 'sidebar-group-manage'
: item.path === '/admin/redeem'
? 'sidebar-wallet'
: undefined
"
@
click=
"handleMenuItemClick(item.path)"
>
<span
v-if=
"item.iconSvg"
class=
"h-5 w-5 flex-shrink-0 sidebar-svg-icon"
v-html=
"sanitizeSvg(item.iconSvg)"
></span>
<component
v-else
:is=
"item.icon"
class=
"h-5 w-5 flex-shrink-0"
/>
<span
class=
"sidebar-label"
:class=
"{ 'sidebar-label-collapsed': sidebarCollapsed }"
:aria-hidden=
"sidebarCollapsed ? 'true' : 'false'"
>
{{ item.label }}
</span>
</router-link>
</template>
</div>
</div>
<!-- Personal Section for Admin (hidden in simple mode) -->
<!-- Personal Section for Admin (hidden in simple mode) -->
<div
v-if=
"!authStore.isSimpleMode"
class=
"sidebar-section"
>
<div
v-if=
"!authStore.isSimpleMode"
class=
"sidebar-section"
>
<div
v-if=
"!sidebarCollapsed"
class=
"sidebar-section-title"
>
<div
class=
"sidebar-section-title"
:class=
"{ 'sidebar-section-title-collapsed': sidebarCollapsed }"
:aria-hidden=
"sidebarCollapsed ? 'true' : 'false'"
>
{{
t
(
'
nav.myAccount
'
)
}}
<span
class=
"sidebar-section-title-text"
:class=
"{ 'sidebar-section-title-text-collapsed': sidebarCollapsed }"
>
{{ t('nav.myAccount') }}
</span>
</div>
</div>
<div
v-else
class=
"mx-3 my-3 h-px"
style=
"background: rgba(255,255,255,0.06)"
></div>
<router-link
<router-link
v-for=
"item in personalNavItems"
v-for=
"item in personalNavItems"
:key=
"item.path"
:key=
"item.path"
:to=
"item.path"
:to=
"item.path"
class=
"sidebar-link mb-1"
class=
"sidebar-link mb-1"
:class=
"
{ 'sidebar-link-active': isActive(item.path) }"
:class=
"{ 'sidebar-link-active': isActive(item.path)
, 'sidebar-link-collapsed': sidebarCollapsed
}"
:title=
"sidebarCollapsed ? item.label : undefined"
:title=
"sidebarCollapsed ? item.label : undefined"
:data-tour=
"item.path === '/keys' ? 'sidebar-my-keys' : undefined"
:data-tour=
"item.path === '/keys' ? 'sidebar-my-keys' : undefined"
@
click=
"handleMenuItemClick(item.path)"
@
click=
"handleMenuItemClick(item.path)"
>
>
<span
v-if=
"item.iconSvg"
class=
"h-5 w-5 flex-shrink-0 sidebar-svg-icon"
v-html=
"sanitizeSvg(item.iconSvg)"
></span>
<span
v-if=
"item.iconSvg"
class=
"h-5 w-5 flex-shrink-0 sidebar-svg-icon"
v-html=
"sanitizeSvg(item.iconSvg)"
></span>
<component
v-else
:is=
"item.icon"
class=
"h-5 w-5 flex-shrink-0"
/>
<component
v-else
:is=
"item.icon"
class=
"h-5 w-5 flex-shrink-0"
/>
<transition
name=
"fade"
>
<span
class=
"sidebar-label"
:class=
"{ 'sidebar-label-collapsed': sidebarCollapsed }"
:aria-hidden=
"sidebarCollapsed ? 'true' : 'false'"
>
{{ item.label }}
</span>
<span
v-if=
"!sidebarCollapsed"
>
{{
item
.
label
}}
</span>
</transition>
</router-link>
</router-link>
</div>
</div>
</template>
</template>
...
@@ -89,16 +126,14 @@
...
@@ -89,16 +126,14 @@
:key=
"item.path"
:key=
"item.path"
:to=
"item.path"
:to=
"item.path"
class=
"sidebar-link mb-1"
class=
"sidebar-link mb-1"
:class=
"
{ 'sidebar-link-active': isActive(item.path) }"
:class=
"
{ 'sidebar-link-active': isActive(item.path)
, 'sidebar-link-collapsed': sidebarCollapsed
}"
:title="sidebarCollapsed ? item.label : undefined"
:title="sidebarCollapsed ? item.label : undefined"
:data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined"
:data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined"
@click="handleMenuItemClick(item.path)"
@click="handleMenuItemClick(item.path)"
>
>
<span
v-if=
"item.iconSvg"
class=
"h-5 w-5 flex-shrink-0 sidebar-svg-icon"
v-html=
"sanitizeSvg(item.iconSvg)"
></span>
<span
v-if=
"item.iconSvg"
class=
"h-5 w-5 flex-shrink-0 sidebar-svg-icon"
v-html=
"sanitizeSvg(item.iconSvg)"
></span>
<component
v-else
:is=
"item.icon"
class=
"h-5 w-5 flex-shrink-0"
/>
<component
v-else
:is=
"item.icon"
class=
"h-5 w-5 flex-shrink-0"
/>
<transition
name=
"fade"
>
<span
class=
"sidebar-label"
:class=
"
{ 'sidebar-label-collapsed': sidebarCollapsed }" :aria-hidden="sidebarCollapsed ? 'true' : 'false'">
{{
item
.
label
}}
</span>
<span
v-if=
"!sidebarCollapsed"
>
{{
item
.
label
}}
</span>
</transition>
</router-link>
</router-link>
</div>
</div>
</
template
>
</
template
>
...
@@ -110,28 +145,26 @@
...
@@ -110,28 +145,26 @@
<button
<button
@
click=
"toggleTheme"
@
click=
"toggleTheme"
class=
"sidebar-link mb-2 w-full"
class=
"sidebar-link mb-2 w-full"
:class=
"{ 'sidebar-link-collapsed': sidebarCollapsed }"
:title=
"sidebarCollapsed ? (isDark ? t('nav.lightMode') : t('nav.darkMode')) : undefined"
:title=
"sidebarCollapsed ? (isDark ? t('nav.lightMode') : t('nav.darkMode')) : undefined"
>
>
<SunIcon
v-if=
"isDark"
class=
"h-5 w-5 flex-shrink-0 text-amber-500"
/>
<SunIcon
v-if=
"isDark"
class=
"h-5 w-5 flex-shrink-0 text-amber-500"
/>
<MoonIcon
v-else
class=
"h-5 w-5 flex-shrink-0"
/>
<MoonIcon
v-else
class=
"h-5 w-5 flex-shrink-0"
/>
<transition
name=
"fade"
>
<span
class=
"sidebar-label"
:class=
"{ 'sidebar-label-collapsed': sidebarCollapsed }"
:aria-hidden=
"sidebarCollapsed ? 'true' : 'false'"
>
{{
<span
v-if=
"!sidebarCollapsed"
>
{{
isDark ? t('nav.lightMode') : t('nav.darkMode')
isDark ? t('nav.lightMode') : t('nav.darkMode')
}}
</span>
}}
</span>
</transition>
</button>
</button>
<!-- Collapse Button -->
<!-- Collapse Button -->
<button
<button
@
click=
"toggleSidebar"
@
click=
"toggleSidebar"
class=
"sidebar-link w-full"
class=
"sidebar-link w-full"
:class=
"{ 'sidebar-link-collapsed': sidebarCollapsed }"
:title=
"sidebarCollapsed ? t('nav.expand') : t('nav.collapse')"
:title=
"sidebarCollapsed ? t('nav.expand') : t('nav.collapse')"
>
>
<ChevronDoubleLeftIcon
v-if=
"!sidebarCollapsed"
class=
"h-5 w-5 flex-shrink-0"
/>
<ChevronDoubleLeftIcon
v-if=
"!sidebarCollapsed"
class=
"h-5 w-5 flex-shrink-0"
/>
<ChevronDoubleRightIcon
v-else
class=
"h-5 w-5 flex-shrink-0"
/>
<ChevronDoubleRightIcon
v-else
class=
"h-5 w-5 flex-shrink-0"
/>
<transition
name=
"fade"
>
<span
class=
"sidebar-label"
:class=
"{ 'sidebar-label-collapsed': sidebarCollapsed }"
:aria-hidden=
"sidebarCollapsed ? 'true' : 'false'"
>
{{ t('nav.collapse') }}
</span>
<span
v-if=
"!sidebarCollapsed"
>
{{ t('nav.collapse') }}
</span>
</transition>
</button>
</button>
</div>
</div>
</aside>
</aside>
...
@@ -159,6 +192,7 @@ interface NavItem {
...
@@ -159,6 +192,7 @@ interface NavItem {
icon
:
unknown
icon
:
unknown
iconSvg
?:
string
iconSvg
?:
string
hideInSimpleMode
?:
boolean
hideInSimpleMode
?:
boolean
children
?:
NavItem
[]
}
}
const
{
t
}
=
useI18n
()
const
{
t
}
=
useI18n
()
...
@@ -174,9 +208,13 @@ const mobileOpen = computed(() => appStore.mobileOpen)
...
@@ -174,9 +208,13 @@ const mobileOpen = computed(() => appStore.mobileOpen)
const
isAdmin
=
computed
(()
=>
authStore
.
isAdmin
)
const
isAdmin
=
computed
(()
=>
authStore
.
isAdmin
)
const
isDark
=
ref
(
document
.
documentElement
.
classList
.
contains
(
'
dark
'
))
const
isDark
=
ref
(
document
.
documentElement
.
classList
.
contains
(
'
dark
'
))
// Track which parent nav groups are expanded
const
expandedGroups
=
ref
<
Set
<
string
>>
(
new
Set
())
// Site settings from appStore (cached, no flicker)
// Site settings from appStore (cached, no flicker)
const
siteName
=
computed
(()
=>
appStore
.
siteName
)
const
siteName
=
computed
(()
=>
appStore
.
siteName
)
const
siteLogo
=
computed
(()
=>
appStore
.
siteLogo
)
const
siteLogo
=
computed
(()
=>
appStore
.
siteLogo
)
const
siteVersion
=
computed
(()
=>
appStore
.
siteVersion
)
const
settingsLoaded
=
computed
(()
=>
appStore
.
publicSettingsLoaded
)
const
settingsLoaded
=
computed
(()
=>
appStore
.
publicSettingsLoaded
)
// SVG Icon Components
// SVG Icon Components
...
@@ -480,6 +518,36 @@ const ChevronDoubleLeftIcon = {
...
@@ -480,6 +518,36 @@ const ChevronDoubleLeftIcon = {
)
)
}
}
const
OrderIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15a2.25 2.25 0 012.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z
'
})
]
)
}
const
OrderListIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z
'
})
]
)
}
const
ChevronDoubleRightIcon
=
{
const
ChevronDoubleRightIcon
=
{
render
:
()
=>
render
:
()
=>
h
(
h
(
...
@@ -495,6 +563,21 @@ const ChevronDoubleRightIcon = {
...
@@ -495,6 +563,21 @@ const ChevronDoubleRightIcon = {
)
)
}
}
const
ChevronDownIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
m19.5 8.25-7.5 7.5-7.5-7.5
'
})
]
)
}
// User navigation items (for regular users)
// User navigation items (for regular users)
const
userNavItems
=
computed
(():
NavItem
[]
=>
{
const
userNavItems
=
computed
(():
NavItem
[]
=>
{
const
items
:
NavItem
[]
=
[
const
items
:
NavItem
[]
=
[
...
@@ -502,14 +585,24 @@ const userNavItems = computed((): NavItem[] => {
...
@@ -502,14 +585,24 @@ const userNavItems = computed((): NavItem[] => {
{
path
:
'
/keys
'
,
label
:
t
(
'
nav.apiKeys
'
),
icon
:
KeyIcon
},
{
path
:
'
/keys
'
,
label
:
t
(
'
nav.apiKeys
'
),
icon
:
KeyIcon
},
{
path
:
'
/usage
'
,
label
:
t
(
'
nav.usage
'
),
icon
:
ChartIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/usage
'
,
label
:
t
(
'
nav.usage
'
),
icon
:
ChartIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/subscriptions
'
,
label
:
t
(
'
nav.mySubscriptions
'
),
icon
:
CreditCardIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/subscriptions
'
,
label
:
t
(
'
nav.mySubscriptions
'
),
icon
:
CreditCardIcon
,
hideInSimpleMode
:
true
},
...(
appStore
.
cachedPublicSettings
?.
p
urchase_subscription
_enabled
...(
appStore
.
cachedPublicSettings
?.
p
ayment
_enabled
?
[
?
[
{
{
path
:
'
/purchase
'
,
path
:
'
/purchase
'
,
label
:
t
(
'
nav.buySubscription
'
),
label
:
t
(
'
nav.buySubscription
'
),
icon
:
RechargeSubscriptionIcon
,
icon
:
RechargeSubscriptionIcon
,
hideInSimpleMode
:
true
hideInSimpleMode
:
true
}
},
]
:
[]),
...(
appStore
.
cachedPublicSettings
?.
payment_enabled
?
[
{
path
:
'
/orders
'
,
label
:
t
(
'
nav.myOrders
'
),
icon
:
OrderListIcon
,
hideInSimpleMode
:
true
},
]
]
:
[]),
:
[]),
{
path
:
'
/redeem
'
,
label
:
t
(
'
nav.redeem
'
),
icon
:
GiftIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/redeem
'
,
label
:
t
(
'
nav.redeem
'
),
icon
:
GiftIcon
,
hideInSimpleMode
:
true
},
...
@@ -531,14 +624,24 @@ const personalNavItems = computed((): NavItem[] => {
...
@@ -531,14 +624,24 @@ const personalNavItems = computed((): NavItem[] => {
{
path
:
'
/keys
'
,
label
:
t
(
'
nav.apiKeys
'
),
icon
:
KeyIcon
},
{
path
:
'
/keys
'
,
label
:
t
(
'
nav.apiKeys
'
),
icon
:
KeyIcon
},
{
path
:
'
/usage
'
,
label
:
t
(
'
nav.usage
'
),
icon
:
ChartIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/usage
'
,
label
:
t
(
'
nav.usage
'
),
icon
:
ChartIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/subscriptions
'
,
label
:
t
(
'
nav.mySubscriptions
'
),
icon
:
CreditCardIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/subscriptions
'
,
label
:
t
(
'
nav.mySubscriptions
'
),
icon
:
CreditCardIcon
,
hideInSimpleMode
:
true
},
...(
appStore
.
cachedPublicSettings
?.
p
urchase_subscription
_enabled
...(
appStore
.
cachedPublicSettings
?.
p
ayment
_enabled
?
[
?
[
{
{
path
:
'
/purchase
'
,
path
:
'
/purchase
'
,
label
:
t
(
'
nav.buySubscription
'
),
label
:
t
(
'
nav.buySubscription
'
),
icon
:
RechargeSubscriptionIcon
,
icon
:
RechargeSubscriptionIcon
,
hideInSimpleMode
:
true
hideInSimpleMode
:
true
}
},
]
:
[]),
...(
appStore
.
cachedPublicSettings
?.
payment_enabled
?
[
{
path
:
'
/orders
'
,
label
:
t
(
'
nav.myOrders
'
),
icon
:
OrderListIcon
,
hideInSimpleMode
:
true
},
]
]
:
[]),
:
[]),
{
path
:
'
/redeem
'
,
label
:
t
(
'
nav.redeem
'
),
icon
:
GiftIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/redeem
'
,
label
:
t
(
'
nav.redeem
'
),
icon
:
GiftIcon
,
hideInSimpleMode
:
true
},
...
@@ -584,6 +687,21 @@ const adminNavItems = computed((): NavItem[] => {
...
@@ -584,6 +687,21 @@ const adminNavItems = computed((): NavItem[] => {
{
path
:
'
/admin/proxies
'
,
label
:
t
(
'
nav.proxies
'
),
icon
:
ServerIcon
},
{
path
:
'
/admin/proxies
'
,
label
:
t
(
'
nav.proxies
'
),
icon
:
ServerIcon
},
{
path
:
'
/admin/redeem
'
,
label
:
t
(
'
nav.redeemCodes
'
),
icon
:
TicketIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/admin/redeem
'
,
label
:
t
(
'
nav.redeemCodes
'
),
icon
:
TicketIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/admin/promo-codes
'
,
label
:
t
(
'
nav.promoCodes
'
),
icon
:
GiftIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/admin/promo-codes
'
,
label
:
t
(
'
nav.promoCodes
'
),
icon
:
GiftIcon
,
hideInSimpleMode
:
true
},
...(
adminSettingsStore
.
paymentEnabled
?
[
{
path
:
'
/admin/orders
'
,
label
:
t
(
'
nav.orderManagement
'
),
icon
:
OrderIcon
,
hideInSimpleMode
:
true
,
children
:
[
{
path
:
'
/admin/orders/dashboard
'
,
label
:
t
(
'
nav.paymentDashboard
'
),
icon
:
ChartIcon
},
{
path
:
'
/admin/orders
'
,
label
:
t
(
'
nav.orderManagement
'
),
icon
:
OrderIcon
},
{
path
:
'
/admin/orders/plans
'
,
label
:
t
(
'
nav.paymentPlans
'
),
icon
:
CreditCardIcon
},
],
},
]
:
[]),
{
path
:
'
/admin/usage
'
,
label
:
t
(
'
nav.usage
'
),
icon
:
ChartIcon
}
{
path
:
'
/admin/usage
'
,
label
:
t
(
'
nav.usage
'
),
icon
:
ChartIcon
}
]
]
...
@@ -645,6 +763,23 @@ function isActive(path: string): boolean {
...
@@ -645,6 +763,23 @@ function isActive(path: string): boolean {
return
route
.
path
===
path
||
route
.
path
.
startsWith
(
path
+
'
/
'
)
return
route
.
path
===
path
||
route
.
path
.
startsWith
(
path
+
'
/
'
)
}
}
function
isGroupActive
(
item
:
NavItem
):
boolean
{
if
(
!
item
.
children
)
return
false
return
item
.
children
.
some
(
child
=>
route
.
path
===
child
.
path
)
}
function
isGroupExpanded
(
item
:
NavItem
):
boolean
{
return
expandedGroups
.
value
.
has
(
item
.
path
)
||
isGroupActive
(
item
)
}
function
toggleGroup
(
item
:
NavItem
)
{
if
(
expandedGroups
.
value
.
has
(
item
.
path
))
{
expandedGroups
.
value
.
delete
(
item
.
path
)
}
else
{
expandedGroups
.
value
.
add
(
item
.
path
)
}
}
// Initialize theme
// Initialize theme
const
savedTheme
=
localStorage
.
getItem
(
'
theme
'
)
const
savedTheme
=
localStorage
.
getItem
(
'
theme
'
)
if
(
if
(
...
@@ -674,21 +809,130 @@ onMounted(() => {
...
@@ -674,21 +809,130 @@ onMounted(() => {
</
script
>
</
script
>
<
style
scoped
>
<
style
scoped
>
.fade-enter-active
,
.sidebar-logo
{
.fade-leave-active
{
flex
:
0
0
2.25rem
;
transition
:
opacity
0.2s
ease
;
min-width
:
2.25rem
;
}
.sidebar-header-collapsed
{
gap
:
0
;
padding-left
:
1.125rem
;
padding-right
:
1.125rem
;
}
.sidebar-brand
{
min-width
:
0
;
flex
:
1
1
auto
;
overflow
:
hidden
;
white-space
:
nowrap
;
transition
:
max-width
0.22s
ease
,
opacity
0.14s
ease
,
transform
0.14s
ease
;
max-width
:
12rem
;
}
.sidebar-brand-collapsed
{
max-width
:
0
;
opacity
:
0
;
transform
:
translateX
(
-4px
);
pointer-events
:
none
;
}
.sidebar-brand-title
{
display
:
block
;
overflow
:
hidden
;
text-overflow
:
ellipsis
;
white-space
:
nowrap
;
}
.sidebar-link-collapsed
{
gap
:
0
;
padding-left
:
0.875rem
;
padding-right
:
0.875rem
;
}
.sidebar-section-title
{
position
:
relative
;
display
:
flex
;
align-items
:
center
;
min-height
:
1.25rem
;
overflow
:
hidden
;
white-space
:
nowrap
;
}
.sidebar-section-title-text
{
display
:
block
;
overflow
:
hidden
;
text-overflow
:
ellipsis
;
white-space
:
nowrap
;
transition
:
opacity
0.16s
ease
,
transform
0.16s
ease
;
}
.sidebar-section-title
::after
{
content
:
''
;
position
:
absolute
;
left
:
0.75rem
;
right
:
0.75rem
;
top
:
50%
;
height
:
1px
;
background
:
rgb
(
229
231
235
);
opacity
:
0
;
transform
:
translateY
(
-50%
);
transition
:
opacity
0.18s
ease
;
}
}
.fade-enter-from
,
.dark
.sidebar-section-title
::after
{
.fade-leave-to
{
background
:
rgb
(
55
65
81
);
}
.sidebar-section-title-text-collapsed
{
opacity
:
0
;
opacity
:
0
;
transform
:
translateX
(
-4px
);
}
.sidebar-section-title-collapsed
::after
{
opacity
:
1
;
transition-delay
:
0.08s
;
}
.sidebar-label
{
display
:
block
;
min-width
:
0
;
overflow
:
hidden
;
text-overflow
:
ellipsis
;
white-space
:
nowrap
;
transition
:
max-width
0.2s
ease
,
opacity
0.12s
ease
,
transform
0.12s
ease
;
max-width
:
12rem
;
}
.sidebar-label-flex
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
gap
:
0.5rem
;
}
.sidebar-label-collapsed
{
max-width
:
0
;
opacity
:
0
;
transform
:
translateX
(
-4px
);
pointer-events
:
none
;
}
/* Custom SVG icon in sidebar: constrain size without overriding uploaded SVG colors */
.sidebar-svg-icon
{
color
:
currentColor
;
}
}
/* Custom SVG icon in sidebar: inherit color, constrain size */
.sidebar-svg-icon
:deep
(
svg
)
{
.sidebar-svg-icon
:deep
(
svg
)
{
display
:
block
;
width
:
1.25rem
;
width
:
1.25rem
;
height
:
1.25rem
;
height
:
1.25rem
;
stroke
:
currentColor
;
fill
:
none
;
}
}
</
style
>
</
style
>
frontend/src/components/layout/__tests__/AppSidebar.spec.ts
0 → 100644
View file @
a04ae28a
import
{
readFileSync
}
from
'
node:fs
'
import
{
dirname
,
resolve
}
from
'
node:path
'
import
{
fileURLToPath
}
from
'
node:url
'
import
{
describe
,
expect
,
it
}
from
'
vitest
'
const
componentPath
=
resolve
(
dirname
(
fileURLToPath
(
import
.
meta
.
url
)),
'
../AppSidebar.vue
'
)
const
componentSource
=
readFileSync
(
componentPath
,
'
utf8
'
)
describe
(
'
AppSidebar custom SVG styles
'
,
()
=>
{
it
(
'
does not override uploaded SVG fill or stroke colors
'
,
()
=>
{
expect
(
componentSource
).
toContain
(
'
.sidebar-svg-icon {
'
)
expect
(
componentSource
).
toContain
(
'
color: currentColor;
'
)
expect
(
componentSource
).
toContain
(
'
display: block;
'
)
expect
(
componentSource
).
not
.
toContain
(
'
stroke: currentColor;
'
)
expect
(
componentSource
).
not
.
toContain
(
'
fill: none;
'
)
})
})
frontend/src/components/payment/AmountInput.vue
0 → 100644
View file @
a04ae28a
<
template
>
<div
class=
"space-y-4"
>
<!-- Quick Amount Buttons -->
<div>
<label
class=
"mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
payment.quickAmounts
'
)
}}
</label>
<div
class=
"grid grid-cols-3 gap-2"
>
<button
v-for=
"amt in filteredAmounts"
:key=
"amt"
type=
"button"
:class=
"[
'rounded-lg border-2 px-4 py-3 text-center font-medium transition-colors',
modelValue === amt
? 'border-primary-500 bg-primary-50 text-primary-700 dark:border-primary-400 dark:bg-primary-900/40 dark:text-primary-300'
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-200 dark:hover:border-dark-500',
]"
@
click=
"selectAmount(amt)"
>
{{
amt
}}
</button>
</div>
</div>
<!-- Custom Amount Input -->
<div>
<label
class=
"mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
payment.customAmount
'
)
}}
</label>
<div
class=
"relative"
>
<span
class=
"absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-dark-500"
>
$
</span>
<input
type=
"text"
inputmode=
"decimal"
:value=
"customText"
:placeholder=
"placeholderText"
class=
"input w-full py-3 pl-8 pr-4"
@
input=
"handleInput"
/>
</div>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
const
props
=
withDefaults
(
defineProps
<
{
amounts
?:
number
[]
modelValue
:
number
|
null
min
?:
number
max
?:
number
}
>
(),
{
amounts
:
()
=>
[
10
,
20
,
50
,
100
,
200
,
500
,
1000
,
2000
,
5000
],
min
:
0
,
max
:
0
,
})
const
emit
=
defineEmits
<
{
'
update:modelValue
'
:
[
value
:
number
|
null
]
}
>
()
const
{
t
}
=
useI18n
()
const
customText
=
ref
(
''
)
// 0 = no limit
const
filteredAmounts
=
computed
(()
=>
props
.
amounts
.
filter
((
a
)
=>
(
props
.
min
<=
0
||
a
>=
props
.
min
)
&&
(
props
.
max
<=
0
||
a
<=
props
.
max
))
)
const
placeholderText
=
computed
(()
=>
{
if
(
props
.
min
>
0
&&
props
.
max
>
0
)
return
`
${
props
.
min
}
-
${
props
.
max
}
`
if
(
props
.
min
>
0
)
return
`≥
${
props
.
min
}
`
if
(
props
.
max
>
0
)
return
`≤
${
props
.
max
}
`
return
t
(
'
payment.enterAmount
'
)
})
const
AMOUNT_PATTERN
=
/^
\d
*
(\.\d{0,2})?
$/
function
selectAmount
(
amt
:
number
)
{
customText
.
value
=
String
(
amt
)
emit
(
'
update:modelValue
'
,
amt
)
}
function
handleInput
(
e
:
Event
)
{
const
val
=
(
e
.
target
as
HTMLInputElement
).
value
if
(
!
AMOUNT_PATTERN
.
test
(
val
))
return
customText
.
value
=
val
if
(
val
===
''
)
{
emit
(
'
update:modelValue
'
,
null
)
return
}
const
num
=
parseFloat
(
val
)
if
(
!
isNaN
(
num
)
&&
num
>
0
)
{
emit
(
'
update:modelValue
'
,
num
)
}
else
{
emit
(
'
update:modelValue
'
,
null
)
}
}
watch
(()
=>
props
.
modelValue
,
(
v
)
=>
{
if
(
v
!==
null
&&
String
(
v
)
!==
customText
.
value
)
{
customText
.
value
=
String
(
v
)
}
},
{
immediate
:
true
})
</
script
>
frontend/src/components/payment/OrderStatusBadge.vue
0 → 100644
View file @
a04ae28a
<
template
>
<span
class=
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
:class=
"statusClass"
>
{{
statusLabel
}}
</span>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
type
{
OrderStatus
}
from
'
@/types/payment
'
const
props
=
defineProps
<
{
status
:
OrderStatus
}
>
()
const
{
t
}
=
useI18n
()
const
statusMap
:
Record
<
OrderStatus
,
{
key
:
string
;
class
:
string
}
>
=
{
PENDING
:
{
key
:
'
payment.status.pending
'
,
class
:
'
bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400
'
},
PAID
:
{
key
:
'
payment.status.paid
'
,
class
:
'
bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400
'
},
RECHARGING
:
{
key
:
'
payment.status.recharging
'
,
class
:
'
bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400
'
},
COMPLETED
:
{
key
:
'
payment.status.completed
'
,
class
:
'
bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400
'
},
EXPIRED
:
{
key
:
'
payment.status.expired
'
,
class
:
'
bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400
'
},
CANCELLED
:
{
key
:
'
payment.status.cancelled
'
,
class
:
'
bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400
'
},
FAILED
:
{
key
:
'
payment.status.failed
'
,
class
:
'
bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400
'
},
REFUND_REQUESTED
:
{
key
:
'
payment.status.refund_requested
'
,
class
:
'
bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400
'
},
REFUNDING
:
{
key
:
'
payment.status.refunding
'
,
class
:
'
bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400
'
},
REFUNDED
:
{
key
:
'
payment.status.refunded
'
,
class
:
'
bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400
'
},
PARTIALLY_REFUNDED
:
{
key
:
'
payment.status.partially_refunded
'
,
class
:
'
bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400
'
},
REFUND_FAILED
:
{
key
:
'
payment.status.refund_failed
'
,
class
:
'
bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400
'
},
}
const
statusLabel
=
computed
(()
=>
{
const
entry
=
statusMap
[
props
.
status
]
return
entry
?
t
(
entry
.
key
)
:
props
.
status
})
const
statusClass
=
computed
(()
=>
{
const
entry
=
statusMap
[
props
.
status
]
return
entry
?.
class
??
'
bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400
'
})
</
script
>
frontend/src/components/payment/OrderTable.vue
0 → 100644
View file @
a04ae28a
<
template
>
<DataTable
:columns=
"columns"
:data=
"orders"
:loading=
"loading"
>
<template
#cell-id
="
{ value }">
<span
class=
"font-mono text-sm"
>
#
{{
value
}}
</span>
</
template
>
<
template
#cell-out_trade_no=
"{ value }"
>
<span
class=
"text-sm text-gray-900 dark:text-white"
>
{{
value
}}
</span>
</
template
>
<
template
v-if=
"showUser"
#cell-user_email=
"{ value, row }"
>
<div
class=
"text-sm"
>
<span
class=
"text-gray-900 dark:text-white"
>
{{
value
||
row
.
user_name
||
'
#
'
+
row
.
user_id
}}
</span>
<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 }"
>
<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>
</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 }"
>
<OrderStatusBadge
:status=
"value"
/>
</
template
>
<
template
#cell-created_at=
"{ value }"
>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
formatDate
(
value
)
}}
</span>
</
template
>
<
template
#cell-actions=
"{ row }"
>
<slot
name=
"actions"
:row=
"row"
/>
</
template
>
</DataTable>
</template>
<
script
setup
lang=
"ts"
>
import
{
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
OrderStatusBadge
from
'
@/components/payment/OrderStatusBadge.vue
'
const
{
t
}
=
useI18n
()
const
props
=
defineProps
<
{
orders
:
PaymentOrder
[]
loading
:
boolean
showUser
?:
boolean
}
>
()
function
formatDate
(
dateStr
:
string
)
{
return
new
Date
(
dateStr
).
toLocaleString
()
}
const
columns
=
computed
(():
Column
[]
=>
{
const
cols
:
Column
[]
=
[
{
key
:
'
id
'
,
label
:
t
(
'
payment.orders.orderId
'
)
},
{
key
:
'
out_trade_no
'
,
label
:
t
(
'
payment.orders.orderNo
'
)
},
]
if
(
props
.
showUser
)
{
cols
.
push
({
key
:
'
user_email
'
,
label
:
t
(
'
payment.admin.colUser
'
)
})
}
cols
.
push
(
{
key
:
'
amount
'
,
label
:
t
(
'
payment.orders.amount
'
)
},
{
key
:
'
payment_type
'
,
label
:
t
(
'
payment.orders.paymentMethod
'
)
},
{
key
:
'
status
'
,
label
:
t
(
'
payment.orders.status
'
)
},
{
key
:
'
created_at
'
,
label
:
t
(
'
payment.orders.createdAt
'
)
},
{
key
:
'
actions
'
,
label
:
t
(
'
common.actions
'
)
},
)
return
cols
})
</
script
>
frontend/src/components/payment/PaymentMethodSelector.vue
0 → 100644
View file @
a04ae28a
<
template
>
<div>
<label
class=
"mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
payment.paymentMethod
'
)
}}
</label>
<div
class=
"grid grid-cols-2 gap-3 sm:flex"
>
<button
v-for=
"method in sortedMethods"
:key=
"method.type"
type=
"button"
:disabled=
"!method.available"
:class=
"[
'relative flex h-[60px] flex-col items-center justify-center rounded-lg border px-3 transition-all sm:flex-1',
!method.available
? 'cursor-not-allowed border-gray-200 bg-gray-50 opacity-50 dark:border-dark-700 dark:bg-dark-800/50'
: selected === method.type
? methodSelectedClass(method.type)
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-200 dark:hover:border-dark-500',
]"
@
click=
"method.available && emit('select', method.type)"
>
<span
class=
"flex items-center gap-2"
>
<img
:src=
"methodIcon(method.type)"
:alt=
"t(`payment.methods.$
{method.type}`)" class="h-7 w-7" />
<span
class=
"flex flex-col items-start leading-none"
>
<span
class=
"text-base font-semibold"
>
{{
t
(
`payment.methods.${method.type
}
`
)
}}
<
/span
>
<
span
v
-
if
=
"
method.fee_rate > 0
"
class
=
"
text-[10px] tracking-wide text-gray-500 dark:text-dark-400
"
>
{{
t
(
'
payment.fee
'
)
}}
{{
method
.
fee_rate
}}
%
<
/span
>
<
/span
>
<
/span
>
<
/button
>
<
/div
>
<
/div
>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
METHOD_ORDER
}
from
'
./providerConfig
'
import
alipayIcon
from
'
@/assets/icons/alipay.svg
'
import
wxpayIcon
from
'
@/assets/icons/wxpay.svg
'
import
stripeIcon
from
'
@/assets/icons/stripe.svg
'
export
interface
PaymentMethodOption
{
type
:
string
fee_rate
:
number
available
:
boolean
}
const
props
=
defineProps
<
{
methods
:
PaymentMethodOption
[]
selected
:
string
}
>
()
const
emit
=
defineEmits
<
{
select
:
[
type
:
string
]
}
>
()
const
{
t
}
=
useI18n
()
const
METHOD_ICONS
:
Record
<
string
,
string
>
=
{
alipay
:
alipayIcon
,
wxpay
:
wxpayIcon
,
stripe
:
stripeIcon
,
}
const
sortedMethods
=
computed
(()
=>
{
const
order
:
readonly
string
[]
=
METHOD_ORDER
return
[...
props
.
methods
].
sort
((
a
,
b
)
=>
{
const
ai
=
order
.
indexOf
(
a
.
type
)
const
bi
=
order
.
indexOf
(
b
.
type
)
return
(
ai
===
-
1
?
999
:
ai
)
-
(
bi
===
-
1
?
999
:
bi
)
}
)
}
)
function
methodIcon
(
type
:
string
):
string
{
if
(
type
.
includes
(
'
alipay
'
))
return
METHOD_ICONS
.
alipay
if
(
type
.
includes
(
'
wxpay
'
))
return
METHOD_ICONS
.
wxpay
return
METHOD_ICONS
[
type
]
||
alipayIcon
}
function
methodSelectedClass
(
type
:
string
):
string
{
if
(
type
.
includes
(
'
alipay
'
))
return
'
border-[#02A9F1] bg-blue-50 text-gray-900 shadow-sm dark:bg-blue-950 dark:text-gray-100
'
if
(
type
.
includes
(
'
wxpay
'
))
return
'
border-[#09BB07] bg-green-50 text-gray-900 shadow-sm dark:bg-green-950 dark:text-gray-100
'
if
(
type
===
'
stripe
'
)
return
'
border-[#676BE5] bg-indigo-50 text-gray-900 shadow-sm dark:bg-indigo-950 dark:text-gray-100
'
return
'
border-primary-500 bg-primary-50 text-gray-900 shadow-sm dark:bg-primary-950 dark:text-gray-100
'
}
<
/script
>
frontend/src/components/payment/PaymentProviderDialog.vue
0 → 100644
View file @
a04ae28a
<
template
>
<BaseDialog
:show=
"show"
:title=
"editing ? t('admin.settings.payment.editProvider') : t('admin.settings.payment.createProvider')"
width=
"wide"
@
close=
"emit('close')"
>
<form
id=
"provider-form"
@
submit.prevent=
"handleSave"
class=
"space-y-4"
>
<!-- Name + Key -->
<div
class=
"grid grid-cols-2 gap-4"
>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.settings.payment.providerName
'
)
}}
<span
class=
"text-red-500"
>
*
</span>
</label>
<input
v-model=
"form.name"
type=
"text"
class=
"input"
required
/>
</div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.settings.payment.providerKey
'
)
}}
<span
class=
"text-red-500"
>
*
</span>
</label>
<Select
v-model=
"form.provider_key"
:options=
"(!!editing ? allKeyOptions : enabledKeyOptions) as SelectOption[]"
:disabled=
"!!editing"
@
change=
"onKeyChange"
/>
</div>
</div>
<!-- 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"
/>
<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"
>
<button
v-for=
"mode in paymentModeOptions"
:key=
"mode.value"
type=
"button"
@
click=
"form.payment_mode = mode.value"
:class=
"[
'rounded-lg border px-2.5 py-1 text-xs font-medium transition-all',
form.payment_mode === mode.value
? 'border-primary-500 bg-primary-500 text-white shadow-sm'
: 'border-gray-300 bg-white text-gray-600 hover:border-gray-400 hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300 dark:hover:border-dark-500',
]"
>
{{
mode
.
label
}}
</button>
</div>
</div>
<div
v-if=
"availableTypes.length > 1"
class=
"flex items-center gap-2"
>
<span
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.settings.payment.supportedTypes
'
)
}}
</span>
<div
class=
"flex flex-wrap gap-1.5"
>
<button
v-for=
"pt in availableTypes"
:key=
"pt.value"
type=
"button"
@
click=
"toggleType(pt.value)"
:class=
"[
'rounded-lg border px-2.5 py-1 text-xs font-medium transition-all',
isTypeSelected(pt.value)
? 'border-primary-500 bg-primary-500 text-white shadow-sm'
: 'border-gray-300 bg-white text-gray-600 hover:border-gray-400 hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300 dark:hover:border-dark-500',
]"
>
{{
pt
.
label
}}
</button>
</div>
</div>
</div>
<!-- Config fields -->
<div
class=
"border-t border-gray-200 pt-4 dark:border-dark-700"
>
<h4
class=
"mb-3 text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
admin.settings.payment.providerConfig
'
)
}}
</h4>
<div
class=
"space-y-3"
>
<div
v-for=
"field in resolvedFields"
:key=
"field.key"
>
<label
class=
"input-label"
>
{{
field
.
label
}}
<span
v-if=
"field.optional"
class=
"text-xs text-gray-400"
>
(
{{
t
(
'
common.optional
'
)
}}
)
</span>
<span
v-else
class=
"text-red-500"
>
*
</span>
</label>
<textarea
v-if=
"field.sensitive && field.key.toLowerCase().includes('key') && field.key !== 'pkey'"
v-model=
"config[field.key]"
rows=
"3"
class=
"input font-mono text-xs"
/>
<div
v-else-if=
"field.sensitive"
class=
"relative"
>
<input
:type=
"visibleFields[field.key] ? 'text' : 'password'"
v-model=
"config[field.key]"
class=
"input pr-10"
:placeholder=
"field.defaultValue || ''"
/>
<button
type=
"button"
@
click=
"visibleFields[field.key] = !visibleFields[field.key]"
class=
"absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg
v-if=
"visibleFields[field.key]"
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21"
/></svg>
<svg
v-else
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/></svg>
</button>
</div>
<input
v-else
type=
"text"
v-model=
"config[field.key]"
class=
"input"
:placeholder=
"field.defaultValue || ''"
/>
</div>
</div>
<!-- Callback URLs (each = editable URL + fixed path) -->
<div
v-if=
"callbackPaths"
class=
"mt-4 space-y-3"
>
<div
v-if=
"callbackPaths.notifyUrl"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.settings.payment.field_notifyUrl
'
)
}}
<span
class=
"text-red-500"
>
*
</span></label>
<div
class=
"flex"
>
<input
v-model=
"notifyBaseUrl"
type=
"text"
class=
"input min-w-0 flex-1 !rounded-r-none !border-r-0"
:placeholder=
"defaultBaseUrl"
/>
<span
class=
"inline-flex items-center whitespace-nowrap rounded-r-lg border border-gray-300 bg-gray-50 px-3 text-xs text-gray-500 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-400"
>
{{
callbackPaths
.
notifyUrl
}}
</span>
</div>
</div>
<div
v-if=
"callbackPaths.returnUrl"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.settings.payment.field_returnUrl
'
)
}}
<span
class=
"text-red-500"
>
*
</span></label>
<div
class=
"flex"
>
<input
v-model=
"returnBaseUrl"
type=
"text"
class=
"input min-w-0 flex-1 !rounded-r-none !border-r-0"
:placeholder=
"defaultBaseUrl"
/>
<span
class=
"inline-flex items-center whitespace-nowrap rounded-r-lg border border-gray-300 bg-gray-50 px-3 text-xs text-gray-500 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-400"
>
{{
callbackPaths
.
returnUrl
}}
</span>
</div>
</div>
</div>
<!-- Stripe webhook hint -->
<div
v-if=
"stripeWebhookUrl"
class=
"mt-3 rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-800/50 dark:bg-blue-900/20"
>
<p
class=
"text-xs text-blue-700 dark:text-blue-300"
>
{{
t
(
'
admin.settings.payment.stripeWebhookHint
'
)
}}
</p>
<code
class=
"mt-1 block break-all rounded bg-blue-100 px-2 py-1 text-xs text-blue-800 dark:bg-blue-900/40 dark:text-blue-200"
>
{{
stripeWebhookUrl
}}
</code>
</div>
</div>
<!-- Per-type limits (collapsible) -->
<div
v-if=
"limitableTypes.length"
class=
"border-t border-gray-200 pt-4 dark:border-dark-700"
>
<button
type=
"button"
@
click=
"limitsExpanded = !limitsExpanded"
class=
"flex w-full items-center justify-between"
>
<h4
class=
"text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
admin.settings.payment.limitsTitle
'
)
}}
</h4>
<svg
:class=
"['h-4 w-4 text-gray-400 transition-transform', limitsExpanded && 'rotate-180']"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M19 9l-7 7-7-7"
/></svg>
</button>
<div
v-show=
"limitsExpanded"
class=
"mt-3 space-y-3"
>
<div
v-for=
"lt in limitableTypes"
:key=
"lt.value"
class=
"rounded-lg border border-gray-100 p-3 dark:border-dark-700"
>
<p
class=
"mb-2 text-xs font-medium text-gray-700 dark:text-gray-300"
>
{{
lt
.
label
}}
</p>
<div
class=
"grid grid-cols-3 gap-3"
>
<div>
<label
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.settings.payment.limitSingleMin
'
)
}}
</label>
<input
type=
"number"
:value=
"getLimitVal(lt.value, 'singleMin')"
@
input=
"setLimitVal(lt.value, 'singleMin', ($event.target as HTMLInputElement).value)"
class=
"input mt-0.5"
min=
"1"
step=
"0.01"
:placeholder=
"limitPlaceholder(lt.value)"
/>
</div>
<div>
<label
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.settings.payment.limitSingleMax
'
)
}}
</label>
<input
type=
"number"
:value=
"getLimitVal(lt.value, 'singleMax')"
@
input=
"setLimitVal(lt.value, 'singleMax', ($event.target as HTMLInputElement).value)"
class=
"input mt-0.5"
min=
"1"
step=
"0.01"
:placeholder=
"limitPlaceholder(lt.value)"
/>
</div>
<div>
<label
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.settings.payment.limitDaily
'
)
}}
</label>
<input
type=
"number"
:value=
"getLimitVal(lt.value, 'dailyLimit')"
@
input=
"setLimitVal(lt.value, 'dailyLimit', ($event.target as HTMLInputElement).value)"
class=
"input mt-0.5"
min=
"1"
step=
"0.01"
:placeholder=
"limitPlaceholder(lt.value)"
/>
</div>
</div>
</div>
<p
class=
"text-xs text-gray-400 dark:text-gray-500"
>
{{
t
(
'
admin.settings.payment.limitsHint
'
)
}}
</p>
</div>
</div>
</form>
<template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
type=
"button"
@
click=
"emit('close')"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
type=
"submit"
form=
"provider-form"
:disabled=
"saving"
class=
"btn btn-primary"
>
{{
saving
?
t
(
'
common.saving
'
)
:
t
(
'
common.save
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
</template>
<
script
setup
lang=
"ts"
>
import
{
reactive
,
computed
,
ref
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
type
{
SelectOption
}
from
'
@/components/common/Select.vue
'
import
ToggleSwitch
from
'
./ToggleSwitch.vue
'
import
type
{
ProviderInstance
}
from
'
@/types/payment
'
import
type
{
TypeOption
}
from
'
./providerConfig
'
import
{
PROVIDER_CONFIG_FIELDS
,
PROVIDER_SUPPORTED_TYPES
,
PROVIDER_CALLBACK_PATHS
,
WEBHOOK_PATHS
,
PAYMENT_MODE_QRCODE
,
PAYMENT_MODE_POPUP
,
getAvailableTypes
,
extractBaseUrl
,
}
from
'
./providerConfig
'
const
props
=
defineProps
<
{
show
:
boolean
saving
:
boolean
editing
:
ProviderInstance
|
null
allKeyOptions
:
TypeOption
[]
enabledKeyOptions
:
TypeOption
[]
allPaymentTypes
:
TypeOption
[]
redirectLabel
:
string
}
>
()
const
emit
=
defineEmits
<
{
close
:
[]
save
:
[
payload
:
{
provider_key
:
string
name
:
string
supported_types
:
string
[]
enabled
:
boolean
payment_mode
:
string
refund_enabled
:
boolean
config
:
Record
<
string
,
string
>
limits
:
string
}]
}
>
()
const
{
t
}
=
useI18n
()
// --- Form state ---
const
form
=
reactive
({
name
:
''
,
provider_key
:
'
easypay
'
,
supported_types
:
[]
as
string
[],
enabled
:
true
,
payment_mode
:
PAYMENT_MODE_QRCODE
,
refund_enabled
:
false
,
})
const
config
=
reactive
<
Record
<
string
,
string
>>
({})
const
limits
=
reactive
<
Record
<
string
,
Record
<
string
,
number
>>>
({})
const
notifyBaseUrl
=
ref
(
''
)
const
returnBaseUrl
=
ref
(
''
)
const
limitsExpanded
=
ref
(
false
)
const
visibleFields
=
reactive
<
Record
<
string
,
boolean
>>
({})
// --- Computed ---
const
defaultBaseUrl
=
typeof
window
!==
'
undefined
'
?
window
.
location
.
origin
:
''
const
stripeWebhookUrl
=
computed
(()
=>
form
.
provider_key
===
'
stripe
'
?
defaultBaseUrl
+
WEBHOOK_PATHS
.
stripe
:
''
,
)
const
callbackPaths
=
computed
(()
=>
PROVIDER_CALLBACK_PATHS
[
form
.
provider_key
]
||
null
)
const
paymentModeOptions
=
computed
(()
=>
{
return
[
{
value
:
PAYMENT_MODE_QRCODE
,
label
:
t
(
'
admin.settings.payment.modeQRCode
'
)
},
{
value
:
PAYMENT_MODE_POPUP
,
label
:
t
(
'
admin.settings.payment.modePopup
'
)
},
]
})
const
availableTypes
=
computed
(()
=>
{
const
base
=
getAvailableTypes
(
form
.
provider_key
,
props
.
allPaymentTypes
,
props
.
redirectLabel
)
// Resolve i18n labels for types not in allPaymentTypes (e.g. card, link inside stripe)
return
base
.
map
(
opt
=>
opt
.
label
===
opt
.
value
?
{
...
opt
,
label
:
t
(
`payment.methods.
${
opt
.
value
}
`
,
opt
.
value
)
}
:
opt
,
)
})
const
resolvedFields
=
computed
(()
=>
{
const
fields
=
PROVIDER_CONFIG_FIELDS
[
form
.
provider_key
]
||
[]
return
fields
.
map
(
f
=>
({
...
f
,
label
:
f
.
label
||
t
(
`admin.settings.payment.field_
${
f
.
key
}
`
),
}))
})
const
limitableTypes
=
computed
(()
=>
{
// Stripe: single "stripe" entry (one set of shared limits)
if
(
form
.
provider_key
===
'
stripe
'
)
{
return
[{
value
:
'
stripe
'
,
label
:
'
Stripe
'
}]
}
const
selected
=
form
.
supported_types
.
filter
(
t
=>
t
!==
'
easypay
'
)
return
selected
.
map
(
v
=>
{
const
found
=
props
.
allPaymentTypes
.
find
(
pt
=>
pt
.
value
===
v
)
return
found
||
{
value
:
v
,
label
:
v
}
})
})
// --- Methods ---
function
isTypeSelected
(
type
:
string
):
boolean
{
return
form
.
supported_types
.
includes
(
type
)
}
function
toggleType
(
type
:
string
)
{
if
(
form
.
supported_types
.
includes
(
type
))
{
form
.
supported_types
=
form
.
supported_types
.
filter
(
t
=>
t
!==
type
)
}
else
{
form
.
supported_types
=
[...
form
.
supported_types
,
type
]
}
}
function
onKeyChange
()
{
form
.
supported_types
=
[...(
PROVIDER_SUPPORTED_TYPES
[
form
.
provider_key
]
||
[])]
clearConfig
()
applyDefaults
()
}
function
clearConfig
()
{
Object
.
keys
(
config
).
forEach
(
k
=>
delete
config
[
k
])
Object
.
keys
(
limits
).
forEach
(
k
=>
delete
limits
[
k
])
Object
.
keys
(
visibleFields
).
forEach
(
k
=>
delete
visibleFields
[
k
])
notifyBaseUrl
.
value
=
''
returnBaseUrl
.
value
=
''
limitsExpanded
.
value
=
false
}
function
applyDefaults
()
{
for
(
const
f
of
PROVIDER_CONFIG_FIELDS
[
form
.
provider_key
]
||
[])
{
if
(
f
.
defaultValue
&&
!
config
[
f
.
key
])
config
[
f
.
key
]
=
f
.
defaultValue
}
}
function
getLimitVal
(
paymentType
:
string
,
field
:
string
):
string
{
const
val
=
limits
[
paymentType
]?.[
field
]
return
val
&&
val
>
0
?
String
(
val
)
:
''
}
/** Returns true if any limit field for this payment type has a value */
function
hasAnyLimit
(
paymentType
:
string
):
boolean
{
const
l
=
limits
[
paymentType
]
if
(
!
l
)
return
false
return
(
l
.
singleMin
>
0
)
||
(
l
.
singleMax
>
0
)
||
(
l
.
dailyLimit
>
0
)
}
/** Dynamic placeholder: "不限制" if sibling has value, "使用全局配置" if all empty */
function
limitPlaceholder
(
paymentType
:
string
):
string
{
return
hasAnyLimit
(
paymentType
)
?
t
(
'
admin.settings.payment.limitsNoLimit
'
)
:
t
(
'
admin.settings.payment.limitsUseGlobal
'
)
}
function
setLimitVal
(
paymentType
:
string
,
field
:
string
,
val
:
string
)
{
if
(
!
limits
[
paymentType
])
limits
[
paymentType
]
=
{}
const
num
=
Number
(
val
)
// Empty → clear the field (use global); reject ≤0
if
(
val
===
''
||
isNaN
(
num
))
{
delete
limits
[
paymentType
][
field
]
return
}
if
(
num
<=
0
)
return
limits
[
paymentType
][
field
]
=
num
}
function
serializeLimits
():
string
{
const
result
:
Record
<
string
,
Record
<
string
,
number
>>
=
{}
for
(
const
[
pt
,
fields
]
of
Object
.
entries
(
limits
))
{
const
clean
:
Record
<
string
,
number
>
=
{}
for
(
const
[
k
,
v
]
of
Object
.
entries
(
fields
))
{
if
(
v
>
0
)
clean
[
k
]
=
v
}
if
(
Object
.
keys
(
clean
).
length
>
0
)
result
[
pt
]
=
clean
}
return
Object
.
keys
(
result
).
length
>
0
?
JSON
.
stringify
(
result
)
:
''
}
function
handleSave
()
{
// Validate required fields
if
(
!
form
.
name
.
trim
())
{
emitValidationError
(
t
(
'
admin.settings.payment.validationNameRequired
'
))
return
}
// Validate required config fields — all non-optional fields must be filled
for
(
const
f
of
PROVIDER_CONFIG_FIELDS
[
form
.
provider_key
]
||
[])
{
if
(
f
.
optional
)
continue
const
val
=
(
config
[
f
.
key
]
||
''
).
trim
()
if
(
!
val
)
{
const
label
=
f
.
label
||
t
(
`admin.settings.payment.field_
${
f
.
key
}
`
)
emitValidationError
(
t
(
'
admin.settings.payment.validationFieldRequired
'
,
{
field
:
label
}))
return
}
}
const
filteredConfig
:
Record
<
string
,
string
>
=
{}
for
(
const
[
k
,
v
]
of
Object
.
entries
(
config
))
{
if
(
!
v
||
!
v
.
trim
())
continue
// Skip masked values — backend keeps existing credentials
if
(
v
===
'
••••••••
'
)
continue
filteredConfig
[
k
]
=
v
}
// Inject computed callback URLs (each URL = independent base + fixed path)
// If base URL is empty, auto-fill with current domain
const
paths
=
PROVIDER_CALLBACK_PATHS
[
form
.
provider_key
]
if
(
paths
)
{
const
notifyBase
=
notifyBaseUrl
.
value
.
trim
()
||
defaultBaseUrl
const
returnBase
=
returnBaseUrl
.
value
.
trim
()
||
defaultBaseUrl
notifyBaseUrl
.
value
=
notifyBase
returnBaseUrl
.
value
=
returnBase
if
(
paths
.
notifyUrl
)
filteredConfig
[
'
notifyUrl
'
]
=
notifyBase
+
paths
.
notifyUrl
if
(
paths
.
returnUrl
)
filteredConfig
[
'
returnUrl
'
]
=
returnBase
+
paths
.
returnUrl
}
emit
(
'
save
'
,
{
provider_key
:
form
.
provider_key
,
name
:
form
.
name
,
supported_types
:
form
.
supported_types
,
enabled
:
form
.
enabled
,
payment_mode
:
form
.
provider_key
===
'
easypay
'
?
form
.
payment_mode
:
''
,
refund_enabled
:
form
.
refund_enabled
,
config
:
filteredConfig
,
limits
:
serializeLimits
(),
})
}
function
emitValidationError
(
msg
:
string
)
{
// Use a custom event or inject appStore — for now use window alert fallback
// The parent handles this via the save event validation
import
(
'
@/stores
'
).
then
(
m
=>
m
.
useAppStore
().
showError
(
msg
))
}
// --- Public API for parent to call ---
function
reset
(
defaultKey
:
string
)
{
form
.
name
=
''
form
.
provider_key
=
defaultKey
form
.
supported_types
=
[...(
PROVIDER_SUPPORTED_TYPES
[
defaultKey
]
||
[])]
form
.
enabled
=
true
form
.
payment_mode
=
defaultKey
===
'
easypay
'
?
PAYMENT_MODE_QRCODE
:
''
form
.
refund_enabled
=
false
clearConfig
()
applyDefaults
()
}
function
loadProvider
(
provider
:
ProviderInstance
)
{
form
.
name
=
provider
.
name
form
.
provider_key
=
provider
.
provider_key
form
.
supported_types
=
provider
.
supported_types
form
.
enabled
=
provider
.
enabled
form
.
payment_mode
=
provider
.
payment_mode
||
(
provider
.
provider_key
===
'
easypay
'
?
PAYMENT_MODE_QRCODE
:
''
)
form
.
refund_enabled
=
provider
.
refund_enabled
clearConfig
()
// Pre-fill config from API response (non-sensitive in cleartext, sensitive masked as ••••••••)
if
(
provider
.
config
)
{
for
(
const
[
k
,
v
]
of
Object
.
entries
(
provider
.
config
))
{
// Skip notifyUrl/returnUrl — they are derived from callbackBaseUrl
if
(
k
===
'
notifyUrl
'
||
k
===
'
returnUrl
'
)
continue
config
[
k
]
=
v
}
// Extract base URLs from existing callback URLs
const
paths
=
PROVIDER_CALLBACK_PATHS
[
provider
.
provider_key
]
if
(
paths
?.
notifyUrl
&&
provider
.
config
[
'
notifyUrl
'
])
{
notifyBaseUrl
.
value
=
extractBaseUrl
(
provider
.
config
[
'
notifyUrl
'
],
paths
.
notifyUrl
)
}
if
(
paths
?.
returnUrl
&&
provider
.
config
[
'
returnUrl
'
])
{
returnBaseUrl
.
value
=
extractBaseUrl
(
provider
.
config
[
'
returnUrl
'
],
paths
.
returnUrl
)
}
}
applyDefaults
()
// Parse existing limits
if
(
provider
.
limits
)
{
try
{
const
parsed
=
JSON
.
parse
(
provider
.
limits
)
for
(
const
[
pt
,
fields
]
of
Object
.
entries
(
parsed
as
Record
<
string
,
Record
<
string
,
number
>>
))
{
limits
[
pt
]
=
{
...
fields
}
}
limitsExpanded
.
value
=
Object
.
keys
(
limits
).
length
>
0
}
catch
{
/* ignore */
}
}
}
defineExpose
({
reset
,
loadProvider
})
</
script
>
frontend/src/components/payment/PaymentProviderList.vue
0 → 100644
View file @
a04ae28a
<
template
>
<div
class=
"card"
>
<!-- Header -->
<div
class=
"border-b border-gray-100 px-4 py-3 dark:border-dark-700"
>
<div
class=
"flex items-center justify-between"
>
<div>
<h2
class=
"text-base font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
admin.settings.payment.providerManagement
'
)
}}
</h2>
<p
class=
"mt-0.5 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.settings.payment.providerManagementDesc
'
)
}}
</p>
</div>
<div
class=
"flex items-center gap-2"
>
<button
type=
"button"
@
click=
"emit('refresh')"
:disabled=
"loading"
class=
"btn btn-secondary btn-sm"
:title=
"t('common.refresh')"
>
<Icon
name=
"refresh"
size=
"sm"
:class=
"loading ? 'animate-spin' : ''"
/>
</button>
<button
type=
"button"
@
click=
"emit('create')"
:disabled=
"!canCreate"
:class=
"canCreate
? 'btn btn-primary btn-sm'
: 'btn btn-secondary btn-sm cursor-not-allowed opacity-50'"
>
{{
t
(
'
admin.settings.payment.createProvider
'
)
}}
</button>
</div>
</div>
</div>
<!-- List -->
<div
class=
"p-4"
>
<!-- Loading -->
<div
v-if=
"loading && !providers.length"
class=
"flex items-center justify-center py-6"
>
<div
class=
"h-5 w-5 animate-spin rounded-full border-2 border-primary-500 border-t-transparent"
/>
</div>
<!-- Provider cards (draggable) -->
<VueDraggable
v-if=
"providers.length"
v-model=
"localProviders"
:animation=
"200"
handle=
".drag-handle"
class=
"space-y-3"
@
end=
"onDragEnd"
>
<div
v-for=
"p in localProviders"
:key=
"p.id"
class=
"flex items-start gap-2"
>
<div
class=
"drag-handle mt-3 flex cursor-grab items-center text-gray-300 hover:text-gray-500 active:cursor-grabbing dark:text-dark-600 dark:hover:text-dark-400"
>
<svg
class=
"h-5 w-5"
viewBox=
"0 0 20 20"
fill=
"currentColor"
>
<path
d=
"M7 2a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM13 2a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM7 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM13 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM7 14a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM13 14a2 2 0 1 0 0 4 2 2 0 0 0 0-4z"
/>
</svg>
</div>
<div
class=
"min-w-0 flex-1"
>
<ProviderCard
:provider=
"p"
:enabled=
"isEnabled(p.provider_key)"
:available-types=
"getTypes(p.provider_key)"
@
toggle-field=
"(field) => emit('toggleField', p, field)"
@
toggle-type=
"(type) => emit('toggleType', p, type)"
@
edit=
"emit('edit', p)"
@
delete=
"emit('delete', p)"
/>
</div>
</div>
</VueDraggable>
<!-- Empty -->
<div
v-else-if=
"!loading"
class=
"py-6 text-center"
>
<p
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{
canCreate
?
t
(
'
admin.settings.payment.noProviders
'
)
:
t
(
'
admin.settings.payment.enableTypesFirst
'
)
}}
</p>
<button
type=
"button"
v-if=
"canCreate"
@
click=
"emit('create')"
class=
"btn btn-primary btn-sm mt-2"
>
{{
t
(
'
admin.settings.payment.createProvider
'
)
}}
</button>
</div>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
VueDraggable
}
from
'
vue-draggable-plus
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
ProviderCard
from
'
./ProviderCard.vue
'
import
type
{
ProviderInstance
}
from
'
@/types/payment
'
import
type
{
TypeOption
}
from
'
./providerConfig
'
import
{
getAvailableTypes
}
from
'
./providerConfig
'
const
props
=
defineProps
<
{
providers
:
ProviderInstance
[]
loading
:
boolean
canCreate
:
boolean
enabledPaymentTypes
:
string
[]
allPaymentTypes
:
TypeOption
[]
redirectLabel
:
string
}
>
()
const
emit
=
defineEmits
<
{
refresh
:
[]
create
:
[]
edit
:
[
provider
:
ProviderInstance
]
delete
:
[
provider
:
ProviderInstance
]
toggleField
:
[
provider
:
ProviderInstance
,
field
:
'
enabled
'
|
'
refund_enabled
'
]
toggleType
:
[
provider
:
ProviderInstance
,
type
:
string
]
reorder
:
[
providers
:
{
id
:
number
;
sort_order
:
number
}[]]
}
>
()
const
{
t
}
=
useI18n
()
const
localProviders
=
ref
<
ProviderInstance
[]
>
([])
watch
(()
=>
props
.
providers
,
(
val
)
=>
{
localProviders
.
value
=
[...
val
]
},
{
immediate
:
true
})
function
onDragEnd
()
{
const
updates
=
localProviders
.
value
.
map
((
p
,
idx
)
=>
({
id
:
p
.
id
,
sort_order
:
idx
,
}))
emit
(
'
reorder
'
,
updates
)
}
function
isEnabled
(
providerKey
:
string
):
boolean
{
return
props
.
enabledPaymentTypes
.
includes
(
providerKey
)
}
function
getTypes
(
providerKey
:
string
):
TypeOption
[]
{
return
getAvailableTypes
(
providerKey
,
props
.
allPaymentTypes
,
props
.
redirectLabel
)
.
map
(
opt
=>
opt
.
label
===
opt
.
value
?
{
...
opt
,
label
:
t
(
`payment.methods.
${
opt
.
value
}
`
,
opt
.
value
)
}
:
opt
,
)
}
</
script
>
frontend/src/components/payment/PaymentQRDialog.vue
0 → 100644
View file @
a04ae28a
<
template
>
<BaseDialog
:show=
"show"
:title=
"dialogTitle"
width=
"narrow"
@
close=
"handleClose"
>
<!-- QR Code + Polling State -->
<div
v-if=
"!success"
class=
"flex flex-col items-center space-y-4"
>
<!-- QR Code mode -->
<template
v-if=
"qrUrl"
>
<div
class=
"rounded-2xl bg-white p-4 shadow-sm dark:bg-dark-800"
>
<canvas
ref=
"qrCanvas"
class=
"mx-auto"
></canvas>
</div>
<p
v-if=
"scanHint"
class=
"text-center text-sm text-gray-500 dark:text-gray-400"
>
{{
scanHint
}}
</p>
</
template
>
<!-- Popup window waiting mode (no QR code) -->
<
template
v-else
>
<div
class=
"flex flex-col items-center py-4"
>
<div
class=
"h-10 w-10 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"
></div>
<p
class=
"mt-4 text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.qr.payInNewWindowHint
'
)
}}
</p>
<button
v-if=
"payUrl"
class=
"btn btn-secondary mt-3 text-sm"
@
click=
"reopenPopup"
>
{{
t
(
'
payment.qr.openPayWindow
'
)
}}
</button>
</div>
</
template
>
<!-- Countdown -->
<div
v-if=
"expired"
class=
"text-center"
>
<p
class=
"text-lg font-medium text-red-500"
>
{{ t('payment.qr.expired') }}
</p>
</div>
<div
v-else
class=
"text-center"
>
<p
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{ qrUrl ? t('payment.qr.expiresIn') : '' }}
</p>
<p
class=
"mt-1 text-2xl font-bold tabular-nums text-gray-900 dark:text-white"
>
{{ countdownDisplay }}
</p>
<p
class=
"mt-1 text-xs text-gray-400 dark:text-gray-500"
>
{{ t('payment.qr.waitingPayment') }}
</p>
</div>
</div>
<!-- Success State -->
<div
v-else
class=
"flex flex-col items-center space-y-4 py-4"
>
<div
class=
"flex h-16 w-16 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30"
>
<Icon
name=
"check"
size=
"lg"
class=
"text-green-500"
/>
</div>
<p
class=
"text-lg font-bold text-gray-900 dark:text-white"
>
{{ t('payment.result.success') }}
</p>
<div
v-if=
"paidOrder"
class=
"w-full rounded-xl bg-gray-50 p-4 dark:bg-dark-800"
>
<div
class=
"space-y-2 text-sm"
>
<div
class=
"flex justify-between"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{ t('payment.orders.orderId') }}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
#{{ paidOrder.id }}
</span>
</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>
</div>
</div>
</div>
</div>
<
template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
v-if=
"!success && !expired"
class=
"btn btn-secondary"
:disabled=
"cancelling"
@
click=
"handleCancel"
>
{{
cancelling
?
t
(
'
common.processing
'
)
:
t
(
'
payment.qr.cancelOrder
'
)
}}
</button>
<button
v-if=
"success"
class=
"btn btn-primary"
@
click=
"handleDone"
>
{{
t
(
'
common.confirm
'
)
}}
</button>
<button
v-if=
"expired"
class=
"btn btn-primary"
@
click=
"handleClose"
>
{{
t
(
'
payment.result.backToRecharge
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
watch
,
onUnmounted
,
nextTick
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
usePaymentStore
}
from
'
@/stores/payment
'
import
{
useAppStore
}
from
'
@/stores
'
import
{
paymentAPI
}
from
'
@/api/payment
'
import
{
extractApiErrorMessage
}
from
'
@/utils/apiError
'
import
{
POPUP_WINDOW_FEATURES
}
from
'
@/components/payment/providerConfig
'
import
type
{
PaymentOrder
}
from
'
@/types/payment
'
import
QRCode
from
'
qrcode
'
import
alipayIcon
from
'
@/assets/icons/alipay.svg
'
import
wxpayIcon
from
'
@/assets/icons/wxpay.svg
'
const
props
=
defineProps
<
{
show
:
boolean
orderId
:
number
qrCode
:
string
expiresAt
:
string
paymentType
:
string
/** URL for reopening the payment popup window */
payUrl
?:
string
}
>
()
const
emit
=
defineEmits
<
{
close
:
[]
success
:
[]
}
>
()
const
{
t
}
=
useI18n
()
const
paymentStore
=
usePaymentStore
()
const
appStore
=
useAppStore
()
const
qrCanvas
=
ref
<
HTMLCanvasElement
|
null
>
(
null
)
const
qrUrl
=
ref
(
''
)
const
remainingSeconds
=
ref
(
0
)
const
expired
=
ref
(
false
)
const
cancelling
=
ref
(
false
)
const
success
=
ref
(
false
)
const
paidOrder
=
ref
<
PaymentOrder
|
null
>
(
null
)
let
pollTimer
:
ReturnType
<
typeof
setInterval
>
|
null
=
null
let
countdownTimer
:
ReturnType
<
typeof
setInterval
>
|
null
=
null
const
isAlipay
=
computed
(()
=>
props
.
paymentType
.
includes
(
'
alipay
'
))
const
isWxpay
=
computed
(()
=>
props
.
paymentType
.
includes
(
'
wxpay
'
))
const
dialogTitle
=
computed
(()
=>
{
if
(
success
.
value
)
return
t
(
'
payment.result.success
'
)
if
(
!
qrUrl
.
value
)
return
t
(
'
payment.qr.payInNewWindow
'
)
if
(
isAlipay
.
value
)
return
t
(
'
payment.qr.scanAlipay
'
)
if
(
isWxpay
.
value
)
return
t
(
'
payment.qr.scanWxpay
'
)
return
t
(
'
payment.qr.scanToPay
'
)
})
const
scanHint
=
computed
(()
=>
{
if
(
isAlipay
.
value
)
return
t
(
'
payment.qr.scanAlipayHint
'
)
if
(
isWxpay
.
value
)
return
t
(
'
payment.qr.scanWxpayHint
'
)
return
''
})
const
countdownDisplay
=
computed
(()
=>
{
const
m
=
Math
.
floor
(
remainingSeconds
.
value
/
60
)
const
s
=
remainingSeconds
.
value
%
60
return
m
.
toString
().
padStart
(
2
,
'
0
'
)
+
'
:
'
+
s
.
toString
().
padStart
(
2
,
'
0
'
)
})
function
getLogoForType
():
string
|
null
{
if
(
isAlipay
.
value
)
return
alipayIcon
if
(
isWxpay
.
value
)
return
wxpayIcon
return
null
}
function
reopenPopup
()
{
if
(
props
.
payUrl
)
{
window
.
open
(
props
.
payUrl
,
'
paymentPopup
'
,
POPUP_WINDOW_FEATURES
)
}
}
async
function
renderQR
()
{
await
nextTick
()
if
(
!
qrCanvas
.
value
||
!
qrUrl
.
value
)
return
const
logoSrc
=
getLogoForType
()
await
QRCode
.
toCanvas
(
qrCanvas
.
value
,
qrUrl
.
value
,
{
width
:
220
,
margin
:
2
,
errorCorrectionLevel
:
logoSrc
?
'
H
'
:
'
M
'
,
})
if
(
!
logoSrc
)
return
const
canvas
=
qrCanvas
.
value
const
ctx
=
canvas
.
getContext
(
'
2d
'
)
if
(
!
ctx
)
return
const
img
=
new
Image
()
img
.
src
=
logoSrc
img
.
onload
=
()
=>
{
const
logoSize
=
40
const
x
=
(
canvas
.
width
-
logoSize
)
/
2
const
y
=
(
canvas
.
height
-
logoSize
)
/
2
const
pad
=
4
ctx
.
fillStyle
=
'
#FFFFFF
'
ctx
.
beginPath
()
const
r
=
5
ctx
.
moveTo
(
x
-
pad
+
r
,
y
-
pad
)
ctx
.
arcTo
(
x
+
logoSize
+
pad
,
y
-
pad
,
x
+
logoSize
+
pad
,
y
+
logoSize
+
pad
,
r
)
ctx
.
arcTo
(
x
+
logoSize
+
pad
,
y
+
logoSize
+
pad
,
x
-
pad
,
y
+
logoSize
+
pad
,
r
)
ctx
.
arcTo
(
x
-
pad
,
y
+
logoSize
+
pad
,
x
-
pad
,
y
-
pad
,
r
)
ctx
.
arcTo
(
x
-
pad
,
y
-
pad
,
x
+
logoSize
+
pad
,
y
-
pad
,
r
)
ctx
.
fill
()
ctx
.
drawImage
(
img
,
x
,
y
,
logoSize
,
logoSize
)
}
}
async
function
pollStatus
()
{
if
(
!
props
.
orderId
)
return
const
order
=
await
paymentStore
.
pollOrderStatus
(
props
.
orderId
)
if
(
!
order
)
return
if
(
order
.
status
===
'
COMPLETED
'
||
order
.
status
===
'
PAID
'
)
{
cleanup
()
paidOrder
.
value
=
order
success
.
value
=
true
emit
(
'
success
'
)
}
else
if
(
order
.
status
===
'
EXPIRED
'
||
order
.
status
===
'
CANCELLED
'
||
order
.
status
===
'
FAILED
'
)
{
cleanup
()
expired
.
value
=
true
}
}
function
startCountdown
(
seconds
:
number
)
{
remainingSeconds
.
value
=
Math
.
max
(
0
,
seconds
)
if
(
remainingSeconds
.
value
<=
0
)
{
expired
.
value
=
true
return
}
countdownTimer
=
setInterval
(()
=>
{
remainingSeconds
.
value
--
if
(
remainingSeconds
.
value
<=
0
)
{
expired
.
value
=
true
cleanup
()
}
},
1000
)
}
async
function
handleCancel
()
{
if
(
!
props
.
orderId
||
cancelling
.
value
)
return
cancelling
.
value
=
true
try
{
await
paymentAPI
.
cancelOrder
(
props
.
orderId
)
cleanup
()
emit
(
'
close
'
)
}
catch
(
err
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
err
,
t
(
'
common.error
'
)))
}
finally
{
cancelling
.
value
=
false
}
}
function
handleClose
()
{
cleanup
()
emit
(
'
close
'
)
}
function
handleDone
()
{
cleanup
()
emit
(
'
close
'
)
}
function
cleanup
()
{
if
(
pollTimer
)
{
clearInterval
(
pollTimer
);
pollTimer
=
null
}
if
(
countdownTimer
)
{
clearInterval
(
countdownTimer
);
countdownTimer
=
null
}
}
function
init
()
{
// Reset state
success
.
value
=
false
paidOrder
.
value
=
null
expired
.
value
=
false
cancelling
.
value
=
false
qrUrl
.
value
=
props
.
qrCode
let
seconds
=
30
*
60
if
(
props
.
expiresAt
)
{
const
expiresAt
=
new
Date
(
props
.
expiresAt
)
seconds
=
Math
.
floor
((
expiresAt
.
getTime
()
-
Date
.
now
())
/
1000
)
}
startCountdown
(
seconds
)
pollTimer
=
setInterval
(
pollStatus
,
3000
)
renderQR
()
}
// Watch for dialog open/close
watch
(()
=>
props
.
show
,
(
isOpen
)
=>
{
if
(
isOpen
)
{
init
()
}
else
{
cleanup
()
}
})
watch
(
qrUrl
,
()
=>
renderQR
())
onUnmounted
(()
=>
cleanup
())
</
script
>
frontend/src/components/payment/PaymentStatusPanel.vue
0 → 100644
View file @
a04ae28a
<
template
>
<div
class=
"space-y-4"
>
<!-- ═══ Terminal States: show result, user clicks to return ═══ -->
<!-- Success -->
<template
v-if=
"outcome === 'success'"
>
<div
class=
"card p-6"
>
<div
class=
"flex flex-col items-center space-y-4 py-4"
>
<div
class=
"flex h-16 w-16 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30"
>
<Icon
name=
"check"
size=
"lg"
class=
"text-green-500"
/>
</div>
<p
class=
"text-lg font-bold text-gray-900 dark:text-white"
>
{{
props
.
orderType
===
'
subscription
'
?
t
(
'
payment.result.subscriptionSuccess
'
)
:
t
(
'
payment.result.success
'
)
}}
</p>
<div
v-if=
"paidOrder"
class=
"w-full rounded-xl bg-gray-50 p-4 dark:bg-dark-800"
>
<div
class=
"space-y-2 text-sm"
>
<div
class=
"flex justify-between"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.orderId
'
)
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
#
{{
paidOrder
.
id
}}
</span>
</div>
<div
v-if=
"paidOrder.out_trade_no"
class=
"flex justify-between"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.orderNo
'
)
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
paidOrder
.
out_trade_no
}}
</span>
</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>
</div>
</div>
</div>
<button
class=
"btn btn-primary"
@
click=
"handleDone"
>
{{
t
(
'
common.confirm
'
)
}}
</button>
</div>
</div>
</
template
>
<!-- Cancelled -->
<
template
v-else-if=
"outcome === 'cancelled'"
>
<div
class=
"card p-6"
>
<div
class=
"flex flex-col items-center space-y-4 py-4"
>
<div
class=
"flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700"
>
<svg
class=
"h-8 w-8 text-gray-400 dark:text-gray-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
<p
class=
"text-lg font-bold text-gray-900 dark:text-white"
>
{{
t
(
'
payment.qr.cancelled
'
)
}}
</p>
<p
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.qr.cancelledDesc
'
)
}}
</p>
<button
class=
"btn btn-primary"
@
click=
"handleDone"
>
{{
t
(
'
common.confirm
'
)
}}
</button>
</div>
</div>
</
template
>
<!-- Expired / Failed -->
<
template
v-else-if=
"outcome === 'expired'"
>
<div
class=
"card p-6"
>
<div
class=
"flex flex-col items-center space-y-4 py-4"
>
<div
class=
"flex h-16 w-16 items-center justify-center rounded-full bg-orange-100 dark:bg-orange-900/30"
>
<svg
class=
"h-8 w-8 text-orange-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<p
class=
"text-lg font-bold text-gray-900 dark:text-white"
>
{{
t
(
'
payment.qr.expired
'
)
}}
</p>
<p
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.qr.expiredDesc
'
)
}}
</p>
<button
class=
"btn btn-primary"
@
click=
"handleDone"
>
{{
t
(
'
common.confirm
'
)
}}
</button>
</div>
</div>
</
template
>
<!-- ═══ Active States: QR or Popup waiting ═══ -->
<!-- QR Code Mode -->
<
template
v-else-if=
"qrUrl"
>
<div
class=
"card p-6"
>
<div
class=
"flex flex-col items-center space-y-4"
>
<p
class=
"text-lg font-semibold text-gray-900 dark:text-white"
>
{{
scanTitle
}}
</p>
<div
:class=
"['relative rounded-lg border-2 p-4', qrBorderClass]"
>
<canvas
ref=
"qrCanvas"
class=
"mx-auto"
></canvas>
<!-- Brand logo overlay -->
<div
class=
"pointer-events-none absolute inset-0 flex items-center justify-center"
>
<span
:class=
"['rounded-full p-2 shadow ring-2 ring-white', qrLogoBgClass]"
>
<img
:src=
"isAlipay ? alipayIcon : wxpayIcon"
alt=
""
class=
"h-5 w-5 brightness-0 invert"
/>
</span>
</div>
</div>
<p
v-if=
"scanHint"
class=
"text-center text-sm text-gray-500 dark:text-gray-400"
>
{{
scanHint
}}
</p>
</div>
</div>
<div
class=
"card p-4 text-center"
>
<p
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.qr.expiresIn
'
)
}}
</p>
<p
class=
"mt-1 text-2xl font-bold tabular-nums text-gray-900 dark:text-white"
>
{{
countdownDisplay
}}
</p>
<p
class=
"mt-1 text-xs text-gray-400 dark:text-gray-500"
>
{{
t
(
'
payment.qr.waitingPayment
'
)
}}
</p>
</div>
<button
class=
"btn btn-secondary w-full"
:disabled=
"cancelling"
@
click=
"handleCancel"
>
{{
cancelling
?
t
(
'
common.processing
'
)
:
t
(
'
payment.qr.cancelOrder
'
)
}}
</button>
</
template
>
<!-- Waiting for Popup/Redirect Mode -->
<
template
v-else
>
<div
class=
"card p-6"
>
<div
class=
"flex flex-col items-center space-y-4 py-4"
>
<div
class=
"h-10 w-10 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"
></div>
<p
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.qr.payInNewWindowHint
'
)
}}
</p>
<button
v-if=
"payUrl"
class=
"btn btn-secondary text-sm"
@
click=
"reopenPopup"
>
{{
t
(
'
payment.qr.openPayWindow
'
)
}}
</button>
</div>
</div>
<div
class=
"card p-4 text-center"
>
<p
class=
"mt-1 text-2xl font-bold tabular-nums text-gray-900 dark:text-white"
>
{{
countdownDisplay
}}
</p>
<p
class=
"mt-1 text-xs text-gray-400 dark:text-gray-500"
>
{{
t
(
'
payment.qr.waitingPayment
'
)
}}
</p>
</div>
<button
class=
"btn btn-secondary w-full"
:disabled=
"cancelling"
@
click=
"handleCancel"
>
{{
cancelling
?
t
(
'
common.processing
'
)
:
t
(
'
payment.qr.cancelOrder
'
)
}}
</button>
</
template
>
</div>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
watch
,
onUnmounted
,
nextTick
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
usePaymentStore
}
from
'
@/stores/payment
'
import
{
useAppStore
}
from
'
@/stores
'
import
{
paymentAPI
}
from
'
@/api/payment
'
import
{
extractApiErrorMessage
}
from
'
@/utils/apiError
'
import
{
POPUP_WINDOW_FEATURES
}
from
'
@/components/payment/providerConfig
'
import
type
{
PaymentOrder
}
from
'
@/types/payment
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
QRCode
from
'
qrcode
'
import
alipayIcon
from
'
@/assets/icons/alipay.svg
'
import
wxpayIcon
from
'
@/assets/icons/wxpay.svg
'
const
props
=
defineProps
<
{
orderId
:
number
qrCode
:
string
expiresAt
:
string
paymentType
:
string
payUrl
?:
string
orderType
?:
string
}
>
()
const
emit
=
defineEmits
<
{
done
:
[];
success
:
[]
}
>
()
const
{
t
}
=
useI18n
()
const
paymentStore
=
usePaymentStore
()
const
appStore
=
useAppStore
()
const
qrCanvas
=
ref
<
HTMLCanvasElement
|
null
>
(
null
)
const
qrUrl
=
ref
(
''
)
const
remainingSeconds
=
ref
(
0
)
const
cancelling
=
ref
(
false
)
const
paidOrder
=
ref
<
PaymentOrder
|
null
>
(
null
)
// Terminal outcome: null = still active, 'success' | 'cancelled' | 'expired'
const
outcome
=
ref
<
'
success
'
|
'
cancelled
'
|
'
expired
'
|
null
>
(
null
)
let
pollTimer
:
ReturnType
<
typeof
setInterval
>
|
null
=
null
let
countdownTimer
:
ReturnType
<
typeof
setInterval
>
|
null
=
null
const
isAlipay
=
computed
(()
=>
props
.
paymentType
.
includes
(
'
alipay
'
))
const
isWxpay
=
computed
(()
=>
props
.
paymentType
.
includes
(
'
wxpay
'
))
const
qrBorderClass
=
computed
(()
=>
{
if
(
isAlipay
.
value
)
return
'
border-[#00AEEF] bg-blue-50 dark:border-[#00AEEF]/70 dark:bg-blue-950/20
'
if
(
isWxpay
.
value
)
return
'
border-[#2BB741] bg-green-50 dark:border-[#2BB741]/70 dark:bg-green-950/20
'
return
'
border-gray-200 bg-white dark:border-dark-600 dark:bg-dark-800
'
})
const
qrLogoBgClass
=
computed
(()
=>
{
if
(
isAlipay
.
value
)
return
'
bg-[#00AEEF]
'
if
(
isWxpay
.
value
)
return
'
bg-[#2BB741]
'
return
'
bg-gray-400
'
})
const
scanTitle
=
computed
(()
=>
{
if
(
isAlipay
.
value
)
return
t
(
'
payment.qr.scanAlipay
'
)
if
(
isWxpay
.
value
)
return
t
(
'
payment.qr.scanWxpay
'
)
return
t
(
'
payment.qr.scanToPay
'
)
})
const
scanHint
=
computed
(()
=>
{
if
(
isAlipay
.
value
)
return
t
(
'
payment.qr.scanAlipayHint
'
)
if
(
isWxpay
.
value
)
return
t
(
'
payment.qr.scanWxpayHint
'
)
return
''
})
const
countdownDisplay
=
computed
(()
=>
{
const
m
=
Math
.
floor
(
remainingSeconds
.
value
/
60
)
const
s
=
remainingSeconds
.
value
%
60
return
m
.
toString
().
padStart
(
2
,
'
0
'
)
+
'
:
'
+
s
.
toString
().
padStart
(
2
,
'
0
'
)
})
function
reopenPopup
()
{
if
(
props
.
payUrl
)
{
window
.
open
(
props
.
payUrl
,
'
paymentPopup
'
,
POPUP_WINDOW_FEATURES
)
}
}
async
function
renderQR
()
{
await
nextTick
()
if
(
!
qrCanvas
.
value
||
!
qrUrl
.
value
)
return
await
QRCode
.
toCanvas
(
qrCanvas
.
value
,
qrUrl
.
value
,
{
width
:
220
,
margin
:
2
,
errorCorrectionLevel
:
'
H
'
,
})
}
async
function
pollStatus
()
{
if
(
!
props
.
orderId
||
outcome
.
value
)
return
const
order
=
await
paymentStore
.
pollOrderStatus
(
props
.
orderId
)
if
(
!
order
)
return
if
(
order
.
status
===
'
COMPLETED
'
||
order
.
status
===
'
PAID
'
)
{
cleanup
()
paidOrder
.
value
=
order
outcome
.
value
=
'
success
'
emit
(
'
success
'
)
}
else
if
(
order
.
status
===
'
CANCELLED
'
)
{
cleanup
()
outcome
.
value
=
'
cancelled
'
}
else
if
(
order
.
status
===
'
EXPIRED
'
||
order
.
status
===
'
FAILED
'
)
{
cleanup
()
outcome
.
value
=
'
expired
'
}
}
function
startCountdown
(
seconds
:
number
)
{
remainingSeconds
.
value
=
Math
.
max
(
0
,
seconds
)
if
(
remainingSeconds
.
value
<=
0
)
{
outcome
.
value
=
'
expired
'
;
return
}
countdownTimer
=
setInterval
(()
=>
{
remainingSeconds
.
value
--
if
(
remainingSeconds
.
value
<=
0
)
{
outcome
.
value
=
'
expired
'
;
cleanup
()
}
},
1000
)
}
async
function
handleCancel
()
{
if
(
!
props
.
orderId
||
cancelling
.
value
)
return
cancelling
.
value
=
true
try
{
await
paymentAPI
.
cancelOrder
(
props
.
orderId
)
cleanup
()
outcome
.
value
=
'
cancelled
'
}
catch
(
err
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
err
,
t
(
'
common.error
'
)))
}
finally
{
cancelling
.
value
=
false
}
}
function
handleDone
()
{
cleanup
();
emit
(
'
done
'
)
}
function
cleanup
()
{
if
(
pollTimer
)
{
clearInterval
(
pollTimer
);
pollTimer
=
null
}
if
(
countdownTimer
)
{
clearInterval
(
countdownTimer
);
countdownTimer
=
null
}
}
// Initialize on mount
qrUrl
.
value
=
props
.
qrCode
let
seconds
=
30
*
60
if
(
props
.
expiresAt
)
{
seconds
=
Math
.
floor
((
new
Date
(
props
.
expiresAt
).
getTime
()
-
Date
.
now
())
/
1000
)
}
startCountdown
(
seconds
)
pollTimer
=
setInterval
(
pollStatus
,
3000
)
renderQR
()
watch
(()
=>
qrUrl
.
value
,
()
=>
renderQR
())
onUnmounted
(()
=>
cleanup
())
</
script
>
frontend/src/components/payment/ProviderCard.vue
0 → 100644
View file @
a04ae28a
<
template
>
<div
:class=
"[
'group relative rounded-lg border transition-all',
enabled ? 'border-gray-200 dark:border-dark-600' : 'border-gray-200 bg-gray-50 opacity-50 dark:border-dark-700 dark:bg-dark-800/50',
]"
:title=
"!enabled ? t('admin.settings.payment.typeDisabled') + ' — ' + t('admin.settings.payment.enableTypesFirst') : undefined"
>
<div
:class=
"[
'flex items-center justify-between px-4 py-2.5',
!enabled && 'pointer-events-none',
]"
>
<!-- Left: icon + name + key badge + type badges -->
<div
class=
"flex items-center gap-3"
>
<div
:class=
"[
'rounded-md p-1.5',
provider.enabled && enabled ? 'bg-green-100 dark:bg-green-900/30' : 'bg-gray-100 dark:bg-dark-700',
]"
>
<Icon
name=
"server"
size=
"sm"
:class=
"provider.enabled && enabled ? 'text-green-600 dark:text-green-400' : 'text-gray-400'"
/>
</div>
<span
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
provider
.
name
}}
</span>
<span
class=
"text-xs text-gray-400 dark:text-gray-500"
>
{{
keyLabel
}}
</span>
<span
v-if=
"provider.payment_mode"
class=
"text-xs text-gray-400 dark:text-gray-500"
>
·
{{
modeLabel
}}
</span>
<span
v-if=
"enabled && availableTypes.length"
class=
"text-xs text-gray-300 dark:text-gray-600"
>
|
</span>
<div
v-if=
"enabled"
class=
"flex items-center gap-1"
>
<button
v-for=
"pt in availableTypes"
:key=
"pt.value"
type=
"button"
@
click=
"emit('toggleType', pt.value)"
:class=
"[
'rounded px-2 py-0.5 text-xs font-medium transition-all',
isSelected(pt.value)
? 'bg-primary-500 text-white'
: 'bg-gray-100 text-gray-400 dark:bg-dark-700 dark:text-gray-500',
]"
>
{{
pt
.
label
}}
</button>
</div>
</div>
<!-- Right: toggles + actions -->
<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')"
/>
<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"
/>
<span
class=
"text-xs"
>
{{
t
(
'
common.edit
'
)
}}
</span>
</button>
<button
type=
"button"
@
click=
"emit('delete')"
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=
"trash"
size=
"sm"
/>
<span
class=
"text-xs"
>
{{
t
(
'
common.delete
'
)
}}
</span>
</button>
</div>
</div>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
ToggleSwitch
from
'
./ToggleSwitch.vue
'
import
type
{
ProviderInstance
}
from
'
@/types/payment
'
import
type
{
TypeOption
}
from
'
./providerConfig
'
import
{
PAYMENT_MODE_QRCODE
,
PAYMENT_MODE_POPUP
}
from
'
./providerConfig
'
const
PROVIDER_KEY_LABELS
:
Record
<
string
,
string
>
=
{
easypay
:
'
admin.settings.payment.providerEasypay
'
,
alipay
:
'
admin.settings.payment.providerAlipay
'
,
wxpay
:
'
admin.settings.payment.providerWxpay
'
,
stripe
:
'
admin.settings.payment.providerStripe
'
,
}
const
props
=
defineProps
<
{
provider
:
ProviderInstance
enabled
:
boolean
availableTypes
:
TypeOption
[]
}
>
()
const
emit
=
defineEmits
<
{
toggleField
:
[
field
:
'
enabled
'
|
'
refund_enabled
'
]
toggleType
:
[
type
:
string
]
edit
:
[]
delete
:
[]
}
>
()
const
{
t
}
=
useI18n
()
const
keyLabel
=
computed
(()
=>
t
(
PROVIDER_KEY_LABELS
[
props
.
provider
.
provider_key
]
||
props
.
provider
.
provider_key
))
const
modeLabel
=
computed
(()
=>
{
if
(
props
.
provider
.
payment_mode
===
PAYMENT_MODE_QRCODE
)
return
t
(
'
admin.settings.payment.modeQRCode
'
)
if
(
props
.
provider
.
payment_mode
===
PAYMENT_MODE_POPUP
)
return
t
(
'
admin.settings.payment.modePopup
'
)
return
''
})
function
isSelected
(
type
:
string
):
boolean
{
return
props
.
provider
.
supported_types
.
includes
(
type
)
}
</
script
>
frontend/src/components/payment/StripePaymentInline.vue
0 → 100644
View file @
a04ae28a
<
template
>
<div
class=
"space-y-4"
>
<div
v-if=
"loading"
class=
"flex items-center justify-center py-12"
>
<div
class=
"h-8 w-8 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"
></div>
</div>
<div
v-else-if=
"initError"
class=
"card p-6 text-center"
>
<p
class=
"text-sm text-red-600 dark:text-red-400"
>
{{
initError
}}
</p>
<button
class=
"btn btn-secondary mt-4"
@
click=
"$emit('back')"
>
{{
t
(
'
payment.result.backToRecharge
'
)
}}
</button>
</div>
<!-- Success -->
<template
v-else-if=
"success"
>
<div
class=
"card p-6"
>
<div
class=
"flex flex-col items-center space-y-4 py-4"
>
<div
class=
"flex h-16 w-16 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30"
>
<Icon
name=
"check"
size=
"lg"
class=
"text-green-500"
/>
</div>
<p
class=
"text-lg font-bold text-gray-900 dark:text-white"
>
{{
t
(
'
payment.result.success
'
)
}}
</p>
<div
class=
"w-full rounded-xl bg-gray-50 p-4 dark:bg-dark-800"
>
<div
class=
"space-y-2 text-sm"
>
<div
class=
"flex justify-between"
>
<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"
>
<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>
</div>
</div>
</div>
<button
class=
"btn btn-primary"
@
click=
"$emit('done')"
>
{{
t
(
'
common.confirm
'
)
}}
</button>
</div>
</div>
</
template
>
<
template
v-else
>
<!-- Amount -->
<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>
</div>
</div>
<!-- Stripe Payment Element -->
<div
class=
"card p-6"
>
<div
ref=
"stripeMount"
class=
"min-h-[200px]"
></div>
<p
v-if=
"error"
class=
"mt-4 text-sm text-red-600 dark:text-red-400"
>
{{
error
}}
</p>
<button
class=
"btn btn-stripe mt-6 w-full py-3 text-base"
:disabled=
"submitting || !ready"
@
click=
"handlePay"
>
<span
v-if=
"submitting"
class=
"flex items-center justify-center gap-2"
>
<span
class=
"h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
></span>
{{
t
(
'
common.processing
'
)
}}
</span>
<span
v-else
>
{{
t
(
'
payment.stripePay
'
)
}}
</span>
</button>
</div>
<!-- Cancel order -->
<button
class=
"btn btn-secondary w-full"
:disabled=
"cancelling"
@
click=
"handleCancel"
>
{{
cancelling
?
t
(
'
common.processing
'
)
:
t
(
'
payment.qr.cancelOrder
'
)
}}
</button>
</
template
>
</div>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
onMounted
,
nextTick
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useRouter
}
from
'
vue-router
'
import
{
extractApiErrorMessage
}
from
'
@/utils/apiError
'
import
{
paymentAPI
}
from
'
@/api/payment
'
import
{
useAppStore
}
from
'
@/stores
'
import
{
STRIPE_POPUP_WINDOW_FEATURES
}
from
'
@/components/payment/providerConfig
'
import
type
{
Stripe
,
StripeElements
}
from
'
@stripe/stripe-js
'
import
Icon
from
'
@/components/icons/Icon.vue
'
// Stripe payment methods that open a popup (redirect or QR code)
const
POPUP_METHODS
=
new
Set
([
'
alipay
'
,
'
wechat_pay
'
])
const
props
=
defineProps
<
{
orderId
:
number
clientSecret
:
string
publishableKey
:
string
payAmount
:
number
}
>
()
const
emit
=
defineEmits
<
{
success
:
[];
done
:
[];
back
:
[];
redirect
:
[
orderId
:
number
,
payUrl
:
string
]
}
>
()
const
{
t
}
=
useI18n
()
const
router
=
useRouter
()
const
appStore
=
useAppStore
()
const
stripeMount
=
ref
<
HTMLElement
|
null
>
(
null
)
const
loading
=
ref
(
true
)
const
initError
=
ref
(
''
)
const
error
=
ref
(
''
)
const
submitting
=
ref
(
false
)
const
cancelling
=
ref
(
false
)
const
success
=
ref
(
false
)
const
ready
=
ref
(
false
)
const
selectedType
=
ref
(
''
)
let
stripeInstance
:
Stripe
|
null
=
null
let
elementsInstance
:
StripeElements
|
null
=
null
onMounted
(
async
()
=>
{
try
{
const
{
loadStripe
}
=
await
import
(
'
@stripe/stripe-js
'
)
const
stripe
=
await
loadStripe
(
props
.
publishableKey
)
if
(
!
stripe
)
{
initError
.
value
=
t
(
'
payment.stripeLoadFailed
'
);
return
}
stripeInstance
=
stripe
loading
.
value
=
false
await
nextTick
()
if
(
!
stripeMount
.
value
)
return
const
isDark
=
document
.
documentElement
.
classList
.
contains
(
'
dark
'
)
const
elements
=
stripe
.
elements
({
clientSecret
:
props
.
clientSecret
,
appearance
:
{
theme
:
isDark
?
'
night
'
:
'
stripe
'
,
variables
:
{
borderRadius
:
'
8px
'
}
},
})
elementsInstance
=
elements
const
paymentElement
=
elements
.
create
(
'
payment
'
,
{
layout
:
'
tabs
'
,
paymentMethodOrder
:
[
'
alipay
'
,
'
wechat_pay
'
,
'
card
'
,
'
link
'
],
}
as
Record
<
string
,
unknown
>
)
paymentElement
.
mount
(
stripeMount
.
value
)
paymentElement
.
on
(
'
ready
'
,
()
=>
{
ready
.
value
=
true
})
paymentElement
.
on
(
'
change
'
,
(
event
:
{
value
:
{
type
:
string
}
})
=>
{
selectedType
.
value
=
event
.
value
.
type
})
}
catch
(
err
:
unknown
)
{
initError
.
value
=
extractApiErrorMessage
(
err
,
t
(
'
payment.stripeLoadFailed
'
))
}
finally
{
loading
.
value
=
false
}
})
async
function
handlePay
()
{
if
(
!
stripeInstance
||
!
elementsInstance
||
submitting
.
value
)
return
// Alipay / WeChat Pay: open popup for redirect or QR display
if
(
POPUP_METHODS
.
has
(
selectedType
.
value
))
{
const
popupUrl
=
router
.
resolve
({
path
:
'
/payment/stripe-popup
'
,
query
:
{
order_id
:
String
(
props
.
orderId
),
method
:
selectedType
.
value
,
amount
:
String
(
props
.
payAmount
),
},
}).
href
const
popup
=
window
.
open
(
popupUrl
,
'
paymentPopup
'
,
STRIPE_POPUP_WINDOW_FEATURES
)
const
onReady
=
(
event
:
MessageEvent
)
=>
{
if
(
event
.
source
!==
popup
||
event
.
data
?.
type
!==
'
STRIPE_POPUP_READY
'
)
return
window
.
removeEventListener
(
'
message
'
,
onReady
)
popup
?.
postMessage
({
type
:
'
STRIPE_POPUP_INIT
'
,
clientSecret
:
props
.
clientSecret
,
publishableKey
:
props
.
publishableKey
,
},
window
.
location
.
origin
)
}
window
.
addEventListener
(
'
message
'
,
onReady
)
emit
(
'
redirect
'
,
props
.
orderId
,
popupUrl
)
return
}
// Card / Link: confirm inline
submitting
.
value
=
true
error
.
value
=
''
try
{
const
{
error
:
stripeError
}
=
await
stripeInstance
.
confirmPayment
({
elements
:
elementsInstance
,
confirmParams
:
{
return_url
:
window
.
location
.
origin
+
'
/payment/result?order_id=
'
+
props
.
orderId
+
'
&status=success
'
,
},
redirect
:
'
if_required
'
,
})
if
(
stripeError
)
{
error
.
value
=
stripeError
.
message
||
t
(
'
payment.result.failed
'
)
}
else
{
success
.
value
=
true
emit
(
'
success
'
)
}
}
catch
(
err
:
unknown
)
{
error
.
value
=
extractApiErrorMessage
(
err
,
t
(
'
payment.result.failed
'
))
}
finally
{
submitting
.
value
=
false
}
}
async
function
handleCancel
()
{
if
(
!
props
.
orderId
||
cancelling
.
value
)
return
cancelling
.
value
=
true
try
{
await
paymentAPI
.
cancelOrder
(
props
.
orderId
)
emit
(
'
back
'
)
}
catch
(
err
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
err
,
t
(
'
common.error
'
)))
}
finally
{
cancelling
.
value
=
false
}
}
</
script
>
frontend/src/components/payment/SubscriptionPlanCard.vue
0 → 100644
View file @
a04ae28a
<
template
>
<div
:class=
"[
'group relative flex flex-col overflow-hidden rounded-2xl border transition-all',
'hover:shadow-xl hover:-translate-y-0.5',
borderClass,
'bg-white dark:bg-dark-800',
]"
>
<!-- Colored top accent bar -->
<div
:class=
"['h-1.5', accentClass]"
/>
<div
class=
"flex flex-1 flex-col p-4"
>
<!-- Header: name + badge + price -->
<div
class=
"mb-3 flex items-start justify-between gap-2"
>
<div
class=
"min-w-0 flex-1"
>
<div
class=
"flex items-center gap-2"
>
<h3
class=
"truncate text-base font-bold text-gray-900 dark:text-white"
>
{{
plan
.
name
}}
</h3>
<span
:class=
"['shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium', badgeLightClass]"
>
{{
pLabel
}}
</span>
</div>
<p
v-if=
"plan.description"
class=
"mt-0.5 text-xs leading-relaxed text-gray-500 dark:text-dark-400 line-clamp-2"
>
{{
plan
.
description
}}
</p>
</div>
<div
class=
"shrink-0 text-right"
>
<div
class=
"flex items-baseline gap-1"
>
<span
class=
"text-xs text-gray-400 dark:text-dark-500"
>
$
</span>
<span
:class=
"['text-2xl font-extrabold tracking-tight', textClass]"
>
{{
plan
.
price
}}
</span>
</div>
<span
class=
"text-[11px] text-gray-400 dark:text-dark-500"
>
/
{{
validitySuffix
}}
</span>
<div
v-if=
"plan.original_price"
class=
"mt-0.5 flex items-center justify-end gap-1.5"
>
<span
class=
"text-xs text-gray-400 line-through dark:text-dark-500"
>
$
{{
plan
.
original_price
}}
</span>
<span
:class=
"['rounded px-1 py-0.5 text-[10px] font-semibold', discountClass]"
>
{{
discountText
}}
</span>
</div>
</div>
</div>
<!-- Group quota info (compact) -->
<div
class=
"mb-3 grid grid-cols-2 gap-x-3 gap-y-1 rounded-lg bg-gray-50 px-3 py-2 text-xs dark:bg-dark-700/50"
>
<div
class=
"flex items-center justify-between"
>
<span
class=
"text-gray-400 dark:text-dark-500"
>
{{
t
(
'
payment.planCard.rate
'
)
}}
</span>
<span
class=
"font-medium text-gray-700 dark:text-gray-300"
>
{{
rateDisplay
}}
</span>
</div>
<div
v-if=
"plan.daily_limit_usd != null"
class=
"flex items-center justify-between"
>
<span
class=
"text-gray-400 dark:text-dark-500"
>
{{
t
(
'
payment.planCard.dailyLimit
'
)
}}
</span>
<span
class=
"font-medium text-gray-700 dark:text-gray-300"
>
$
{{
plan
.
daily_limit_usd
}}
</span>
</div>
<div
v-if=
"plan.weekly_limit_usd != null"
class=
"flex items-center justify-between"
>
<span
class=
"text-gray-400 dark:text-dark-500"
>
{{
t
(
'
payment.planCard.weeklyLimit
'
)
}}
</span>
<span
class=
"font-medium text-gray-700 dark:text-gray-300"
>
$
{{
plan
.
weekly_limit_usd
}}
</span>
</div>
<div
v-if=
"plan.monthly_limit_usd != null"
class=
"flex items-center justify-between"
>
<span
class=
"text-gray-400 dark:text-dark-500"
>
{{
t
(
'
payment.planCard.monthlyLimit
'
)
}}
</span>
<span
class=
"font-medium text-gray-700 dark:text-gray-300"
>
$
{{
plan
.
monthly_limit_usd
}}
</span>
</div>
<div
v-if=
"plan.daily_limit_usd == null && plan.weekly_limit_usd == null && plan.monthly_limit_usd == null"
class=
"flex items-center justify-between"
>
<span
class=
"text-gray-400 dark:text-dark-500"
>
{{
t
(
'
payment.planCard.quota
'
)
}}
</span>
<span
class=
"font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
payment.planCard.unlimited
'
)
}}
</span>
</div>
<div
v-if=
"modelScopeLabels.length > 0"
class=
"col-span-2 flex items-center justify-between"
>
<span
class=
"text-gray-400 dark:text-dark-500"
>
{{
t
(
'
payment.planCard.models
'
)
}}
</span>
<div
class=
"flex flex-wrap justify-end gap-1"
>
<span
v-for=
"scope in modelScopeLabels"
:key=
"scope"
class=
"rounded bg-gray-200/80 px-1.5 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-dark-600 dark:text-gray-300"
>
{{
scope
}}
</span>
</div>
</div>
</div>
<!-- Features list (compact) -->
<div
v-if=
"plan.features.length > 0"
class=
"mb-3 space-y-1"
>
<div
v-for=
"feature in plan.features"
:key=
"feature"
class=
"flex items-start gap-1.5"
>
<svg
:class=
"['mt-0.5 h-3.5 w-3.5 flex-shrink-0', iconClass]"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M4.5 12.75l6 6 9-13.5"
/>
</svg>
<span
class=
"text-xs text-gray-600 dark:text-gray-300"
>
{{
feature
}}
</span>
</div>
</div>
<div
class=
"flex-1"
/>
<!-- Subscribe Button -->
<button
type=
"button"
:class=
"['w-full rounded-xl py-2.5 text-sm font-semibold transition-all active:scale-[0.98]', btnClass]"
@
click=
"emit('select', plan)"
>
{{
isRenewal
?
t
(
'
payment.renewNow
'
)
:
t
(
'
payment.subscribeNow
'
)
}}
</button>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
type
{
SubscriptionPlan
}
from
'
@/types/payment
'
import
type
{
UserSubscription
}
from
'
@/types
'
import
{
platformAccentBarClass
,
platformBadgeLightClass
,
platformBorderClass
,
platformTextClass
,
platformIconClass
,
platformButtonClass
,
platformDiscountClass
,
platformLabel
,
}
from
'
@/utils/platformColors
'
const
props
=
defineProps
<
{
plan
:
SubscriptionPlan
;
activeSubscriptions
?:
UserSubscription
[]
}
>
()
const
emit
=
defineEmits
<
{
select
:
[
plan
:
SubscriptionPlan
]
}
>
()
const
{
t
}
=
useI18n
()
const
platform
=
computed
(()
=>
props
.
plan
.
group_platform
||
''
)
const
isRenewal
=
computed
(()
=>
props
.
activeSubscriptions
?.
some
(
s
=>
s
.
group_id
===
props
.
plan
.
group_id
&&
s
.
status
===
'
active
'
)
??
false
)
// Derived color classes from central config
const
accentClass
=
computed
(()
=>
platformAccentBarClass
(
platform
.
value
))
const
borderClass
=
computed
(()
=>
platformBorderClass
(
platform
.
value
))
const
badgeLightClass
=
computed
(()
=>
platformBadgeLightClass
(
platform
.
value
))
const
textClass
=
computed
(()
=>
platformTextClass
(
platform
.
value
))
const
iconClass
=
computed
(()
=>
platformIconClass
(
platform
.
value
))
const
btnClass
=
computed
(()
=>
platformButtonClass
(
platform
.
value
))
const
discountClass
=
computed
(()
=>
platformDiscountClass
(
platform
.
value
))
const
pLabel
=
computed
(()
=>
platformLabel
(
platform
.
value
))
const
discountText
=
computed
(()
=>
{
if
(
!
props
.
plan
.
original_price
||
props
.
plan
.
original_price
<=
0
)
return
''
const
pct
=
Math
.
round
((
1
-
props
.
plan
.
price
/
props
.
plan
.
original_price
)
*
100
)
return
pct
>
0
?
`-
${
pct
}
%`
:
''
})
const
rateDisplay
=
computed
(()
=>
{
const
rate
=
props
.
plan
.
rate_multiplier
??
1
return
`×
${
Number
(
rate
.
toPrecision
(
10
))}
`
})
const
MODEL_SCOPE_LABELS
:
Record
<
string
,
string
>
=
{
claude
:
'
Claude
'
,
gemini_text
:
'
Gemini
'
,
gemini_image
:
'
Imagen
'
,
}
const
modelScopeLabels
=
computed
(()
=>
{
const
scopes
=
props
.
plan
.
supported_model_scopes
if
(
!
scopes
||
scopes
.
length
===
0
)
return
[]
return
scopes
.
map
(
s
=>
MODEL_SCOPE_LABELS
[
s
]
||
s
)
})
const
validitySuffix
=
computed
(()
=>
{
const
u
=
props
.
plan
.
validity_unit
||
'
day
'
if
(
u
===
'
month
'
)
return
t
(
'
payment.perMonth
'
)
if
(
u
===
'
year
'
)
return
t
(
'
payment.perYear
'
)
return
`
${
props
.
plan
.
validity_days
}${
t
(
'
payment.days
'
)}
`
})
</
script
>
frontend/src/components/payment/ToggleSwitch.vue
0 → 100644
View file @
a04ae28a
<
template
>
<label
class=
"flex items-center gap-1.5 cursor-pointer"
>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
label
}}
</span>
<button
type=
"button"
role=
"switch"
:aria-checked=
"checked"
@
click=
"emit('toggle')"
:class=
"[
'relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors duration-200',
checked ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600',
]"
>
<span
:class=
"[
'pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200',
checked ? 'translate-x-4' : 'translate-x-0',
]"
/>
</button>
</label>
</
template
>
<
script
setup
lang=
"ts"
>
defineProps
<
{
label
:
string
;
checked
:
boolean
}
>
()
const
emit
=
defineEmits
<
{
toggle
:
[]
}
>
()
</
script
>
frontend/src/components/payment/orderUtils.ts
0 → 100644
View file @
a04ae28a
/**
* Shared utility functions for payment order display.
* Used by AdminOrderDetail, AdminOrderTable, AdminRefundDialog, AdminOrdersView, etc.
*/
const
STATUS_BADGE_MAP
:
Record
<
string
,
string
>
=
{
PENDING
:
'
badge-warning
'
,
PAID
:
'
badge-info
'
,
RECHARGING
:
'
badge-info
'
,
COMPLETED
:
'
badge-success
'
,
EXPIRED
:
'
badge-secondary
'
,
CANCELLED
:
'
badge-secondary
'
,
FAILED
:
'
badge-danger
'
,
REFUND_REQUESTED
:
'
badge-warning
'
,
REFUNDING
:
'
badge-warning
'
,
PARTIALLY_REFUNDED
:
'
badge-warning
'
,
REFUNDED
:
'
badge-info
'
,
REFUND_FAILED
:
'
badge-danger
'
,
}
const
REFUNDABLE_STATUSES
=
[
'
COMPLETED
'
,
'
PARTIALLY_REFUNDED
'
,
'
REFUND_REQUESTED
'
,
'
REFUND_FAILED
'
]
export
function
statusBadgeClass
(
status
:
string
):
string
{
return
STATUS_BADGE_MAP
[
status
]
||
'
badge-secondary
'
}
export
function
canRefund
(
status
:
string
):
boolean
{
return
REFUNDABLE_STATUSES
.
includes
(
status
)
}
export
function
formatOrderDateTime
(
dateStr
:
string
):
string
{
if
(
!
dateStr
)
return
'
-
'
return
new
Date
(
dateStr
).
toLocaleString
()
}
frontend/src/components/payment/providerConfig.ts
0 → 100644
View file @
a04ae28a
/**
* Shared constants and types for payment provider management.
*/
// --- Types ---
export
interface
ConfigFieldDef
{
key
:
string
label
:
string
sensitive
:
boolean
optional
?:
boolean
defaultValue
?:
string
}
export
interface
TypeOption
{
value
:
string
label
:
string
}
/** Callback URL paths for a provider. */
export
interface
CallbackPaths
{
notifyUrl
?:
string
returnUrl
?:
string
}
// --- Constants ---
/** Maps provider key → available payment types. */
export
const
PROVIDER_SUPPORTED_TYPES
:
Record
<
string
,
string
[]
>
=
{
easypay
:
[
'
alipay
'
,
'
wxpay
'
],
alipay
:
[
'
alipay
'
],
wxpay
:
[
'
wxpay
'
],
stripe
:
[
'
card
'
,
'
alipay
'
,
'
wxpay
'
,
'
link
'
],
}
/** Available payment modes for EasyPay providers. */
export
const
EASYPAY_PAYMENT_MODES
=
[
'
qrcode
'
,
'
popup
'
]
as
const
/** Fixed display order for user-facing payment methods */
export
const
METHOD_ORDER
=
[
'
alipay
'
,
'
alipay_direct
'
,
'
wxpay
'
,
'
wxpay_direct
'
,
'
stripe
'
]
as
const
/** Payment mode constants */
export
const
PAYMENT_MODE_QRCODE
=
'
qrcode
'
export
const
PAYMENT_MODE_POPUP
=
'
popup
'
/** Window features for payment popup windows */
export
const
POPUP_WINDOW_FEATURES
=
'
width=1000,height=750,left=100,top=80,scrollbars=yes,resizable=yes
'
/** Wider popup for Stripe redirect methods (Alipay checkout page needs ~1200px) */
export
const
STRIPE_POPUP_WINDOW_FEATURES
=
'
width=1250,height=780,left=80,top=60,scrollbars=yes,resizable=yes
'
/** Webhook paths for each provider (relative to origin). */
export
const
WEBHOOK_PATHS
:
Record
<
string
,
string
>
=
{
easypay
:
'
/api/v1/payment/webhook/easypay
'
,
alipay
:
'
/api/v1/payment/webhook/alipay
'
,
wxpay
:
'
/api/v1/payment/webhook/wxpay
'
,
stripe
:
'
/api/v1/payment/webhook/stripe
'
,
}
export
const
RETURN_PATH
=
'
/payment/result
'
/** Fixed callback paths per provider — displayed as read-only after base URL. */
export
const
PROVIDER_CALLBACK_PATHS
:
Record
<
string
,
CallbackPaths
>
=
{
easypay
:
{
notifyUrl
:
WEBHOOK_PATHS
.
easypay
,
returnUrl
:
RETURN_PATH
},
alipay
:
{
notifyUrl
:
WEBHOOK_PATHS
.
alipay
,
returnUrl
:
RETURN_PATH
},
wxpay
:
{
notifyUrl
:
WEBHOOK_PATHS
.
wxpay
},
// stripe: no callback URL config needed (webhook is separate)
}
/** Per-provider config fields (excludes notifyUrl/returnUrl which are handled separately). */
export
const
PROVIDER_CONFIG_FIELDS
:
Record
<
string
,
ConfigFieldDef
[]
>
=
{
easypay
:
[
{
key
:
'
pid
'
,
label
:
'
PID
'
,
sensitive
:
false
},
{
key
:
'
pkey
'
,
label
:
'
PKey
'
,
sensitive
:
true
},
{
key
:
'
apiBase
'
,
label
:
''
,
sensitive
:
false
},
{
key
:
'
cidAlipay
'
,
label
:
''
,
sensitive
:
false
,
optional
:
true
},
{
key
:
'
cidWxpay
'
,
label
:
''
,
sensitive
:
false
,
optional
:
true
},
],
alipay
:
[
{
key
:
'
appId
'
,
label
:
'
App ID
'
,
sensitive
:
false
},
{
key
:
'
privateKey
'
,
label
:
''
,
sensitive
:
true
},
{
key
:
'
publicKey
'
,
label
:
''
,
sensitive
:
true
},
],
wxpay
:
[
{
key
:
'
appId
'
,
label
:
'
App ID
'
,
sensitive
:
false
},
{
key
:
'
mchId
'
,
label
:
''
,
sensitive
:
false
},
{
key
:
'
privateKey
'
,
label
:
''
,
sensitive
:
true
},
{
key
:
'
apiV3Key
'
,
label
:
''
,
sensitive
:
true
},
{
key
:
'
publicKey
'
,
label
:
''
,
sensitive
:
true
},
{
key
:
'
publicKeyId
'
,
label
:
''
,
sensitive
:
false
,
optional
:
true
},
{
key
:
'
certSerial
'
,
label
:
''
,
sensitive
:
false
,
optional
:
true
},
],
stripe
:
[
{
key
:
'
secretKey
'
,
label
:
''
,
sensitive
:
true
},
{
key
:
'
publishableKey
'
,
label
:
''
,
sensitive
:
false
},
{
key
:
'
webhookSecret
'
,
label
:
''
,
sensitive
:
true
},
],
}
// --- Helpers ---
/** Resolve type label for display. */
export
function
resolveTypeLabel
(
typeVal
:
string
,
_providerKey
:
string
,
allTypes
:
TypeOption
[],
_redirectLabel
:
string
,
):
TypeOption
{
return
allTypes
.
find
(
pt
=>
pt
.
value
===
typeVal
)
||
{
value
:
typeVal
,
label
:
typeVal
}
}
/** Get available type options for a provider key. */
export
function
getAvailableTypes
(
providerKey
:
string
,
allTypes
:
TypeOption
[],
redirectLabel
:
string
,
):
TypeOption
[]
{
const
types
=
PROVIDER_SUPPORTED_TYPES
[
providerKey
]
||
[]
return
types
.
map
(
t
=>
resolveTypeLabel
(
t
,
providerKey
,
allTypes
,
redirectLabel
))
}
/** Extract base URL from a full callback URL by removing the known path suffix. */
export
function
extractBaseUrl
(
fullUrl
:
string
,
path
:
string
):
string
{
if
(
!
fullUrl
)
return
''
if
(
fullUrl
.
endsWith
(
path
))
return
fullUrl
.
slice
(
0
,
-
path
.
length
)
// Fallback: try to extract origin
try
{
return
new
URL
(
fullUrl
).
origin
}
catch
{
return
fullUrl
}
}
frontend/src/composables/__tests__/usePersistedPageSize.spec.ts
0 → 100644
View file @
a04ae28a
import
{
afterEach
,
describe
,
expect
,
it
}
from
'
vitest
'
import
{
getPersistedPageSize
}
from
'
@/composables/usePersistedPageSize
'
describe
(
'
usePersistedPageSize
'
,
()
=>
{
afterEach
(()
=>
{
localStorage
.
clear
()
delete
window
.
__APP_CONFIG__
})
it
(
'
uses the system table default instead of stale localStorage state
'
,
()
=>
{
window
.
__APP_CONFIG__
=
{
table_default_page_size
:
1000
,
table_page_size_options
:
[
20
,
50
,
1000
]
}
as
any
localStorage
.
setItem
(
'
table-page-size
'
,
'
50
'
)
localStorage
.
setItem
(
'
table-page-size-source
'
,
'
user
'
)
expect
(
getPersistedPageSize
()).
toBe
(
1000
)
})
})
Prev
1
…
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