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
1f955249
Commit
1f955249
authored
Mar 03, 2026
by
erio
Browse files
feat: ImageUpload component, custom page title, sidebar menu order
parent
a50d5d35
Changes
15
Hide whitespace changes
Inline
Side-by-side
frontend/src/components/common/ImageUpload.vue
0 → 100644
View file @
1f955249
<
template
>
<div
class=
"flex items-start gap-4"
>
<!-- Preview Box -->
<div
class=
"flex-shrink-0"
>
<div
class=
"flex items-center justify-center overflow-hidden rounded-xl border-2 border-dashed border-gray-300 bg-gray-50 dark:border-dark-600 dark:bg-dark-800"
:class=
"[previewSizeClass,
{ 'border-solid': !!modelValue }]"
>
<!-- SVG mode: render inline -->
<span
v-if=
"mode === 'svg' && modelValue"
class=
"text-gray-600 dark:text-gray-300 [&>svg]:h-full [&>svg]:w-full"
:class=
"innerSizeClass"
v-html=
"modelValue"
></span>
<!-- Image mode: show as img -->
<img
v-else-if=
"mode === 'image' && modelValue"
:src=
"modelValue"
alt=
""
class=
"h-full w-full object-contain"
/>
<!-- Empty placeholder -->
<svg
v-else
class=
"text-gray-400 dark:text-dark-500"
:class=
"placeholderSizeClass"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"1.5"
d=
"M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
</div>
<!-- Controls -->
<div
class=
"flex-1 space-y-2"
>
<div
class=
"flex items-center gap-2"
>
<label
class=
"btn btn-secondary btn-sm cursor-pointer"
>
<input
type=
"file"
:accept=
"acceptTypes"
class=
"hidden"
@
change=
"handleUpload"
/>
<Icon
name=
"upload"
size=
"sm"
class=
"mr-1.5"
:stroke-width=
"2"
/>
{{
uploadLabel
}}
</label>
<button
v-if=
"modelValue"
type=
"button"
class=
"btn btn-secondary btn-sm text-red-600 hover:text-red-700 dark:text-red-400"
@
click=
"$emit('update:modelValue', '')"
>
<Icon
name=
"trash"
size=
"sm"
class=
"mr-1.5"
:stroke-width=
"2"
/>
{{
removeLabel
}}
</button>
</div>
<p
v-if=
"hint"
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
hint
}}
</p>
<p
v-if=
"error"
class=
"text-xs text-red-500"
>
{{
error
}}
</p>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
}
from
'
vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
const
props
=
withDefaults
(
defineProps
<
{
modelValue
:
string
mode
?:
'
image
'
|
'
svg
'
size
?:
'
sm
'
|
'
md
'
uploadLabel
?:
string
removeLabel
?:
string
hint
?:
string
maxSize
?:
number
// bytes
}
>
(),
{
mode
:
'
image
'
,
size
:
'
md
'
,
uploadLabel
:
'
Upload
'
,
removeLabel
:
'
Remove
'
,
hint
:
''
,
maxSize
:
300
*
1024
,
})
const
emit
=
defineEmits
<
{
'
update:modelValue
'
:
[
value
:
string
]
}
>
()
const
error
=
ref
(
''
)
const
acceptTypes
=
computed
(()
=>
props
.
mode
===
'
svg
'
?
'
.svg
'
:
'
image/*
'
)
const
previewSizeClass
=
computed
(()
=>
props
.
size
===
'
sm
'
?
'
h-14 w-14
'
:
'
h-20 w-20
'
)
const
innerSizeClass
=
computed
(()
=>
props
.
size
===
'
sm
'
?
'
h-7 w-7
'
:
'
h-12 w-12
'
)
const
placeholderSizeClass
=
computed
(()
=>
props
.
size
===
'
sm
'
?
'
h-5 w-5
'
:
'
h-8 w-8
'
)
function
handleUpload
(
event
:
Event
)
{
const
input
=
event
.
target
as
HTMLInputElement
const
file
=
input
.
files
?.[
0
]
error
.
value
=
''
if
(
!
file
)
return
if
(
props
.
maxSize
&&
file
.
size
>
props
.
maxSize
)
{
error
.
value
=
`File too large (
${(
file
.
size
/
1024
).
toFixed
(
1
)}
KB), max
${(
props
.
maxSize
/
1024
).
toFixed
(
0
)}
KB`
input
.
value
=
''
return
}
const
reader
=
new
FileReader
()
if
(
props
.
mode
===
'
svg
'
)
{
reader
.
onload
=
(
e
)
=>
{
const
text
=
e
.
target
?.
result
as
string
if
(
text
)
emit
(
'
update:modelValue
'
,
text
.
trim
())
}
reader
.
readAsText
(
file
)
}
else
{
if
(
!
file
.
type
.
startsWith
(
'
image/
'
))
{
error
.
value
=
'
Please select an image file
'
input
.
value
=
''
return
}
reader
.
onload
=
(
e
)
=>
{
emit
(
'
update:modelValue
'
,
e
.
target
?.
result
as
string
)
}
reader
.
readAsDataURL
(
file
)
}
reader
.
onerror
=
()
=>
{
error
.
value
=
'
Failed to read file
'
}
input
.
value
=
''
}
</
script
>
frontend/src/components/layout/AppHeader.vue
View file @
1f955249
...
@@ -254,6 +254,13 @@ const displayName = computed(() => {
...
@@ -254,6 +254,13 @@ const displayName = computed(() => {
})
})
const
pageTitle
=
computed
(()
=>
{
const
pageTitle
=
computed
(()
=>
{
// For custom pages, use the menu item's label instead of generic "自定义页面"
if
(
route
.
name
===
'
CustomPage
'
)
{
const
id
=
route
.
params
.
id
as
string
const
items
=
appStore
.
cachedPublicSettings
?.
custom_menu_items
??
[]
const
menuItem
=
items
.
find
((
item
)
=>
item
.
id
===
id
)
if
(
menuItem
?.
label
)
return
menuItem
.
label
}
const
titleKey
=
route
.
meta
.
titleKey
as
string
const
titleKey
=
route
.
meta
.
titleKey
as
string
if
(
titleKey
)
{
if
(
titleKey
)
{
return
t
(
titleKey
)
return
t
(
titleKey
)
...
...
frontend/src/components/layout/AppSidebar.vue
View file @
1f955249
...
@@ -526,15 +526,14 @@ const userNavItems = computed((): NavItem[] => {
...
@@ -526,15 +526,14 @@ const userNavItems = computed((): NavItem[] => {
}
}
]
]
:
[]),
:
[]),
{
path
:
'
/redeem
'
,
label
:
t
(
'
nav.redeem
'
),
icon
:
GiftIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/profile
'
,
label
:
t
(
'
nav.profile
'
),
icon
:
UserIcon
},
...
customMenuItemsForUser
.
value
.
map
((
item
):
NavItem
=>
({
...
customMenuItemsForUser
.
value
.
map
((
item
):
NavItem
=>
({
path
:
`/custom/
${
item
.
id
}
`
,
path
:
`/custom/
${
item
.
id
}
`
,
label
:
item
.
label
,
label
:
item
.
label
,
icon
:
null
,
icon
:
null
,
iconSvg
:
item
.
icon_svg
,
iconSvg
:
item
.
icon_svg
,
hideInSimpleMode
:
true
,
})),
})),
{
path
:
'
/redeem
'
,
label
:
t
(
'
nav.redeem
'
),
icon
:
GiftIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/profile
'
,
label
:
t
(
'
nav.profile
'
),
icon
:
UserIcon
}
]
]
return
authStore
.
isSimpleMode
?
items
.
filter
(
item
=>
!
item
.
hideInSimpleMode
)
:
items
return
authStore
.
isSimpleMode
?
items
.
filter
(
item
=>
!
item
.
hideInSimpleMode
)
:
items
})
})
...
@@ -558,15 +557,14 @@ const personalNavItems = computed((): NavItem[] => {
...
@@ -558,15 +557,14 @@ const personalNavItems = computed((): NavItem[] => {
}
}
]
]
:
[]),
:
[]),
{
path
:
'
/redeem
'
,
label
:
t
(
'
nav.redeem
'
),
icon
:
GiftIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/profile
'
,
label
:
t
(
'
nav.profile
'
),
icon
:
UserIcon
},
...
customMenuItemsForUser
.
value
.
map
((
item
):
NavItem
=>
({
...
customMenuItemsForUser
.
value
.
map
((
item
):
NavItem
=>
({
path
:
`/custom/
${
item
.
id
}
`
,
path
:
`/custom/
${
item
.
id
}
`
,
label
:
item
.
label
,
label
:
item
.
label
,
icon
:
null
,
icon
:
null
,
iconSvg
:
item
.
icon_svg
,
iconSvg
:
item
.
icon_svg
,
hideInSimpleMode
:
true
,
})),
})),
{
path
:
'
/redeem
'
,
label
:
t
(
'
nav.redeem
'
),
icon
:
GiftIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/profile
'
,
label
:
t
(
'
nav.profile
'
),
icon
:
UserIcon
}
]
]
return
authStore
.
isSimpleMode
?
items
.
filter
(
item
=>
!
item
.
hideInSimpleMode
)
:
items
return
authStore
.
isSimpleMode
?
items
.
filter
(
item
=>
!
item
.
hideInSimpleMode
)
:
items
})
})
...
@@ -607,22 +605,22 @@ const adminNavItems = computed((): NavItem[] => {
...
@@ -607,22 +605,22 @@ const adminNavItems = computed((): NavItem[] => {
// 简单模式下,在系统设置前插入 API密钥
// 简单模式下,在系统设置前插入 API密钥
if
(
authStore
.
isSimpleMode
)
{
if
(
authStore
.
isSimpleMode
)
{
const
filtered
=
baseItems
.
filter
(
item
=>
!
item
.
hideInSimpleMode
)
const
filtered
=
baseItems
.
filter
(
item
=>
!
item
.
hideInSimpleMode
)
// Add admin custom menu items
for
(
const
cm
of
customMenuItemsForAdmin
.
value
)
{
filtered
.
push
({
path
:
`/custom/
${
cm
.
id
}
`
,
label
:
cm
.
label
,
icon
:
null
,
iconSvg
:
cm
.
icon_svg
})
}
filtered
.
push
({
path
:
'
/keys
'
,
label
:
t
(
'
nav.apiKeys
'
),
icon
:
KeyIcon
})
filtered
.
push
({
path
:
'
/keys
'
,
label
:
t
(
'
nav.apiKeys
'
),
icon
:
KeyIcon
})
filtered
.
push
({
path
:
'
/admin/data-management
'
,
label
:
t
(
'
nav.dataManagement
'
),
icon
:
DatabaseIcon
})
filtered
.
push
({
path
:
'
/admin/data-management
'
,
label
:
t
(
'
nav.dataManagement
'
),
icon
:
DatabaseIcon
})
filtered
.
push
({
path
:
'
/admin/settings
'
,
label
:
t
(
'
nav.settings
'
),
icon
:
CogIcon
})
filtered
.
push
({
path
:
'
/admin/settings
'
,
label
:
t
(
'
nav.settings
'
),
icon
:
CogIcon
})
// Add admin custom menu items after settings
for
(
const
cm
of
customMenuItemsForAdmin
.
value
)
{
filtered
.
push
({
path
:
`/custom/
${
cm
.
id
}
`
,
label
:
cm
.
label
,
icon
:
null
,
iconSvg
:
cm
.
icon_svg
})
}
return
filtered
return
filtered
}
}
baseItems
.
push
({
path
:
'
/admin/data-management
'
,
label
:
t
(
'
nav.dataManagement
'
),
icon
:
DatabaseIcon
})
baseItems
.
push
({
path
:
'
/admin/data-management
'
,
label
:
t
(
'
nav.dataManagement
'
),
icon
:
DatabaseIcon
})
// Add admin custom menu items before settings
baseItems
.
push
({
path
:
'
/admin/settings
'
,
label
:
t
(
'
nav.settings
'
),
icon
:
CogIcon
})
// Add admin custom menu items after settings
for
(
const
cm
of
customMenuItemsForAdmin
.
value
)
{
for
(
const
cm
of
customMenuItemsForAdmin
.
value
)
{
baseItems
.
push
({
path
:
`/custom/
${
cm
.
id
}
`
,
label
:
cm
.
label
,
icon
:
null
,
iconSvg
:
cm
.
icon_svg
})
baseItems
.
push
({
path
:
`/custom/
${
cm
.
id
}
`
,
label
:
cm
.
label
,
icon
:
null
,
iconSvg
:
cm
.
icon_svg
})
}
}
baseItems
.
push
({
path
:
'
/admin/settings
'
,
label
:
t
(
'
nav.settings
'
),
icon
:
CogIcon
})
return
baseItems
return
baseItems
})
})
...
...
frontend/src/i18n/locales/en.ts
View file @
1f955249
...
@@ -3636,6 +3636,8 @@ export default {
...
@@ -3636,6 +3636,8 @@ export default {
iconSvg
:
'
SVG Icon
'
,
iconSvg
:
'
SVG Icon
'
,
iconSvgPlaceholder
:
'
<svg>...</svg>
'
,
iconSvgPlaceholder
:
'
<svg>...</svg>
'
,
iconPreview
:
'
Icon Preview
'
,
iconPreview
:
'
Icon Preview
'
,
uploadSvg
:
'
Upload SVG
'
,
removeSvg
:
'
Remove
'
,
visibility
:
'
Visible To
'
,
visibility
:
'
Visible To
'
,
visibilityUser
:
'
Regular Users
'
,
visibilityUser
:
'
Regular Users
'
,
visibilityAdmin
:
'
Administrators
'
,
visibilityAdmin
:
'
Administrators
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
1f955249
...
@@ -3806,6 +3806,8 @@ export default {
...
@@ -3806,6 +3806,8 @@ export default {
iconSvg
:
'
SVG 图标
'
,
iconSvg
:
'
SVG 图标
'
,
iconSvgPlaceholder
:
'
<svg>...</svg>
'
,
iconSvgPlaceholder
:
'
<svg>...</svg>
'
,
iconPreview
:
'
图标预览
'
,
iconPreview
:
'
图标预览
'
,
uploadSvg
:
'
上传 SVG
'
,
removeSvg
:
'
清除
'
,
visibility
:
'
可见角色
'
,
visibility
:
'
可见角色
'
,
visibilityUser
:
'
普通用户
'
,
visibilityUser
:
'
普通用户
'
,
visibilityAdmin
:
'
管理员
'
,
visibilityAdmin
:
'
管理员
'
,
...
...
frontend/src/router/index.ts
View file @
1f955249
...
@@ -428,7 +428,20 @@ router.beforeEach((to, _from, next) => {
...
@@ -428,7 +428,20 @@ router.beforeEach((to, _from, next) => {
// Set page title
// Set page title
const
appStore
=
useAppStore
()
const
appStore
=
useAppStore
()
document
.
title
=
resolveDocumentTitle
(
to
.
meta
.
title
,
appStore
.
siteName
,
to
.
meta
.
titleKey
as
string
)
// For custom pages, use menu item label as document title
if
(
to
.
name
===
'
CustomPage
'
)
{
const
id
=
to
.
params
.
id
as
string
const
items
=
appStore
.
cachedPublicSettings
?.
custom_menu_items
??
[]
const
menuItem
=
items
.
find
((
item
)
=>
item
.
id
===
id
)
if
(
menuItem
?.
label
)
{
const
siteName
=
appStore
.
siteName
||
'
Sub2API
'
document
.
title
=
`
${
menuItem
.
label
}
-
${
siteName
}
`
}
else
{
document
.
title
=
resolveDocumentTitle
(
to
.
meta
.
title
,
appStore
.
siteName
,
to
.
meta
.
titleKey
as
string
)
}
}
else
{
document
.
title
=
resolveDocumentTitle
(
to
.
meta
.
title
,
appStore
.
siteName
,
to
.
meta
.
titleKey
as
string
)
}
// Check if route requires authentication
// Check if route requires authentication
const
requiresAuth
=
to
.
meta
.
requiresAuth
!==
false
// Default to true
const
requiresAuth
=
to
.
meta
.
requiresAuth
!==
false
// Default to true
...
...
frontend/src/views/admin/SettingsView.vue
View file @
1f955249
...
@@ -832,64 +832,14 @@
...
@@ -832,64 +832,14 @@
<label
class=
"mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
<label
class=
"mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t('admin.settings.site.siteLogo') }}
{{ t('admin.settings.site.siteLogo') }}
</label>
</label>
<div
class=
"flex items-start gap-6"
>
<ImageUpload
<!-- Logo Preview -->
v-model=
"form.site_logo"
<div
class=
"flex-shrink-0"
>
mode=
"image"
<div
:upload-label=
"t('admin.settings.site.uploadImage')"
class=
"flex h-20 w-20 items-center justify-center overflow-hidden rounded-xl border-2 border-dashed border-gray-300 bg-gray-50 dark:border-dark-600 dark:bg-dark-800"
:remove-label=
"t('admin.settings.site.remove')"
:class=
"{ 'border-solid': form.site_logo }"
:hint=
"t('admin.settings.site.logoHint')"
>
:max-size=
"300 * 1024"
<img
/>
v-if=
"form.site_logo"
:src=
"form.site_logo"
alt=
"Site Logo"
class=
"h-full w-full object-contain"
/>
<svg
v-else
class=
"h-8 w-8 text-gray-400 dark:text-dark-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"1.5"
d=
"M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
</div>
<!-- Upload Controls -->
<div
class=
"flex-1 space-y-3"
>
<div
class=
"flex items-center gap-3"
>
<label
class=
"btn btn-secondary btn-sm cursor-pointer"
>
<input
type=
"file"
accept=
"image/*"
class=
"hidden"
@
change=
"handleLogoUpload"
/>
<Icon
name=
"upload"
size=
"sm"
class=
"mr-1.5"
:stroke-width=
"2"
/>
{{ t('admin.settings.site.uploadImage') }}
</label>
<button
v-if=
"form.site_logo"
type=
"button"
@
click=
"form.site_logo = ''"
class=
"btn btn-secondary btn-sm text-red-600 hover:text-red-700 dark:text-red-400"
>
<Icon
name=
"trash"
size=
"sm"
class=
"mr-1.5"
:stroke-width=
"2"
/>
{{ t('admin.settings.site.remove') }}
</button>
</div>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('admin.settings.site.logoHint') }}
</p>
<p
v-if=
"logoError"
class=
"text-xs text-red-500"
>
{{ logoError }}
</p>
</div>
</div>
</div>
</div>
<!-- Home Content -->
<!-- Home Content -->
...
@@ -1257,22 +1207,14 @@
...
@@ -1257,22 +1207,14 @@
<label
class=
"mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
<label
class=
"mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{ t('admin.settings.customMenu.iconSvg') }}
{{ t('admin.settings.customMenu.iconSvg') }}
</label>
</label>
<div
class=
"flex items-start gap-3"
>
<ImageUpload
<textarea
:model-value=
"item.icon_svg"
v-model=
"item.icon_svg"
mode=
"svg"
rows=
"2"
size=
"sm"
class=
"input flex-1 font-mono text-xs"
:upload-label=
"t('admin.settings.customMenu.uploadSvg')"
:placeholder=
"t('admin.settings.customMenu.iconSvgPlaceholder')"
:remove-label=
"t('admin.settings.customMenu.removeSvg')"
></textarea>
@
update:model-value=
"(v: string) => item.icon_svg = v"
<!-- SVG Preview -->
/>
<div
v-if=
"item.icon_svg"
class=
"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 dark:border-dark-600 dark:bg-dark-800"
:title=
"t('admin.settings.customMenu.iconPreview')"
>
<span
class=
"h-5 w-5 text-gray-600 dark:text-gray-300 [&>svg]:h-5 [&>svg]:w-5 [&>svg]:stroke-current"
v-html=
"item.icon_svg"
></span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
...
@@ -1390,6 +1332,7 @@ import Select from '@/components/common/Select.vue'
...
@@ -1390,6 +1332,7 @@ import Select from '@/components/common/Select.vue'
import
GroupBadge
from
'
@/components/common/GroupBadge.vue
'
import
GroupBadge
from
'
@/components/common/GroupBadge.vue
'
import
GroupOptionItem
from
'
@/components/common/GroupOptionItem.vue
'
import
GroupOptionItem
from
'
@/components/common/GroupOptionItem.vue
'
import
Toggle
from
'
@/components/common/Toggle.vue
'
import
Toggle
from
'
@/components/common/Toggle.vue
'
import
ImageUpload
from
'
@/components/common/ImageUpload.vue
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
import
{
useAppStore
}
from
'
@/stores
'
import
{
useAppStore
}
from
'
@/stores
'
...
@@ -1402,7 +1345,6 @@ const saving = ref(false)
...
@@ -1402,7 +1345,6 @@ const saving = ref(false)
const
testingSmtp
=
ref
(
false
)
const
testingSmtp
=
ref
(
false
)
const
sendingTestEmail
=
ref
(
false
)
const
sendingTestEmail
=
ref
(
false
)
const
testEmailAddress
=
ref
(
''
)
const
testEmailAddress
=
ref
(
''
)
const
logoError
=
ref
(
''
)
// Admin API Key 状态
// Admin API Key 状态
const
adminApiKeyLoading
=
ref
(
true
)
const
adminApiKeyLoading
=
ref
(
true
)
...
@@ -1559,44 +1501,6 @@ function moveMenuItem(index: number, direction: -1 | 1) {
...
@@ -1559,44 +1501,6 @@ function moveMenuItem(index: number, direction: -1 | 1) {
})
})
}
}
function
handleLogoUpload
(
event
:
Event
)
{
const
input
=
event
.
target
as
HTMLInputElement
const
file
=
input
.
files
?.[
0
]
logoError
.
value
=
''
if
(
!
file
)
return
// Check file size (300KB = 307200 bytes)
const
maxSize
=
300
*
1024
if
(
file
.
size
>
maxSize
)
{
logoError
.
value
=
t
(
'
admin.settings.site.logoSizeError
'
,
{
size
:
(
file
.
size
/
1024
).
toFixed
(
1
)
})
input
.
value
=
''
return
}
// Check file type
if
(
!
file
.
type
.
startsWith
(
'
image/
'
))
{
logoError
.
value
=
t
(
'
admin.settings.site.logoTypeError
'
)
input
.
value
=
''
return
}
// Convert to base64
const
reader
=
new
FileReader
()
reader
.
onload
=
(
e
)
=>
{
form
.
site_logo
=
e
.
target
?.
result
as
string
}
reader
.
onerror
=
()
=>
{
logoError
.
value
=
t
(
'
admin.settings.site.logoReadError
'
)
}
reader
.
readAsDataURL
(
file
)
// Reset input
input
.
value
=
''
}
async
function
loadSettings
()
{
async
function
loadSettings
()
{
loading
.
value
=
true
loading
.
value
=
true
try
{
try
{
...
...
tmp_api_admin_orders/[id]/cancel/route.ts
deleted
100644 → 0
View file @
a50d5d35
import
{
NextRequest
,
NextResponse
}
from
'
next/server
'
;
import
{
verifyAdminToken
,
unauthorizedResponse
}
from
'
@/lib/admin-auth
'
;
import
{
adminCancelOrder
,
OrderError
}
from
'
@/lib/order/service
'
;
export
async
function
POST
(
request
:
NextRequest
,
{
params
}:
{
params
:
Promise
<
{
id
:
string
}
>
},
)
{
if
(
!
verifyAdminToken
(
request
))
return
unauthorizedResponse
();
try
{
const
{
id
}
=
await
params
;
await
adminCancelOrder
(
id
);
return
NextResponse
.
json
({
success
:
true
});
}
catch
(
error
)
{
if
(
error
instanceof
OrderError
)
{
return
NextResponse
.
json
(
{
error
:
error
.
message
,
code
:
error
.
code
},
{
status
:
error
.
statusCode
},
);
}
console
.
error
(
'
Admin cancel order error:
'
,
error
);
return
NextResponse
.
json
({
error
:
'
取消订单失败
'
},
{
status
:
500
});
}
}
tmp_api_admin_orders/[id]/retry/route.ts
deleted
100644 → 0
View file @
a50d5d35
import
{
NextRequest
,
NextResponse
}
from
'
next/server
'
;
import
{
verifyAdminToken
,
unauthorizedResponse
}
from
'
@/lib/admin-auth
'
;
import
{
retryRecharge
,
OrderError
}
from
'
@/lib/order/service
'
;
export
async
function
POST
(
request
:
NextRequest
,
{
params
}:
{
params
:
Promise
<
{
id
:
string
}
>
},
)
{
if
(
!
verifyAdminToken
(
request
))
return
unauthorizedResponse
();
try
{
const
{
id
}
=
await
params
;
await
retryRecharge
(
id
);
return
NextResponse
.
json
({
success
:
true
});
}
catch
(
error
)
{
if
(
error
instanceof
OrderError
)
{
return
NextResponse
.
json
(
{
error
:
error
.
message
,
code
:
error
.
code
},
{
status
:
error
.
statusCode
},
);
}
console
.
error
(
'
Retry recharge error:
'
,
error
);
return
NextResponse
.
json
({
error
:
'
重试充值失败
'
},
{
status
:
500
});
}
}
tmp_api_admin_orders/[id]/route.ts
deleted
100644 → 0
View file @
a50d5d35
import
{
NextRequest
,
NextResponse
}
from
'
next/server
'
;
import
{
prisma
}
from
'
@/lib/db
'
;
import
{
verifyAdminToken
,
unauthorizedResponse
}
from
'
@/lib/admin-auth
'
;
export
async
function
GET
(
request
:
NextRequest
,
{
params
}:
{
params
:
Promise
<
{
id
:
string
}
>
},
)
{
if
(
!
verifyAdminToken
(
request
))
return
unauthorizedResponse
();
const
{
id
}
=
await
params
;
const
order
=
await
prisma
.
order
.
findUnique
({
where
:
{
id
},
include
:
{
auditLogs
:
{
orderBy
:
{
createdAt
:
'
desc
'
},
},
},
});
if
(
!
order
)
{
return
NextResponse
.
json
({
error
:
'
订单不存在
'
},
{
status
:
404
});
}
return
NextResponse
.
json
({
...
order
,
amount
:
Number
(
order
.
amount
),
refundAmount
:
order
.
refundAmount
?
Number
(
order
.
refundAmount
)
:
null
,
});
}
tmp_api_admin_orders/route.ts
deleted
100644 → 0
View file @
a50d5d35
import
{
NextRequest
,
NextResponse
}
from
'
next/server
'
;
import
{
prisma
}
from
'
@/lib/db
'
;
import
{
verifyAdminToken
,
unauthorizedResponse
}
from
'
@/lib/admin-auth
'
;
import
{
Prisma
}
from
'
@prisma/client
'
;
export
async
function
GET
(
request
:
NextRequest
)
{
if
(
!
verifyAdminToken
(
request
))
return
unauthorizedResponse
();
const
searchParams
=
request
.
nextUrl
.
searchParams
;
const
page
=
Math
.
max
(
1
,
Number
(
searchParams
.
get
(
'
page
'
)
||
'
1
'
));
const
pageSize
=
Math
.
min
(
100
,
Math
.
max
(
1
,
Number
(
searchParams
.
get
(
'
page_size
'
)
||
'
20
'
)));
const
status
=
searchParams
.
get
(
'
status
'
);
const
userId
=
searchParams
.
get
(
'
user_id
'
);
const
dateFrom
=
searchParams
.
get
(
'
date_from
'
);
const
dateTo
=
searchParams
.
get
(
'
date_to
'
);
const
where
:
Prisma
.
OrderWhereInput
=
{};
if
(
status
)
where
.
status
=
status
as
any
;
if
(
userId
)
where
.
userId
=
Number
(
userId
);
if
(
dateFrom
||
dateTo
)
{
where
.
createdAt
=
{};
if
(
dateFrom
)
where
.
createdAt
.
gte
=
new
Date
(
dateFrom
);
if
(
dateTo
)
where
.
createdAt
.
lte
=
new
Date
(
dateTo
);
}
const
[
orders
,
total
]
=
await
Promise
.
all
([
prisma
.
order
.
findMany
({
where
,
orderBy
:
{
createdAt
:
'
desc
'
},
skip
:
(
page
-
1
)
*
pageSize
,
take
:
pageSize
,
select
:
{
id
:
true
,
userId
:
true
,
userName
:
true
,
userEmail
:
true
,
amount
:
true
,
status
:
true
,
paymentType
:
true
,
createdAt
:
true
,
paidAt
:
true
,
completedAt
:
true
,
failedReason
:
true
,
expiresAt
:
true
,
},
}),
prisma
.
order
.
count
({
where
}),
]);
return
NextResponse
.
json
({
orders
:
orders
.
map
(
o
=>
({
...
o
,
amount
:
Number
(
o
.
amount
),
})),
total
,
page
,
page_size
:
pageSize
,
total_pages
:
Math
.
ceil
(
total
/
pageSize
),
});
}
tmp_api_orders/[id]/cancel/route.ts
deleted
100644 → 0
View file @
a50d5d35
import
{
NextRequest
,
NextResponse
}
from
'
next/server
'
;
import
{
z
}
from
'
zod
'
;
import
{
cancelOrder
,
OrderError
}
from
'
@/lib/order/service
'
;
const
cancelSchema
=
z
.
object
({
user_id
:
z
.
number
().
int
().
positive
(),
});
export
async
function
POST
(
request
:
NextRequest
,
{
params
}:
{
params
:
Promise
<
{
id
:
string
}
>
},
)
{
try
{
const
{
id
}
=
await
params
;
const
body
=
await
request
.
json
();
const
parsed
=
cancelSchema
.
safeParse
(
body
);
if
(
!
parsed
.
success
)
{
return
NextResponse
.
json
(
{
error
:
'
参数错误
'
,
details
:
parsed
.
error
.
flatten
().
fieldErrors
},
{
status
:
400
},
);
}
await
cancelOrder
(
id
,
parsed
.
data
.
user_id
);
return
NextResponse
.
json
({
success
:
true
});
}
catch
(
error
)
{
if
(
error
instanceof
OrderError
)
{
return
NextResponse
.
json
(
{
error
:
error
.
message
,
code
:
error
.
code
},
{
status
:
error
.
statusCode
},
);
}
console
.
error
(
'
Cancel order error:
'
,
error
);
return
NextResponse
.
json
({
error
:
'
取消订单失败
'
},
{
status
:
500
});
}
}
tmp_api_orders/[id]/route.ts
deleted
100644 → 0
View file @
a50d5d35
import
{
NextRequest
,
NextResponse
}
from
'
next/server
'
;
import
{
prisma
}
from
'
@/lib/db
'
;
export
async
function
GET
(
request
:
NextRequest
,
{
params
}:
{
params
:
Promise
<
{
id
:
string
}
>
},
)
{
const
{
id
}
=
await
params
;
const
order
=
await
prisma
.
order
.
findUnique
({
where
:
{
id
},
select
:
{
id
:
true
,
userId
:
true
,
userName
:
true
,
amount
:
true
,
status
:
true
,
paymentType
:
true
,
payUrl
:
true
,
qrCode
:
true
,
qrCodeImg
:
true
,
expiresAt
:
true
,
paidAt
:
true
,
completedAt
:
true
,
failedReason
:
true
,
createdAt
:
true
,
},
});
if
(
!
order
)
{
return
NextResponse
.
json
({
error
:
'
订单不存在
'
},
{
status
:
404
});
}
return
NextResponse
.
json
({
order_id
:
order
.
id
,
user_id
:
order
.
userId
,
user_name
:
order
.
userName
,
amount
:
Number
(
order
.
amount
),
status
:
order
.
status
,
payment_type
:
order
.
paymentType
,
pay_url
:
order
.
payUrl
,
qr_code
:
order
.
qrCode
,
qr_code_img
:
order
.
qrCodeImg
,
expires_at
:
order
.
expiresAt
,
paid_at
:
order
.
paidAt
,
completed_at
:
order
.
completedAt
,
failed_reason
:
order
.
failedReason
,
created_at
:
order
.
createdAt
,
});
}
tmp_api_orders/my/route.ts
deleted
100644 → 0
View file @
a50d5d35
import
{
NextRequest
,
NextResponse
}
from
'
next/server
'
;
import
{
prisma
}
from
'
@/lib/db
'
;
import
{
getCurrentUserByToken
}
from
'
@/lib/sub2api/client
'
;
export
async
function
GET
(
request
:
NextRequest
)
{
const
token
=
request
.
nextUrl
.
searchParams
.
get
(
'
token
'
)?.
trim
();
if
(
!
token
)
{
return
NextResponse
.
json
({
error
:
'
token is required
'
},
{
status
:
400
});
}
try
{
const
user
=
await
getCurrentUserByToken
(
token
);
const
orders
=
await
prisma
.
order
.
findMany
({
where
:
{
userId
:
user
.
id
},
orderBy
:
{
createdAt
:
'
desc
'
},
take
:
20
,
select
:
{
id
:
true
,
amount
:
true
,
status
:
true
,
paymentType
:
true
,
createdAt
:
true
,
},
});
return
NextResponse
.
json
({
user
:
{
id
:
user
.
id
,
username
:
user
.
username
,
email
:
user
.
email
,
displayName
:
user
.
username
||
user
.
email
||
`用户 #
${
user
.
id
}
`
,
balance
:
user
.
balance
,
},
orders
:
orders
.
map
((
item
)
=>
({
id
:
item
.
id
,
amount
:
Number
(
item
.
amount
),
status
:
item
.
status
,
paymentType
:
item
.
paymentType
,
createdAt
:
item
.
createdAt
,
})),
});
}
catch
(
error
)
{
console
.
error
(
'
Get my orders error:
'
,
error
);
return
NextResponse
.
json
({
error
:
'
unauthorized
'
},
{
status
:
401
});
}
}
tmp_api_orders/route.ts
deleted
100644 → 0
View file @
a50d5d35
import
{
NextRequest
,
NextResponse
}
from
'
next/server
'
;
import
{
z
}
from
'
zod
'
;
import
{
createOrder
,
OrderError
}
from
'
@/lib/order/service
'
;
import
{
getEnv
}
from
'
@/lib/config
'
;
const
createOrderSchema
=
z
.
object
({
user_id
:
z
.
number
().
int
().
positive
(),
amount
:
z
.
number
().
positive
(),
payment_type
:
z
.
enum
([
'
alipay
'
,
'
wxpay
'
]),
});
export
async
function
POST
(
request
:
NextRequest
)
{
try
{
const
env
=
getEnv
();
const
body
=
await
request
.
json
();
const
parsed
=
createOrderSchema
.
safeParse
(
body
);
if
(
!
parsed
.
success
)
{
return
NextResponse
.
json
(
{
error
:
'
参数错误
'
,
details
:
parsed
.
error
.
flatten
().
fieldErrors
},
{
status
:
400
},
);
}
const
{
user_id
,
amount
,
payment_type
}
=
parsed
.
data
;
// Validate amount range
if
(
amount
<
env
.
MIN_RECHARGE_AMOUNT
||
amount
>
env
.
MAX_RECHARGE_AMOUNT
)
{
return
NextResponse
.
json
(
{
error
:
`充值金额需在
${
env
.
MIN_RECHARGE_AMOUNT
}
-
${
env
.
MAX_RECHARGE_AMOUNT
}
之间`
},
{
status
:
400
},
);
}
// Validate payment type is enabled
if
(
!
env
.
ENABLED_PAYMENT_TYPES
.
includes
(
payment_type
))
{
return
NextResponse
.
json
(
{
error
:
`不支持的支付方式:
${
payment_type
}
`
},
{
status
:
400
},
);
}
const
clientIp
=
request
.
headers
.
get
(
'
x-forwarded-for
'
)?.
split
(
'
,
'
)[
0
]?.
trim
()
||
request
.
headers
.
get
(
'
x-real-ip
'
)
||
'
127.0.0.1
'
;
const
result
=
await
createOrder
({
userId
:
user_id
,
amount
,
paymentType
:
payment_type
,
clientIp
,
});
return
NextResponse
.
json
(
result
);
}
catch
(
error
)
{
if
(
error
instanceof
OrderError
)
{
return
NextResponse
.
json
(
{
error
:
error
.
message
,
code
:
error
.
code
},
{
status
:
error
.
statusCode
},
);
}
console
.
error
(
'
Create order error:
'
,
error
);
return
NextResponse
.
json
(
{
error
:
'
创建订单失败,请稍后重试
'
},
{
status
:
500
},
);
}
}
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