feat(admin-frontend): 完成订阅与系统管理真实工作台
补齐订单、优惠券、主题、插件、公告与支付管理页面, 接入对应后台接口、路由入口与工具层类型定义。 同时修复套餐页开关初始化误写问题,避免浏览即触发写操作。 在订阅协议侧为 Stash 导出增加 AnyTLS 版本守卫, 未知版本或低于 3.3.0 时不再导出该协议,并补充回归测试与知识记录。
This commit is contained in:
@@ -1,13 +1,32 @@
|
||||
import { adminClient } from './client'
|
||||
import type {
|
||||
AdminCouponFetchParams,
|
||||
AdminCouponGeneratePayload,
|
||||
AdminCouponListItem,
|
||||
AdminConfigGroupKey,
|
||||
AdminConfigMappings,
|
||||
AdminOrderAssignPayload,
|
||||
AdminOrderDetail,
|
||||
AdminOrderFetchParams,
|
||||
AdminOrderListItem,
|
||||
AdminKnowledgeDetail,
|
||||
AdminKnowledgeListItem,
|
||||
AdminKnowledgeSavePayload,
|
||||
AdminNoticeItem,
|
||||
AdminNoticeSavePayload,
|
||||
AdminNodeItem,
|
||||
AdminNodeUpdatePayload,
|
||||
AdminPaymentConfigFields,
|
||||
AdminPaymentListItem,
|
||||
AdminPaymentSavePayload,
|
||||
AdminQueueFailedJobResult,
|
||||
AdminPaginationResult,
|
||||
AdminPlanListItem,
|
||||
AdminPlanSavePayload,
|
||||
AdminThemeConfigRecord,
|
||||
AdminThemeListResult,
|
||||
AdminPluginItem,
|
||||
AdminPluginTypeItem,
|
||||
AdminServerGroupItem,
|
||||
AdminTicketDetail,
|
||||
AdminTicketFetchParams,
|
||||
@@ -110,6 +129,86 @@ export function getPlans(): Promise<ApiResponse<AdminPlanListItem[]>> {
|
||||
return unwrap<AdminPlanListItem[]>('/plan/fetch')
|
||||
}
|
||||
|
||||
export function fetchOrders(params: AdminOrderFetchParams): Promise<AdminPaginationResult<AdminOrderListItem>> {
|
||||
return adminClient
|
||||
.get<AdminPaginationResult<AdminOrderListItem>>('/order/fetch', { params })
|
||||
.then((res) => res.data)
|
||||
}
|
||||
|
||||
export function getOrderDetail(id: number): Promise<ApiResponse<AdminOrderDetail>> {
|
||||
return unwrapPost<AdminOrderDetail>('/order/detail', { id })
|
||||
}
|
||||
|
||||
export function assignOrder(payload: AdminOrderAssignPayload): Promise<ApiResponse<string>> {
|
||||
return unwrapPost<string>('/order/assign', payload as unknown as Record<string, unknown>)
|
||||
}
|
||||
|
||||
export function markOrderPaid(tradeNo: string): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/order/paid', { trade_no: tradeNo })
|
||||
}
|
||||
|
||||
export function cancelOrder(tradeNo: string): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/order/cancel', { trade_no: tradeNo })
|
||||
}
|
||||
|
||||
export function updateOrderCommissionStatus(tradeNo: string, commissionStatus: number): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/order/update', {
|
||||
trade_no: tradeNo,
|
||||
commission_status: commissionStatus,
|
||||
})
|
||||
}
|
||||
|
||||
export function getThemes(): Promise<ApiResponse<AdminThemeListResult>> {
|
||||
return unwrap<AdminThemeListResult>('/theme/getThemes')
|
||||
}
|
||||
|
||||
export function getThemeConfig(name: string): Promise<ApiResponse<AdminThemeConfigRecord>> {
|
||||
return unwrapPost<AdminThemeConfigRecord>('/theme/getThemeConfig', { name })
|
||||
}
|
||||
|
||||
export function saveThemeConfig(
|
||||
name: string,
|
||||
config: AdminThemeConfigRecord,
|
||||
): Promise<ApiResponse<AdminThemeConfigRecord>> {
|
||||
return unwrapPost<AdminThemeConfigRecord>('/theme/saveThemeConfig', { name, config })
|
||||
}
|
||||
|
||||
export function uploadTheme(file: File): Promise<ApiResponse<boolean>> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return adminClient
|
||||
.post<ApiResponse<boolean>>('/theme/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
.then((res) => res.data)
|
||||
}
|
||||
|
||||
export function fetchCoupons(params: AdminCouponFetchParams = {}): Promise<AdminPaginationResult<AdminCouponListItem>> {
|
||||
return adminClient
|
||||
.get<AdminPaginationResult<AdminCouponListItem>>('/coupon/fetch', {
|
||||
params: {
|
||||
current: params.current,
|
||||
pageSize: params.pageSize,
|
||||
},
|
||||
})
|
||||
.then((res) => res.data)
|
||||
}
|
||||
|
||||
export function saveCoupon(payload: AdminCouponGeneratePayload): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/coupon/generate', payload as unknown as Record<string, unknown>)
|
||||
}
|
||||
|
||||
export function updateCoupon(id: number, payload: Pick<AdminCouponListItem, 'show'>): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/coupon/update', {
|
||||
id,
|
||||
...payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteCoupon(id: number): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/coupon/drop', { id })
|
||||
}
|
||||
|
||||
export function fetchAdminConfig(key?: AdminConfigGroupKey): Promise<ApiResponse<AdminConfigMappings>> {
|
||||
return unwrap<AdminConfigMappings>('/config/fetch', key ? { key } : undefined)
|
||||
}
|
||||
@@ -128,6 +227,136 @@ export function setTelegramWebhook(payload: {
|
||||
return unwrapPost<Record<string, unknown>>('/config/setTelegramWebhook', payload)
|
||||
}
|
||||
|
||||
export function getKnowledges(): Promise<ApiResponse<AdminKnowledgeListItem[]>> {
|
||||
return unwrap<AdminKnowledgeListItem[]>('/knowledge/fetch')
|
||||
}
|
||||
|
||||
export function getKnowledgeById(id: number): Promise<ApiResponse<AdminKnowledgeDetail>> {
|
||||
return unwrap<AdminKnowledgeDetail>('/knowledge/fetch', { id })
|
||||
}
|
||||
|
||||
export function getKnowledgeCategories(): Promise<ApiResponse<string[]>> {
|
||||
return unwrap<string[]>('/knowledge/getCategory')
|
||||
}
|
||||
|
||||
export function saveKnowledge(payload: AdminKnowledgeSavePayload): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/knowledge/save', payload as unknown as Record<string, unknown>)
|
||||
}
|
||||
|
||||
export function toggleKnowledgeVisibility(id: number): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/knowledge/show', { id })
|
||||
}
|
||||
|
||||
export function deleteKnowledge(id: number): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/knowledge/drop', { id })
|
||||
}
|
||||
|
||||
export function sortKnowledges(ids: number[]): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/knowledge/sort', { ids })
|
||||
}
|
||||
|
||||
export function fetchNotices(): Promise<ApiResponse<AdminNoticeItem[]>> {
|
||||
return unwrap<AdminNoticeItem[]>('/notice/fetch')
|
||||
}
|
||||
|
||||
export function saveNotice(payload: AdminNoticeSavePayload): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/notice/save', payload as unknown as Record<string, unknown>)
|
||||
}
|
||||
|
||||
export function toggleNoticeVisibility(id: number): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/notice/show', { id })
|
||||
}
|
||||
|
||||
export function deleteNotice(id: number): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/notice/drop', { id })
|
||||
}
|
||||
|
||||
export function sortNotices(ids: number[]): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/notice/sort', { ids })
|
||||
}
|
||||
|
||||
export function fetchPayments(): Promise<ApiResponse<AdminPaymentListItem[]>> {
|
||||
return unwrap<AdminPaymentListItem[]>('/payment/fetch')
|
||||
}
|
||||
|
||||
export function getPaymentMethods(): Promise<ApiResponse<string[]>> {
|
||||
return unwrap<string[]>('/payment/getPaymentMethods')
|
||||
}
|
||||
|
||||
export function getPaymentForm(payload: {
|
||||
payment: string
|
||||
id?: number
|
||||
}): Promise<ApiResponse<AdminPaymentConfigFields>> {
|
||||
return unwrapPost<AdminPaymentConfigFields>('/payment/getPaymentForm', payload as unknown as Record<string, unknown>)
|
||||
}
|
||||
|
||||
export function savePayment(payload: AdminPaymentSavePayload): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/payment/save', payload as unknown as Record<string, unknown>)
|
||||
}
|
||||
|
||||
export function togglePaymentVisibility(id: number): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/payment/show', { id })
|
||||
}
|
||||
|
||||
export function deletePayment(id: number): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/payment/drop', { id })
|
||||
}
|
||||
|
||||
export function sortPayments(ids: number[]): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/payment/sort', { ids })
|
||||
}
|
||||
|
||||
export function getPluginTypes(): Promise<ApiResponse<AdminPluginTypeItem[]>> {
|
||||
return unwrap<AdminPluginTypeItem[]>('/plugin/types')
|
||||
}
|
||||
|
||||
export function getPlugins(params: {
|
||||
type?: string
|
||||
} = {}): Promise<ApiResponse<AdminPluginItem[]>> {
|
||||
return unwrap<AdminPluginItem[]>('/plugin/getPlugins', params)
|
||||
}
|
||||
|
||||
export function installPlugin(code: string): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/plugin/install', { code })
|
||||
}
|
||||
|
||||
export function uninstallPlugin(code: string): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/plugin/uninstall', { code })
|
||||
}
|
||||
|
||||
export function enablePlugin(code: string): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/plugin/enable', { code })
|
||||
}
|
||||
|
||||
export function disablePlugin(code: string): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/plugin/disable', { code })
|
||||
}
|
||||
|
||||
export function getPluginConfig(code: string): Promise<ApiResponse<Record<string, unknown>>> {
|
||||
return unwrap<Record<string, unknown>>('/plugin/config', { code })
|
||||
}
|
||||
|
||||
export function savePluginConfig(code: string, config: Record<string, unknown>): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/plugin/config', { code, config })
|
||||
}
|
||||
|
||||
export function upgradePlugin(code: string): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/plugin/upgrade', { code })
|
||||
}
|
||||
|
||||
export function uploadPluginPackage(file: File): Promise<ApiResponse<boolean>> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
return adminClient
|
||||
.post<ApiResponse<boolean>>('/plugin/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
.then((res) => res.data)
|
||||
}
|
||||
|
||||
export function savePlan(payload: AdminPlanSavePayload): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/plan/save', payload as unknown as Record<string, unknown>)
|
||||
}
|
||||
|
||||
@@ -62,8 +62,8 @@ const managementItems: MenuItem[] = [
|
||||
|
||||
const subscriptionItems: MenuItem[] = [
|
||||
{ index: '/subscriptions/plans', title: '套餐管理', icon: CollectionTag },
|
||||
{ index: '/subscriptions/orders', title: '订单管理', icon: Document, disabled: true, badge: '即将开放' },
|
||||
{ index: '/subscriptions/coupons', title: '优惠券管理', icon: Discount, disabled: true, badge: '即将开放' },
|
||||
{ index: '/subscriptions/orders', title: '订单管理', icon: Document },
|
||||
{ index: '/subscriptions/coupons', title: '优惠券管理', icon: Discount },
|
||||
{ index: '/subscriptions/gift-cards', title: '礼品卡管理', icon: Present, disabled: true, badge: '即将开放' },
|
||||
]
|
||||
|
||||
|
||||
@@ -59,6 +59,18 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/subscriptions/PlansView.vue'),
|
||||
meta: { title: '订阅套餐', kicker: 'Plans' },
|
||||
},
|
||||
{
|
||||
path: 'subscriptions/orders',
|
||||
name: 'SubscriptionOrders',
|
||||
component: () => import('@/views/subscriptions/OrdersView.vue'),
|
||||
meta: { title: '订单管理', kicker: 'Orders' },
|
||||
},
|
||||
{
|
||||
path: 'subscriptions/coupons',
|
||||
name: 'SubscriptionCoupons',
|
||||
component: () => import('@/views/subscriptions/CouponsView.vue'),
|
||||
meta: { title: '优惠券管理', kicker: 'Coupons' },
|
||||
},
|
||||
{
|
||||
path: 'system/config',
|
||||
name: 'SystemConfig',
|
||||
@@ -68,31 +80,31 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'system/plugins',
|
||||
name: 'SystemPlugins',
|
||||
component: () => import('@/views/system/SystemPlaceholderView.vue'),
|
||||
component: () => import('@/views/system/PluginManagementView.vue'),
|
||||
meta: { title: '插件管理', kicker: 'System Management' },
|
||||
},
|
||||
{
|
||||
path: 'system/themes',
|
||||
name: 'SystemThemes',
|
||||
component: () => import('@/views/system/SystemPlaceholderView.vue'),
|
||||
component: () => import('@/views/system/ThemesView.vue'),
|
||||
meta: { title: '主题配置', kicker: 'System Management' },
|
||||
},
|
||||
{
|
||||
path: 'system/notices',
|
||||
name: 'SystemNotices',
|
||||
component: () => import('@/views/system/SystemPlaceholderView.vue'),
|
||||
component: () => import('@/views/system/SystemNoticesView.vue'),
|
||||
meta: { title: '公告管理', kicker: 'System Management' },
|
||||
},
|
||||
{
|
||||
path: 'system/payments',
|
||||
name: 'SystemPayments',
|
||||
component: () => import('@/views/system/SystemPlaceholderView.vue'),
|
||||
component: () => import('@/views/system/SystemPaymentsView.vue'),
|
||||
meta: { title: '支付配置', kicker: 'System Management' },
|
||||
},
|
||||
{
|
||||
path: 'system/knowledge',
|
||||
name: 'SystemKnowledge',
|
||||
component: () => import('@/views/system/SystemPlaceholderView.vue'),
|
||||
component: () => import('@/views/system/SystemKnowledgeView.vue'),
|
||||
meta: { title: '知识库管理', kicker: 'System Management' },
|
||||
},
|
||||
],
|
||||
|
||||
Vendored
+277
-4
@@ -136,6 +136,14 @@ export interface AdminQueueFailedJobResult extends AdminPaginationResult<AdminQu
|
||||
export interface AdminPaginationResult<T> {
|
||||
data: T[]
|
||||
total: number
|
||||
current_page?: number
|
||||
per_page?: number
|
||||
last_page?: number
|
||||
}
|
||||
|
||||
export interface AdminTableSort {
|
||||
id: string
|
||||
desc: boolean
|
||||
}
|
||||
|
||||
export interface AdminGroupOption {
|
||||
@@ -175,6 +183,99 @@ export type AdminConfigGroupValue = Record<string, unknown>
|
||||
|
||||
export type AdminConfigMappings = Partial<Record<AdminConfigGroupKey, AdminConfigGroupValue>>
|
||||
|
||||
export interface AdminKnowledgeListItem {
|
||||
id: number
|
||||
title: string
|
||||
updated_at: number | string | null
|
||||
category: string | null
|
||||
show: boolean
|
||||
}
|
||||
|
||||
export interface AdminKnowledgeDetail extends AdminKnowledgeListItem {
|
||||
body: string
|
||||
language: string
|
||||
sort?: number | null
|
||||
created_at?: number | string | null
|
||||
}
|
||||
|
||||
export interface AdminKnowledgeSavePayload {
|
||||
id?: number
|
||||
category: string
|
||||
language: string
|
||||
title: string
|
||||
body: string
|
||||
show: boolean
|
||||
}
|
||||
|
||||
export type AdminPluginTypeValue = 'feature' | 'payment'
|
||||
|
||||
export interface AdminPluginTypeItem {
|
||||
value: AdminPluginTypeValue
|
||||
label: string
|
||||
description: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
export interface AdminPluginConfigOption {
|
||||
label: string
|
||||
value: string | number | boolean
|
||||
}
|
||||
|
||||
export interface AdminPluginConfigField {
|
||||
type: string
|
||||
label: string
|
||||
placeholder: string
|
||||
description: string
|
||||
value: unknown
|
||||
options: AdminPluginConfigOption[] | Record<string, string> | Array<string | number | boolean>
|
||||
}
|
||||
|
||||
export interface AdminPluginItem {
|
||||
code: string
|
||||
name: string
|
||||
version: string
|
||||
description: string
|
||||
author: string
|
||||
type: AdminPluginTypeValue | string
|
||||
is_installed: boolean
|
||||
is_enabled: boolean
|
||||
is_protected: boolean
|
||||
can_be_deleted: boolean
|
||||
config: Record<string, AdminPluginConfigField>
|
||||
readme: string
|
||||
need_upgrade: boolean
|
||||
admin_menus?: unknown
|
||||
admin_crud?: unknown
|
||||
}
|
||||
|
||||
export type AdminThemeFieldType = 'input' | 'textarea' | 'select'
|
||||
|
||||
export interface AdminThemeConfigField {
|
||||
label: string
|
||||
placeholder?: string
|
||||
field_name: string
|
||||
field_type: AdminThemeFieldType
|
||||
select_options?: Record<string, string>
|
||||
default_value?: string | number | boolean | null
|
||||
}
|
||||
|
||||
export interface AdminThemeSummary {
|
||||
name: string
|
||||
description?: string
|
||||
version?: string
|
||||
images?: string
|
||||
configs?: AdminThemeConfigField[]
|
||||
can_delete?: boolean
|
||||
is_system?: boolean
|
||||
}
|
||||
|
||||
export interface AdminThemeListResult {
|
||||
themes: Record<string, AdminThemeSummary>
|
||||
active: string
|
||||
}
|
||||
|
||||
export type AdminThemeConfigRecord = Record<string, string | number | boolean | null>
|
||||
|
||||
export interface AdminPlanPriceMap {
|
||||
monthly?: number | null
|
||||
quarterly?: number | null
|
||||
@@ -218,11 +319,186 @@ export interface AdminPlanSavePayload {
|
||||
force_update?: boolean
|
||||
}
|
||||
|
||||
export interface AdminNoticeItem {
|
||||
id: number
|
||||
title: string
|
||||
content: string
|
||||
img_url?: string | null
|
||||
tags?: string[] | null
|
||||
show: boolean | number
|
||||
popup?: boolean | number | null
|
||||
sort?: number | null
|
||||
created_at?: number | string | null
|
||||
updated_at?: number | string | null
|
||||
}
|
||||
|
||||
export interface AdminNoticeSavePayload {
|
||||
id?: number
|
||||
title: string
|
||||
content: string
|
||||
img_url?: string | null
|
||||
tags?: string[]
|
||||
show?: boolean
|
||||
popup?: boolean
|
||||
}
|
||||
|
||||
export interface AdminPaymentConfigField {
|
||||
type: string
|
||||
label: string
|
||||
placeholder: string
|
||||
description: string
|
||||
value: string
|
||||
options: AdminPluginConfigOption[] | Record<string, string> | Array<string | number | boolean>
|
||||
}
|
||||
|
||||
export type AdminPaymentConfigFields = Record<string, AdminPaymentConfigField>
|
||||
|
||||
export interface AdminPaymentListItem {
|
||||
id: number
|
||||
uuid: string
|
||||
payment: string
|
||||
name: string
|
||||
icon?: string | null
|
||||
config?: Record<string, unknown> | null
|
||||
notify_domain?: string | null
|
||||
notify_url?: string | null
|
||||
handling_fee_fixed?: number | null
|
||||
handling_fee_percent?: number | string | null
|
||||
enable: boolean | number
|
||||
sort?: number | null
|
||||
created_at?: number | string | null
|
||||
updated_at?: number | string | null
|
||||
}
|
||||
|
||||
export interface AdminPaymentSavePayload {
|
||||
id?: number
|
||||
name: string
|
||||
icon?: string | null
|
||||
payment: string
|
||||
config: Record<string, string>
|
||||
notify_domain?: string | null
|
||||
handling_fee_fixed?: number | null
|
||||
handling_fee_percent?: number | null
|
||||
}
|
||||
|
||||
export type AdminCouponType = 1 | 2
|
||||
|
||||
export interface AdminCouponListItem {
|
||||
id: number
|
||||
show: boolean
|
||||
name: string
|
||||
type: AdminCouponType
|
||||
value: number
|
||||
code: string
|
||||
limit_use?: number | null
|
||||
limit_use_with_user?: number | null
|
||||
limit_plan_ids?: string[] | null
|
||||
limit_period?: string[] | null
|
||||
started_at: number
|
||||
ended_at: number
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export interface AdminCouponFetchParams {
|
||||
current?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface AdminCouponGeneratePayload {
|
||||
id?: number
|
||||
generate_count?: number
|
||||
name: string
|
||||
type: AdminCouponType
|
||||
value: number
|
||||
started_at: number
|
||||
ended_at: number
|
||||
limit_use?: number | null
|
||||
limit_use_with_user?: number | null
|
||||
limit_plan_ids?: number[]
|
||||
limit_period?: string[]
|
||||
code?: string
|
||||
}
|
||||
|
||||
export interface AdminUserRef {
|
||||
id: number
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface AdminOrderUserRef {
|
||||
id: number
|
||||
email: string
|
||||
balance?: number | null
|
||||
commission_balance?: number | null
|
||||
plan_id?: number | null
|
||||
}
|
||||
|
||||
export interface AdminCommissionLogItem {
|
||||
id: number
|
||||
invite_user_id: number
|
||||
user_id: number
|
||||
trade_no: string
|
||||
order_amount: number
|
||||
get_amount: number
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export interface AdminOrderListItem {
|
||||
id: number
|
||||
invite_user_id?: number | null
|
||||
user_id: number
|
||||
plan_id: number | null
|
||||
coupon_id?: number | null
|
||||
payment_id?: number | null
|
||||
type: number
|
||||
period: string
|
||||
trade_no: string
|
||||
callback_no?: string | null
|
||||
total_amount: number
|
||||
handling_amount?: number | null
|
||||
discount_amount?: number | null
|
||||
surplus_amount?: number | null
|
||||
refund_amount?: number | null
|
||||
balance_amount?: number | null
|
||||
surplus_order_ids?: number[] | null
|
||||
status: number
|
||||
commission_status?: number | null
|
||||
commission_balance?: number | null
|
||||
actual_commission_balance?: number | null
|
||||
paid_at?: number | null
|
||||
created_at: number
|
||||
updated_at: number
|
||||
plan?: AdminPlanOption | null
|
||||
}
|
||||
|
||||
export interface AdminOrderDetail extends AdminOrderListItem {
|
||||
user?: AdminOrderUserRef | null
|
||||
invite_user?: AdminOrderUserRef | null
|
||||
commission_log?: AdminCommissionLogItem[]
|
||||
surplus_orders?: AdminOrderListItem[]
|
||||
}
|
||||
|
||||
export interface AdminOrderFilter {
|
||||
id: string
|
||||
value: string | number | Array<string | number>
|
||||
}
|
||||
|
||||
export interface AdminOrderFetchParams {
|
||||
current: number
|
||||
pageSize: number
|
||||
filter?: AdminOrderFilter[]
|
||||
sort?: AdminTableSort[]
|
||||
is_commission?: boolean
|
||||
}
|
||||
|
||||
export interface AdminOrderAssignPayload {
|
||||
email: string
|
||||
plan_id: number
|
||||
period: string
|
||||
total_amount: number
|
||||
}
|
||||
|
||||
export interface AdminUserListItem {
|
||||
id: number
|
||||
email: string
|
||||
@@ -260,10 +536,7 @@ export interface AdminUserFilter {
|
||||
logic?: 'and' | 'or'
|
||||
}
|
||||
|
||||
export interface AdminUserSort {
|
||||
id: string
|
||||
desc: boolean
|
||||
}
|
||||
export type AdminUserSort = AdminTableSort
|
||||
|
||||
export interface AdminUserFetchParams {
|
||||
current: number
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import type {
|
||||
AdminCouponGeneratePayload,
|
||||
AdminCouponListItem,
|
||||
AdminCouponType,
|
||||
} from '@/types/api'
|
||||
|
||||
export type CouponTypeFilter = 'all' | `${AdminCouponType}`
|
||||
export type CouponSortKey = 'id' | 'type' | 'limit_use' | 'limit_use_with_user' | 'ended_at'
|
||||
export type CouponSortOrder = 'ascending' | 'descending' | null
|
||||
|
||||
export interface CouponFormModel {
|
||||
id?: number
|
||||
name: string
|
||||
generateCount: number | null
|
||||
code: string
|
||||
type: AdminCouponType
|
||||
value: number | null
|
||||
dateRange: [string, string] | []
|
||||
limitUse: number | null
|
||||
limitUseWithUser: number | null
|
||||
limitPlanIds: number[]
|
||||
limitPeriod: string[]
|
||||
}
|
||||
|
||||
export interface CouponExpiryMeta {
|
||||
text: string
|
||||
kind: 'danger' | 'success' | 'info'
|
||||
}
|
||||
|
||||
export const COUPON_TYPE_OPTIONS: Array<{
|
||||
label: string
|
||||
shortLabel: string
|
||||
value: AdminCouponType
|
||||
}> = [
|
||||
{ label: '按金额优惠', shortLabel: '金额优惠', value: 1 },
|
||||
{ label: '按比例优惠', shortLabel: '比例优惠', value: 2 },
|
||||
]
|
||||
|
||||
export const COUPON_PERIOD_OPTIONS = [
|
||||
{ label: '月付', value: 'month_price' },
|
||||
{ label: '季付', value: 'quarter_price' },
|
||||
{ label: '半年付', value: 'half_year_price' },
|
||||
{ label: '年付', value: 'year_price' },
|
||||
{ label: '两年付', value: 'two_year_price' },
|
||||
{ label: '三年付', value: 'three_year_price' },
|
||||
{ label: '一次性', value: 'onetime_price' },
|
||||
{ label: '重置流量', value: 'reset_price' },
|
||||
] as const
|
||||
|
||||
function clampNumber(value: number | null): number | null {
|
||||
if (!Number.isFinite(Number(value))) {
|
||||
return null
|
||||
}
|
||||
return Number(value)
|
||||
}
|
||||
|
||||
function roundCurrencyToCent(value: number | null): number {
|
||||
return Math.round(Number(value || 0) * 100)
|
||||
}
|
||||
|
||||
function formatDatePart(date: Date): string {
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${month}/${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
function normalizeTimestampToMs(timestamp: number | null | undefined): string {
|
||||
const value = Number(timestamp || 0)
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return ''
|
||||
}
|
||||
return String(value * 1000)
|
||||
}
|
||||
|
||||
export function createEmptyCouponForm(): CouponFormModel {
|
||||
const now = Date.now()
|
||||
const nextWeek = now + 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
return {
|
||||
name: '',
|
||||
generateCount: null,
|
||||
code: '',
|
||||
type: 1,
|
||||
value: null,
|
||||
dateRange: [String(now), String(nextWeek)],
|
||||
limitUse: null,
|
||||
limitUseWithUser: null,
|
||||
limitPlanIds: [],
|
||||
limitPeriod: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeCoupon(coupon: AdminCouponListItem): AdminCouponListItem {
|
||||
return {
|
||||
...coupon,
|
||||
show: Boolean(coupon.show),
|
||||
limit_plan_ids: coupon.limit_plan_ids ?? null,
|
||||
limit_period: coupon.limit_period ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function toCouponFormModel(coupon?: AdminCouponListItem | null): CouponFormModel {
|
||||
const base = createEmptyCouponForm()
|
||||
if (!coupon) {
|
||||
return base
|
||||
}
|
||||
|
||||
return {
|
||||
id: coupon.id,
|
||||
name: coupon.name || '',
|
||||
generateCount: null,
|
||||
code: coupon.code || '',
|
||||
type: coupon.type,
|
||||
value: coupon.type === 1
|
||||
? Number((coupon.value / 100).toFixed(2))
|
||||
: Number(coupon.value),
|
||||
dateRange: [
|
||||
normalizeTimestampToMs(coupon.started_at),
|
||||
normalizeTimestampToMs(coupon.ended_at),
|
||||
],
|
||||
limitUse: clampNumber(coupon.limit_use ?? null),
|
||||
limitUseWithUser: clampNumber(coupon.limit_use_with_user ?? null),
|
||||
limitPlanIds: (coupon.limit_plan_ids ?? [])
|
||||
.map((item) => Number(item))
|
||||
.filter((item) => Number.isFinite(item)),
|
||||
limitPeriod: [...(coupon.limit_period ?? [])],
|
||||
}
|
||||
}
|
||||
|
||||
export function toCouponSavePayload(form: CouponFormModel): AdminCouponGeneratePayload {
|
||||
const [startedAt, endedAt] = form.dateRange
|
||||
const normalizedCode = form.code.trim()
|
||||
const payload: AdminCouponGeneratePayload = {
|
||||
id: form.id,
|
||||
name: form.name.trim(),
|
||||
type: form.type,
|
||||
value: form.type === 1
|
||||
? roundCurrencyToCent(form.value)
|
||||
: Math.round(Number(form.value || 0)),
|
||||
started_at: Math.floor(Number(startedAt) / 1000),
|
||||
ended_at: Math.floor(Number(endedAt) / 1000),
|
||||
}
|
||||
|
||||
if (form.generateCount && form.generateCount > 1) {
|
||||
payload.generate_count = Math.round(form.generateCount)
|
||||
}
|
||||
|
||||
if (normalizedCode) {
|
||||
payload.code = normalizedCode
|
||||
}
|
||||
|
||||
if (form.limitUse !== null && Number.isFinite(form.limitUse)) {
|
||||
payload.limit_use = Math.round(form.limitUse)
|
||||
}
|
||||
|
||||
if (form.limitUseWithUser !== null && Number.isFinite(form.limitUseWithUser)) {
|
||||
payload.limit_use_with_user = Math.round(form.limitUseWithUser)
|
||||
}
|
||||
|
||||
if (form.limitPlanIds.length) {
|
||||
payload.limit_plan_ids = form.limitPlanIds
|
||||
}
|
||||
|
||||
if (form.limitPeriod.length) {
|
||||
payload.limit_period = form.limitPeriod
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
export function getCouponTypeLabel(type: AdminCouponType): string {
|
||||
return COUPON_TYPE_OPTIONS.find((item) => item.value === type)?.label || '未知类型'
|
||||
}
|
||||
|
||||
export function getCouponTypeShortLabel(type: AdminCouponType): string {
|
||||
return COUPON_TYPE_OPTIONS.find((item) => item.value === type)?.shortLabel || '未知类型'
|
||||
}
|
||||
|
||||
export function formatCouponValue(coupon: Pick<AdminCouponListItem, 'type' | 'value'>): string {
|
||||
if (coupon.type === 1) {
|
||||
return `¥${(coupon.value / 100).toFixed(2).replace(/\.00$/, '')}`
|
||||
}
|
||||
return `${coupon.value}%`
|
||||
}
|
||||
|
||||
export function filterCoupons(
|
||||
coupons: AdminCouponListItem[],
|
||||
keyword: string,
|
||||
typeFilter: CouponTypeFilter,
|
||||
): AdminCouponListItem[] {
|
||||
const normalized = keyword.trim().toLowerCase()
|
||||
return coupons.filter((coupon) => {
|
||||
const matchesType = typeFilter === 'all' || String(coupon.type) === typeFilter
|
||||
if (!matchesType) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!normalized) {
|
||||
return true
|
||||
}
|
||||
|
||||
const haystack = [coupon.id, coupon.name, coupon.code]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return haystack.includes(normalized)
|
||||
})
|
||||
}
|
||||
|
||||
export function sortCoupons(
|
||||
coupons: AdminCouponListItem[],
|
||||
sortKey: CouponSortKey,
|
||||
sortOrder: CouponSortOrder,
|
||||
): AdminCouponListItem[] {
|
||||
if (!sortOrder) {
|
||||
return [...coupons].sort((left, right) => right.id - left.id)
|
||||
}
|
||||
|
||||
const factor = sortOrder === 'ascending' ? 1 : -1
|
||||
return [...coupons].sort((left, right) => {
|
||||
const leftValue = Number(left[sortKey] ?? -1)
|
||||
const rightValue = Number(right[sortKey] ?? -1)
|
||||
return (leftValue - rightValue) * factor
|
||||
})
|
||||
}
|
||||
|
||||
export function formatCouponLimit(value: number | null | undefined, fallback = '无限次'): string {
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return fallback
|
||||
}
|
||||
return String(numeric)
|
||||
}
|
||||
|
||||
export function formatCouponDateRange(coupon: Pick<AdminCouponListItem, 'started_at' | 'ended_at'>): string {
|
||||
return `${formatDatePart(new Date(coupon.started_at * 1000))} → ${formatDatePart(new Date(coupon.ended_at * 1000))}`
|
||||
}
|
||||
|
||||
export function getCouponExpiryMeta(endedAt: number): CouponExpiryMeta {
|
||||
const diff = endedAt * 1000 - Date.now()
|
||||
const diffDays = Math.max(0, Math.floor(Math.abs(diff) / (24 * 60 * 60 * 1000)))
|
||||
if (diff < 0) {
|
||||
return {
|
||||
text: `已过期${diffDays}天`,
|
||||
kind: 'danger',
|
||||
}
|
||||
}
|
||||
|
||||
if (diffDays <= 3) {
|
||||
return {
|
||||
text: `剩余${diffDays}天`,
|
||||
kind: 'info',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: `有效中`,
|
||||
kind: 'success',
|
||||
}
|
||||
}
|
||||
|
||||
export function countEnabledCoupons(coupons: AdminCouponListItem[]): number {
|
||||
return coupons.filter((coupon) => coupon.show).length
|
||||
}
|
||||
|
||||
export function countExpiredCoupons(coupons: AdminCouponListItem[]): number {
|
||||
return coupons.filter((coupon) => coupon.ended_at * 1000 < Date.now()).length
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import type {
|
||||
AdminKnowledgeDetail,
|
||||
AdminKnowledgeListItem,
|
||||
AdminKnowledgeSavePayload,
|
||||
} from '@/types/api'
|
||||
|
||||
export interface KnowledgeFormModel {
|
||||
id?: number
|
||||
title: string
|
||||
category: string
|
||||
language: string
|
||||
show: boolean
|
||||
body: string
|
||||
}
|
||||
|
||||
export interface KnowledgeLanguageOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const markdown = new MarkdownIt({
|
||||
html: true,
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
})
|
||||
|
||||
export const KNOWLEDGE_LANGUAGE_OPTIONS: KnowledgeLanguageOption[] = [
|
||||
{ label: '简体中文', value: 'zh-CN' },
|
||||
{ label: 'English', value: 'en-US' },
|
||||
]
|
||||
|
||||
function normalizeText(value: string): string {
|
||||
return value.trim().replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
export function createEmptyKnowledgeForm(): KnowledgeFormModel {
|
||||
return {
|
||||
title: '',
|
||||
category: '',
|
||||
language: 'zh-CN',
|
||||
show: true,
|
||||
body: '',
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeKnowledgeItem(item: AdminKnowledgeListItem): AdminKnowledgeListItem {
|
||||
return {
|
||||
...item,
|
||||
category: typeof item.category === 'string' ? item.category.trim() : item.category,
|
||||
show: Boolean(item.show),
|
||||
}
|
||||
}
|
||||
|
||||
export function toKnowledgeFormModel(item?: AdminKnowledgeDetail | null): KnowledgeFormModel {
|
||||
if (!item) {
|
||||
return createEmptyKnowledgeForm()
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title || '',
|
||||
category: typeof item.category === 'string' ? item.category : '',
|
||||
language: item.language || 'zh-CN',
|
||||
show: Boolean(item.show),
|
||||
body: item.body || '',
|
||||
}
|
||||
}
|
||||
|
||||
export function toKnowledgeSavePayload(form: KnowledgeFormModel): AdminKnowledgeSavePayload {
|
||||
return {
|
||||
id: form.id,
|
||||
title: form.title.trim(),
|
||||
category: normalizeText(form.category),
|
||||
language: form.language,
|
||||
show: Boolean(form.show),
|
||||
body: form.body.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
export function renderKnowledgeBody(source: string): string {
|
||||
return markdown.render(source || '')
|
||||
}
|
||||
|
||||
export function getKnowledgeCategoryLabel(category: string | null | undefined): string {
|
||||
if (typeof category !== 'string') {
|
||||
return '未分类'
|
||||
}
|
||||
|
||||
const normalized = normalizeText(category)
|
||||
return normalized || '未分类'
|
||||
}
|
||||
|
||||
export function normalizeKnowledgeCategories(
|
||||
categories: string[],
|
||||
items: Array<Pick<AdminKnowledgeListItem, 'category'>>,
|
||||
): string[] {
|
||||
const next = new Set<string>()
|
||||
|
||||
categories.forEach((item) => {
|
||||
const normalized = normalizeText(item || '')
|
||||
if (normalized) {
|
||||
next.add(normalized)
|
||||
}
|
||||
})
|
||||
|
||||
items.forEach((item) => {
|
||||
const normalized = getKnowledgeCategoryLabel(item.category)
|
||||
if (normalized !== '未分类') {
|
||||
next.add(normalized)
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(next).sort((left, right) => left.localeCompare(right, 'zh-CN'))
|
||||
}
|
||||
|
||||
export function filterKnowledges(
|
||||
items: AdminKnowledgeListItem[],
|
||||
keyword: string,
|
||||
category: string,
|
||||
): AdminKnowledgeListItem[] {
|
||||
const normalizedKeyword = keyword.trim().toLowerCase()
|
||||
const normalizedCategory = normalizeText(category)
|
||||
|
||||
return items.filter((item) => {
|
||||
const hitKeyword = !normalizedKeyword || [
|
||||
item.id,
|
||||
item.title,
|
||||
getKnowledgeCategoryLabel(item.category),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(normalizedKeyword)
|
||||
|
||||
const hitCategory = !normalizedCategory || getKnowledgeCategoryLabel(item.category) === normalizedCategory
|
||||
return hitKeyword && hitCategory
|
||||
})
|
||||
}
|
||||
|
||||
export function countVisibleKnowledges(items: AdminKnowledgeListItem[]): number {
|
||||
return items.filter((item) => Boolean(item.show)).length
|
||||
}
|
||||
|
||||
export function moveKnowledgeOrder(
|
||||
items: AdminKnowledgeListItem[],
|
||||
fromIndex: number,
|
||||
direction: -1 | 1,
|
||||
): AdminKnowledgeListItem[] {
|
||||
const targetIndex = fromIndex + direction
|
||||
if (targetIndex < 0 || targetIndex >= items.length) {
|
||||
return items
|
||||
}
|
||||
|
||||
const next = [...items]
|
||||
const [current] = next.splice(fromIndex, 1)
|
||||
next.splice(targetIndex, 0, current)
|
||||
return next
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import type { AdminNoticeItem, AdminNoticeSavePayload } from '@/types/api'
|
||||
|
||||
export interface NoticeFormModel {
|
||||
id?: number
|
||||
title: string
|
||||
content: string
|
||||
imgUrl: string
|
||||
tags: string[]
|
||||
show: boolean
|
||||
popup: boolean
|
||||
}
|
||||
|
||||
const markdown = new MarkdownIt({
|
||||
html: true,
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
})
|
||||
|
||||
function normalizeText(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function normalizeTagList(tags: unknown): string[] {
|
||||
if (!Array.isArray(tags)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [...new Set(tags
|
||||
.map((tag) => normalizeNoticeTag(String(tag)))
|
||||
.filter(Boolean))]
|
||||
}
|
||||
|
||||
function stripMarkup(source: string): string {
|
||||
return source
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/[`*_>#-]/g, ' ')
|
||||
.replace(/\[(.*?)\]\((.*?)\)/g, '$1')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function createEmptyNoticeForm(): NoticeFormModel {
|
||||
return {
|
||||
title: '',
|
||||
content: '',
|
||||
imgUrl: '',
|
||||
tags: [],
|
||||
show: true,
|
||||
popup: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeNoticeTag(raw: string): string {
|
||||
return raw.trim().replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
export function normalizeNoticeItem(notice: AdminNoticeItem): AdminNoticeItem {
|
||||
return {
|
||||
...notice,
|
||||
title: normalizeText(notice.title),
|
||||
content: typeof notice.content === 'string' ? notice.content : '',
|
||||
img_url: normalizeText(notice.img_url),
|
||||
tags: normalizeTagList(notice.tags),
|
||||
show: Boolean(notice.show),
|
||||
popup: Boolean(notice.popup),
|
||||
}
|
||||
}
|
||||
|
||||
export function toNoticeFormModel(notice?: AdminNoticeItem | null): NoticeFormModel {
|
||||
const form = createEmptyNoticeForm()
|
||||
if (!notice) {
|
||||
return form
|
||||
}
|
||||
|
||||
const normalized = normalizeNoticeItem(notice)
|
||||
return {
|
||||
id: normalized.id,
|
||||
title: normalized.title,
|
||||
content: normalized.content,
|
||||
imgUrl: normalized.img_url || '',
|
||||
tags: [...(normalized.tags ?? [])],
|
||||
show: Boolean(normalized.show),
|
||||
popup: Boolean(normalized.popup),
|
||||
}
|
||||
}
|
||||
|
||||
export function toNoticeSavePayload(form: NoticeFormModel): AdminNoticeSavePayload {
|
||||
return {
|
||||
id: form.id,
|
||||
title: form.title.trim(),
|
||||
content: form.content.trim(),
|
||||
img_url: normalizeText(form.imgUrl) || null,
|
||||
tags: form.tags,
|
||||
show: Boolean(form.show),
|
||||
popup: Boolean(form.popup),
|
||||
}
|
||||
}
|
||||
|
||||
export function filterNotices(notices: AdminNoticeItem[], keyword: string): AdminNoticeItem[] {
|
||||
const normalized = keyword.trim().toLowerCase()
|
||||
if (!normalized) {
|
||||
return notices
|
||||
}
|
||||
|
||||
return notices.filter((notice) => [
|
||||
notice.id,
|
||||
notice.title,
|
||||
notice.content,
|
||||
notice.tags?.join(' '),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(normalized))
|
||||
}
|
||||
|
||||
export function countEnabledNotices(notices: AdminNoticeItem[], field: 'show' | 'popup'): number {
|
||||
return notices.filter((notice) => Boolean(notice[field])).length
|
||||
}
|
||||
|
||||
export function moveNoticeOrder(notices: AdminNoticeItem[], fromIndex: number, direction: -1 | 1): AdminNoticeItem[] {
|
||||
const targetIndex = fromIndex + direction
|
||||
if (targetIndex < 0 || targetIndex >= notices.length) {
|
||||
return notices
|
||||
}
|
||||
|
||||
const next = [...notices]
|
||||
const [current] = next.splice(fromIndex, 1)
|
||||
next.splice(targetIndex, 0, current)
|
||||
return next
|
||||
}
|
||||
|
||||
export function summarizeNoticeContent(source: string, maxLength = 78): string {
|
||||
const plainText = stripMarkup(source)
|
||||
if (!plainText) {
|
||||
return '暂无公告摘要'
|
||||
}
|
||||
|
||||
if (plainText.length <= maxLength) {
|
||||
return plainText
|
||||
}
|
||||
|
||||
return `${plainText.slice(0, maxLength).trimEnd()}…`
|
||||
}
|
||||
|
||||
export function renderNoticeContent(source: string): string {
|
||||
return markdown.render(source || '')
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import type {
|
||||
AdminOrderDetail,
|
||||
AdminOrderFilter,
|
||||
AdminOrderListItem,
|
||||
AdminPlanListItem,
|
||||
} from '@/types/api'
|
||||
import { formatPlanPrice } from './plans'
|
||||
|
||||
export type OrderFilterValue<T> = T | 'all'
|
||||
export type OrderPeriodKey =
|
||||
| 'monthly'
|
||||
| 'quarterly'
|
||||
| 'half_yearly'
|
||||
| 'yearly'
|
||||
| 'two_yearly'
|
||||
| 'three_yearly'
|
||||
| 'onetime'
|
||||
| 'reset_traffic'
|
||||
|
||||
export type OrderLegacyPeriodKey =
|
||||
| 'month_price'
|
||||
| 'quarter_price'
|
||||
| 'half_year_price'
|
||||
| 'year_price'
|
||||
| 'two_year_price'
|
||||
| 'three_year_price'
|
||||
| 'onetime_price'
|
||||
| 'reset_price'
|
||||
|
||||
export interface OrderFilterOption<T extends string | number> {
|
||||
label: string
|
||||
value: T
|
||||
}
|
||||
|
||||
export interface OrderStatusMeta {
|
||||
label: string
|
||||
tone: 'success' | 'warning' | 'danger' | 'info' | 'neutral'
|
||||
}
|
||||
|
||||
export interface AssignablePeriodOption {
|
||||
label: string
|
||||
value: OrderLegacyPeriodKey
|
||||
amount: number
|
||||
}
|
||||
|
||||
const CURRENCY_FORMATTER = new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency',
|
||||
currency: 'CNY',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
|
||||
const FULL_DATE_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
|
||||
const PERIOD_META: Array<{
|
||||
key: OrderPeriodKey
|
||||
legacy: OrderLegacyPeriodKey
|
||||
label: string
|
||||
}> = [
|
||||
{ key: 'monthly', legacy: 'month_price', label: '月付' },
|
||||
{ key: 'quarterly', legacy: 'quarter_price', label: '季付' },
|
||||
{ key: 'half_yearly', legacy: 'half_year_price', label: '半年付' },
|
||||
{ key: 'yearly', legacy: 'year_price', label: '年付' },
|
||||
{ key: 'two_yearly', legacy: 'two_year_price', label: '两年付' },
|
||||
{ key: 'three_yearly', legacy: 'three_year_price', label: '三年付' },
|
||||
{ key: 'onetime', legacy: 'onetime_price', label: '一次性' },
|
||||
{ key: 'reset_traffic', legacy: 'reset_price', label: '重置流量' },
|
||||
]
|
||||
|
||||
export const ORDER_TYPE_OPTIONS: Array<OrderFilterOption<number>> = [
|
||||
{ label: '新购', value: 1 },
|
||||
{ label: '续费', value: 2 },
|
||||
{ label: '升级', value: 3 },
|
||||
{ label: '流量重置', value: 4 },
|
||||
]
|
||||
|
||||
export const ORDER_STATUS_OPTIONS: Array<OrderFilterOption<number>> = [
|
||||
{ label: '待支付', value: 0 },
|
||||
{ label: '开通中', value: 1 },
|
||||
{ label: '已取消', value: 2 },
|
||||
{ label: '已完成', value: 3 },
|
||||
{ label: '已折抵', value: 4 },
|
||||
]
|
||||
|
||||
export const COMMISSION_STATUS_OPTIONS: Array<OrderFilterOption<number>> = [
|
||||
{ label: '待确认', value: 0 },
|
||||
{ label: '发放中', value: 1 },
|
||||
{ label: '已发放', value: 2 },
|
||||
{ label: '无效', value: 3 },
|
||||
]
|
||||
|
||||
export const COMMISSION_STATUS_UPDATE_OPTIONS: Array<OrderFilterOption<number>> = [
|
||||
{ label: '待确认', value: 0 },
|
||||
{ label: '发放中', value: 1 },
|
||||
{ label: '无效', value: 3 },
|
||||
]
|
||||
|
||||
export const ORDER_PERIOD_OPTIONS: Array<OrderFilterOption<OrderPeriodKey>> = PERIOD_META.map((item) => ({
|
||||
label: item.label,
|
||||
value: item.key,
|
||||
}))
|
||||
|
||||
function toAmount(value: unknown): number {
|
||||
const numeric = Number(value)
|
||||
return Number.isFinite(numeric) ? numeric : 0
|
||||
}
|
||||
|
||||
function toTimestampMilliseconds(value: number | string | null | undefined): number | null {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
const numeric = Number(value)
|
||||
if (Number.isFinite(numeric)) {
|
||||
return numeric > 1_000_000_000_000 ? numeric : numeric * 1000
|
||||
}
|
||||
|
||||
const parsed = Date.parse(String(value))
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
function findPeriodMeta(period: string | null | undefined) {
|
||||
if (!period) {
|
||||
return null
|
||||
}
|
||||
|
||||
return PERIOD_META.find((item) => item.key === period || item.legacy === period) ?? null
|
||||
}
|
||||
|
||||
function getOptionLabel<T extends string | number>(options: Array<OrderFilterOption<T>>, value: T | 'all'): string {
|
||||
if (value === 'all') {
|
||||
return '全部'
|
||||
}
|
||||
|
||||
return options.find((item) => item.value === value)?.label ?? '全部'
|
||||
}
|
||||
|
||||
export function formatOrderAmount(value: number | string | null | undefined): string {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
return CURRENCY_FORMATTER.format(numeric / 100)
|
||||
}
|
||||
|
||||
export function formatOrderDateTime(value: number | string | null | undefined): string {
|
||||
const timestamp = toTimestampMilliseconds(value)
|
||||
if (timestamp === null) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
return FULL_DATE_FORMATTER.format(new Date(timestamp))
|
||||
}
|
||||
|
||||
export function yuanToOrderAmount(value: number | null | undefined): number {
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric) || numeric < 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.round(numeric * 100)
|
||||
}
|
||||
|
||||
export function orderAmountToYuan(value: number | null | undefined): number {
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Number((numeric / 100).toFixed(2))
|
||||
}
|
||||
|
||||
export function getOrderTypeMeta(type: number | null | undefined): OrderStatusMeta {
|
||||
return {
|
||||
label: getOptionLabel(ORDER_TYPE_OPTIONS, (type ?? 'all') as number | 'all'),
|
||||
tone: type === 4 ? 'info' : type === 3 ? 'warning' : 'neutral',
|
||||
}
|
||||
}
|
||||
|
||||
export function getOrderStatusMeta(status: number | null | undefined): OrderStatusMeta {
|
||||
switch (status) {
|
||||
case 0:
|
||||
return { label: '待支付', tone: 'warning' }
|
||||
case 1:
|
||||
return { label: '开通中', tone: 'info' }
|
||||
case 2:
|
||||
return { label: '已取消', tone: 'danger' }
|
||||
case 3:
|
||||
return { label: '已完成', tone: 'success' }
|
||||
case 4:
|
||||
return { label: '已折抵', tone: 'neutral' }
|
||||
default:
|
||||
return { label: '未知状态', tone: 'neutral' }
|
||||
}
|
||||
}
|
||||
|
||||
export function getCommissionStatusMeta(status: number | null | undefined, amount?: number | null): OrderStatusMeta {
|
||||
if ((amount ?? 0) <= 0 && (status === null || status === undefined)) {
|
||||
return { label: '-', tone: 'neutral' }
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 0:
|
||||
return { label: '待确认', tone: 'warning' }
|
||||
case 1:
|
||||
return { label: '发放中', tone: 'info' }
|
||||
case 2:
|
||||
return { label: '已发放', tone: 'success' }
|
||||
case 3:
|
||||
return { label: '无效', tone: 'danger' }
|
||||
default:
|
||||
return { label: '未参与', tone: 'neutral' }
|
||||
}
|
||||
}
|
||||
|
||||
export function getOrderPeriodLabel(period: string | null | undefined): string {
|
||||
return findPeriodMeta(period)?.label ?? (period || '-')
|
||||
}
|
||||
|
||||
export function getOrderFilterLabel(type: OrderFilterValue<number>): string {
|
||||
return getOptionLabel(ORDER_TYPE_OPTIONS, type)
|
||||
}
|
||||
|
||||
export function getOrderStatusFilterLabel(status: OrderFilterValue<number>): string {
|
||||
return getOptionLabel(ORDER_STATUS_OPTIONS, status)
|
||||
}
|
||||
|
||||
export function getCommissionStatusFilterLabel(status: OrderFilterValue<number>): string {
|
||||
return getOptionLabel(COMMISSION_STATUS_OPTIONS, status)
|
||||
}
|
||||
|
||||
export function getOrderPeriodFilterLabel(period: OrderFilterValue<OrderPeriodKey>): string {
|
||||
return getOptionLabel(ORDER_PERIOD_OPTIONS, period)
|
||||
}
|
||||
|
||||
export function buildOrderFetchFilters(filters: {
|
||||
keyword: string
|
||||
type: OrderFilterValue<number>
|
||||
period: OrderFilterValue<OrderPeriodKey>
|
||||
status: OrderFilterValue<number>
|
||||
commissionStatus: OrderFilterValue<number>
|
||||
}): AdminOrderFilter[] {
|
||||
const result: AdminOrderFilter[] = []
|
||||
|
||||
if (filters.keyword.trim()) {
|
||||
result.push({
|
||||
id: 'trade_no',
|
||||
value: filters.keyword.trim(),
|
||||
})
|
||||
}
|
||||
|
||||
if (filters.type !== 'all') {
|
||||
result.push({
|
||||
id: 'type',
|
||||
value: [filters.type],
|
||||
})
|
||||
}
|
||||
|
||||
if (filters.period !== 'all') {
|
||||
result.push({
|
||||
id: 'period',
|
||||
value: [filters.period],
|
||||
})
|
||||
}
|
||||
|
||||
if (filters.status !== 'all') {
|
||||
result.push({
|
||||
id: 'status',
|
||||
value: [filters.status],
|
||||
})
|
||||
}
|
||||
|
||||
if (filters.commissionStatus !== 'all') {
|
||||
result.push({
|
||||
id: 'commission_status',
|
||||
value: [filters.commissionStatus],
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function getAssignablePeriods(plan?: Pick<AdminPlanListItem, 'prices'> | null): AssignablePeriodOption[] {
|
||||
if (!plan?.prices) {
|
||||
return []
|
||||
}
|
||||
|
||||
return PERIOD_META
|
||||
.filter((item) => toAmount(plan.prices?.[item.key]) > 0)
|
||||
.map((item) => ({
|
||||
label: `${item.label} · ${formatPlanPrice(plan.prices?.[item.key])}`,
|
||||
value: item.legacy,
|
||||
amount: Number(plan.prices?.[item.key] ?? 0),
|
||||
}))
|
||||
}
|
||||
|
||||
export function canMarkOrderPaid(order?: Pick<AdminOrderListItem, 'status'> | null): boolean {
|
||||
return order?.status === 0
|
||||
}
|
||||
|
||||
export function canCancelOrder(order?: Pick<AdminOrderListItem, 'status'> | null): boolean {
|
||||
return order?.status === 0
|
||||
}
|
||||
|
||||
export function canUpdateCommissionStatus(order?: Pick<AdminOrderDetail, 'commission_balance' | 'commission_status'> | null): boolean {
|
||||
if (!order) {
|
||||
return false
|
||||
}
|
||||
|
||||
if ((order.commission_balance ?? 0) <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return order.commission_status !== 2
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import type {
|
||||
AdminPaymentConfigField,
|
||||
AdminPaymentConfigFields,
|
||||
AdminPaymentListItem,
|
||||
AdminPaymentSavePayload,
|
||||
} from '@/types/api'
|
||||
|
||||
export interface PaymentFormModel {
|
||||
id?: number
|
||||
name: string
|
||||
icon: string
|
||||
payment: string
|
||||
notifyDomain: string
|
||||
handlingFeePercent: number | null
|
||||
handlingFeeFixed: number | null
|
||||
config: Record<string, string>
|
||||
}
|
||||
|
||||
function normalizeNullableNumber(value: number | string | null | undefined): number | null {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
const numeric = Number(value)
|
||||
return Number.isFinite(numeric) ? numeric : null
|
||||
}
|
||||
|
||||
function normalizeFieldValue(field: AdminPaymentConfigField | undefined): string {
|
||||
if (!field) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (field.value === null || field.value === undefined) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return String(field.value)
|
||||
}
|
||||
|
||||
export function createEmptyPaymentForm(): PaymentFormModel {
|
||||
return {
|
||||
name: '',
|
||||
icon: '',
|
||||
payment: '',
|
||||
notifyDomain: '',
|
||||
handlingFeePercent: null,
|
||||
handlingFeeFixed: null,
|
||||
config: {},
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePayment(payment: AdminPaymentListItem): AdminPaymentListItem {
|
||||
return {
|
||||
...payment,
|
||||
enable: Boolean(payment.enable),
|
||||
config: payment.config ?? {},
|
||||
notify_domain: payment.notify_domain ?? null,
|
||||
notify_url: payment.notify_url ?? '',
|
||||
handling_fee_fixed: normalizeNullableNumber(payment.handling_fee_fixed),
|
||||
handling_fee_percent: normalizeNullableNumber(payment.handling_fee_percent),
|
||||
sort: Number(payment.sort || 0),
|
||||
}
|
||||
}
|
||||
|
||||
export function sortPaymentsByOrder(payments: AdminPaymentListItem[]): AdminPaymentListItem[] {
|
||||
return [...payments].sort((left, right) => {
|
||||
const leftSort = Number(left.sort || 0)
|
||||
const rightSort = Number(right.sort || 0)
|
||||
if (leftSort !== rightSort) {
|
||||
return leftSort - rightSort
|
||||
}
|
||||
return left.id - right.id
|
||||
})
|
||||
}
|
||||
|
||||
export function filterPayments(payments: AdminPaymentListItem[], keyword: string): AdminPaymentListItem[] {
|
||||
const normalized = keyword.trim().toLowerCase()
|
||||
if (!normalized) {
|
||||
return payments
|
||||
}
|
||||
|
||||
return payments.filter((payment) => {
|
||||
const haystack = [
|
||||
payment.id,
|
||||
payment.name,
|
||||
payment.payment,
|
||||
payment.notify_url,
|
||||
payment.notify_domain,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return haystack.includes(normalized)
|
||||
})
|
||||
}
|
||||
|
||||
export function countEnabledPayments(payments: AdminPaymentListItem[]): number {
|
||||
return payments.filter((payment) => Boolean(payment.enable)).length
|
||||
}
|
||||
|
||||
export function countCustomNotifyDomains(payments: AdminPaymentListItem[]): number {
|
||||
return payments.filter((payment) => Boolean(String(payment.notify_domain || '').trim())).length
|
||||
}
|
||||
|
||||
export function movePaymentOrder<T>(list: T[], index: number, direction: -1 | 1): T[] {
|
||||
const targetIndex = index + direction
|
||||
if (targetIndex < 0 || targetIndex >= list.length) {
|
||||
return list
|
||||
}
|
||||
|
||||
const next = [...list]
|
||||
const [item] = next.splice(index, 1)
|
||||
next.splice(targetIndex, 0, item)
|
||||
return next
|
||||
}
|
||||
|
||||
export function formatPaymentFee(payment: Pick<AdminPaymentListItem, 'handling_fee_percent' | 'handling_fee_fixed'>): string {
|
||||
const segments: string[] = []
|
||||
const percent = normalizeNullableNumber(payment.handling_fee_percent)
|
||||
const fixed = normalizeNullableNumber(payment.handling_fee_fixed)
|
||||
|
||||
if (percent !== null && percent > 0) {
|
||||
segments.push(`${percent}% 手续费`)
|
||||
}
|
||||
|
||||
if (fixed !== null && fixed > 0) {
|
||||
segments.push(`固定 ¥${fixed}`)
|
||||
}
|
||||
|
||||
return segments.join(' + ') || '无额外手续费'
|
||||
}
|
||||
|
||||
export function normalizePaymentConfigFields(fields: AdminPaymentConfigFields | null | undefined): AdminPaymentConfigFields {
|
||||
if (!fields) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return Object.entries(fields).reduce<AdminPaymentConfigFields>((result, [key, field]) => {
|
||||
result[key] = {
|
||||
type: field.type || 'string',
|
||||
label: field.label || key,
|
||||
placeholder: field.placeholder || '',
|
||||
description: field.description || '',
|
||||
value: normalizeFieldValue(field),
|
||||
options: field.options || [],
|
||||
}
|
||||
return result
|
||||
}, {})
|
||||
}
|
||||
|
||||
export function extractPaymentConfigValues(fields: AdminPaymentConfigFields): Record<string, string> {
|
||||
return Object.entries(fields).reduce<Record<string, string>>((result, [key, field]) => {
|
||||
result[key] = normalizeFieldValue(field)
|
||||
return result
|
||||
}, {})
|
||||
}
|
||||
|
||||
export function toPaymentFormModel(payment?: AdminPaymentListItem | null): PaymentFormModel {
|
||||
const base = createEmptyPaymentForm()
|
||||
if (!payment) {
|
||||
return base
|
||||
}
|
||||
|
||||
return {
|
||||
id: payment.id,
|
||||
name: payment.name || '',
|
||||
icon: payment.icon || '',
|
||||
payment: payment.payment || '',
|
||||
notifyDomain: payment.notify_domain || '',
|
||||
handlingFeePercent: normalizeNullableNumber(payment.handling_fee_percent),
|
||||
handlingFeeFixed: normalizeNullableNumber(payment.handling_fee_fixed),
|
||||
config: Object.entries(payment.config || {}).reduce<Record<string, string>>((result, [key, value]) => {
|
||||
result[key] = value === null || value === undefined ? '' : String(value)
|
||||
return result
|
||||
}, {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function toPaymentSavePayload(
|
||||
form: PaymentFormModel,
|
||||
fields: AdminPaymentConfigFields,
|
||||
): AdminPaymentSavePayload {
|
||||
const config = Object.entries(fields).reduce<Record<string, string>>((result, [key, field]) => {
|
||||
const currentValue = form.config[key] ?? ''
|
||||
result[key] = field.type === 'text'
|
||||
? currentValue
|
||||
: currentValue.trim()
|
||||
return result
|
||||
}, {})
|
||||
|
||||
return {
|
||||
id: form.id,
|
||||
name: form.name.trim(),
|
||||
icon: form.icon.trim() || null,
|
||||
payment: form.payment,
|
||||
config,
|
||||
notify_domain: form.notifyDomain.trim() || null,
|
||||
handling_fee_fixed: form.handlingFeeFixed,
|
||||
handling_fee_percent: form.handlingFeePercent,
|
||||
}
|
||||
}
|
||||
@@ -229,6 +229,15 @@ export function countEnabledPlans(plans: AdminPlanListItem[], field: 'show' | 's
|
||||
return plans.filter((plan) => Boolean(plan[field])).length
|
||||
}
|
||||
|
||||
export function normalizePlanToggleFields(plan: AdminPlanListItem): AdminPlanListItem {
|
||||
return {
|
||||
...plan,
|
||||
show: Boolean(plan.show),
|
||||
sell: Boolean(plan.sell),
|
||||
renew: Boolean(plan.renew),
|
||||
}
|
||||
}
|
||||
|
||||
export function movePlanOrder(plans: AdminPlanListItem[], fromIndex: number, direction: -1 | 1): AdminPlanListItem[] {
|
||||
const targetIndex = fromIndex + direction
|
||||
if (targetIndex < 0 || targetIndex >= plans.length) {
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import type {
|
||||
AdminPluginConfigField,
|
||||
AdminPluginConfigOption,
|
||||
AdminPluginItem,
|
||||
AdminPluginTypeItem,
|
||||
AdminPluginTypeValue,
|
||||
} from '@/types/api'
|
||||
|
||||
export type PluginTabValue = AdminPluginTypeValue | 'all'
|
||||
export type PluginStatusFilter = 'all' | 'enabled' | 'installed_disabled' | 'uninstalled' | 'upgrade'
|
||||
export type NormalizedPluginFieldType = 'boolean' | 'text' | 'json' | 'number' | 'select' | 'string'
|
||||
export type PluginConfigDraft = Record<string, string | number | boolean>
|
||||
|
||||
export interface PluginStatusMeta {
|
||||
label: string
|
||||
tone: '' | 'success' | 'warning' | 'info' | 'danger'
|
||||
helper: string
|
||||
}
|
||||
|
||||
export interface NormalizedPluginConfigField extends Omit<AdminPluginConfigField, 'options'> {
|
||||
key: string
|
||||
type: NormalizedPluginFieldType
|
||||
options: AdminPluginConfigOption[]
|
||||
}
|
||||
|
||||
const markdown = new MarkdownIt({
|
||||
html: true,
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
})
|
||||
|
||||
export const PLUGIN_STATUS_FILTER_OPTIONS: Array<{ label: string; value: PluginStatusFilter }> = [
|
||||
{ label: '全部状态', value: 'all' },
|
||||
{ label: '已启用', value: 'enabled' },
|
||||
{ label: '已安装未启用', value: 'installed_disabled' },
|
||||
{ label: '未安装', value: 'uninstalled' },
|
||||
{ label: '可升级', value: 'upgrade' },
|
||||
]
|
||||
|
||||
export function buildPluginTabs(types: AdminPluginTypeItem[]): Array<{ label: string; value: PluginTabValue }> {
|
||||
return [
|
||||
...types.map((item) => ({
|
||||
label: item.label,
|
||||
value: item.value,
|
||||
})),
|
||||
{ label: '所有插件', value: 'all' as const },
|
||||
]
|
||||
}
|
||||
|
||||
export function getPluginTypeLabel(type: string, types: Array<{ value: string; label: string }>): string {
|
||||
return types.find((item) => item.value === type)?.label || type || '未知类型'
|
||||
}
|
||||
|
||||
export function getPluginStatusMeta(plugin: AdminPluginItem): PluginStatusMeta {
|
||||
if (plugin.need_upgrade) {
|
||||
return {
|
||||
label: plugin.is_enabled ? '待升级(运行中)' : '待升级',
|
||||
tone: 'warning',
|
||||
helper: '检测到本地插件版本高于当前已安装版本',
|
||||
}
|
||||
}
|
||||
|
||||
if (!plugin.is_installed) {
|
||||
return {
|
||||
label: '未安装',
|
||||
tone: 'info',
|
||||
helper: '插件目录已存在,可直接安装',
|
||||
}
|
||||
}
|
||||
|
||||
if (plugin.is_enabled) {
|
||||
return {
|
||||
label: '已启用',
|
||||
tone: 'success',
|
||||
helper: '插件已加载到当前系统',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
label: '已安装未启用',
|
||||
tone: '',
|
||||
helper: '插件已安装,但当前未启用',
|
||||
}
|
||||
}
|
||||
|
||||
export function matchPluginStatus(plugin: AdminPluginItem, filter: PluginStatusFilter): boolean {
|
||||
switch (filter) {
|
||||
case 'enabled':
|
||||
return plugin.is_installed && plugin.is_enabled
|
||||
case 'installed_disabled':
|
||||
return plugin.is_installed && !plugin.is_enabled
|
||||
case 'uninstalled':
|
||||
return !plugin.is_installed
|
||||
case 'upgrade':
|
||||
return plugin.need_upgrade
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function filterPlugins(plugins: AdminPluginItem[], keyword: string, status: PluginStatusFilter): AdminPluginItem[] {
|
||||
const normalizedKeyword = keyword.trim().toLowerCase()
|
||||
|
||||
return plugins.filter((plugin) => {
|
||||
if (!matchPluginStatus(plugin, status)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!normalizedKeyword) {
|
||||
return true
|
||||
}
|
||||
|
||||
const haystack = [
|
||||
plugin.name,
|
||||
plugin.code,
|
||||
plugin.description,
|
||||
plugin.author,
|
||||
plugin.version,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return haystack.includes(normalizedKeyword)
|
||||
})
|
||||
}
|
||||
|
||||
export function countEnabledPlugins(plugins: AdminPluginItem[]): number {
|
||||
return plugins.filter((plugin) => plugin.is_installed && plugin.is_enabled).length
|
||||
}
|
||||
|
||||
export function countUpgradeablePlugins(plugins: AdminPluginItem[]): number {
|
||||
return plugins.filter((plugin) => plugin.need_upgrade).length
|
||||
}
|
||||
|
||||
export function countUserPlugins(plugins: AdminPluginItem[]): number {
|
||||
return plugins.filter((plugin) => plugin.can_be_deleted).length
|
||||
}
|
||||
|
||||
export function hasPluginConfig(plugin: AdminPluginItem | null | undefined): boolean {
|
||||
return Boolean(plugin && Object.keys(plugin.config || {}).length)
|
||||
}
|
||||
|
||||
export function hasPluginReadme(plugin: AdminPluginItem | null | undefined): boolean {
|
||||
return Boolean(plugin?.readme?.trim())
|
||||
}
|
||||
|
||||
export function renderPluginReadme(source: string): string {
|
||||
return markdown.render(source || '')
|
||||
}
|
||||
|
||||
function normalizeFieldType(field: AdminPluginConfigField): NormalizedPluginFieldType {
|
||||
const rawType = String(field.type || 'string').trim().toLowerCase()
|
||||
|
||||
if (rawType === 'boolean' || rawType === 'bool' || rawType === 'switch') return 'boolean'
|
||||
if (rawType === 'text' || rawType === 'textarea') return 'text'
|
||||
if (rawType === 'json') return 'json'
|
||||
if (rawType === 'number' || rawType === 'int' || rawType === 'float') return 'number'
|
||||
if (rawType === 'select' || rawType === 'enum') return 'select'
|
||||
return 'string'
|
||||
}
|
||||
|
||||
function normalizeFieldOptions(
|
||||
options: AdminPluginConfigField['options'],
|
||||
): AdminPluginConfigOption[] {
|
||||
if (Array.isArray(options)) {
|
||||
return options.map((item) => (
|
||||
typeof item === 'object' && item !== null && 'label' in item && 'value' in item
|
||||
? item as AdminPluginConfigOption
|
||||
: {
|
||||
label: String(item),
|
||||
value: item as string | number | boolean,
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
if (options && typeof options === 'object') {
|
||||
return Object.entries(options).map(([value, label]) => ({
|
||||
label: String(label),
|
||||
value,
|
||||
}))
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function normalizeDraftValue(field: NormalizedPluginConfigField): string | number | boolean {
|
||||
if (field.type === 'boolean') {
|
||||
return Boolean(field.value)
|
||||
}
|
||||
|
||||
if (field.type === 'number') {
|
||||
const numeric = Number(field.value)
|
||||
return Number.isFinite(numeric) ? numeric : 0
|
||||
}
|
||||
|
||||
if (field.type === 'json') {
|
||||
if (field.value === null || field.value === undefined || field.value === '') {
|
||||
return ''
|
||||
}
|
||||
return typeof field.value === 'string'
|
||||
? field.value
|
||||
: JSON.stringify(field.value, null, 2)
|
||||
}
|
||||
|
||||
if (field.value === null || field.value === undefined) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return String(field.value)
|
||||
}
|
||||
|
||||
export function getPluginConfigFields(plugin: AdminPluginItem | null | undefined): NormalizedPluginConfigField[] {
|
||||
if (!plugin) return []
|
||||
|
||||
return Object.entries(plugin.config || {}).map(([key, field]) => {
|
||||
const normalizedField: NormalizedPluginConfigField = {
|
||||
...field,
|
||||
key,
|
||||
type: normalizeFieldType(field),
|
||||
options: normalizeFieldOptions(field.options),
|
||||
}
|
||||
return normalizedField
|
||||
})
|
||||
}
|
||||
|
||||
export function createPluginConfigDraft(plugin: AdminPluginItem | null | undefined): PluginConfigDraft {
|
||||
return getPluginConfigFields(plugin).reduce((acc, field) => {
|
||||
acc[field.key] = normalizeDraftValue(field)
|
||||
return acc
|
||||
}, {} as PluginConfigDraft)
|
||||
}
|
||||
|
||||
export function serializePluginConfigDraft(
|
||||
plugin: AdminPluginItem,
|
||||
draft: PluginConfigDraft,
|
||||
): Record<string, unknown> {
|
||||
return getPluginConfigFields(plugin).reduce((acc, field) => {
|
||||
const value = draft[field.key]
|
||||
|
||||
if (field.type === 'boolean') {
|
||||
acc[field.key] = Boolean(value)
|
||||
return acc
|
||||
}
|
||||
|
||||
if (field.type === 'number') {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
acc[field.key] = null
|
||||
return acc
|
||||
}
|
||||
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) {
|
||||
throw new Error(`配置项「${field.label || field.key}」必须是有效数字`)
|
||||
}
|
||||
acc[field.key] = numeric
|
||||
return acc
|
||||
}
|
||||
|
||||
if (field.type === 'json') {
|
||||
const raw = String(value ?? '').trim()
|
||||
if (!raw) {
|
||||
acc[field.key] = null
|
||||
return acc
|
||||
}
|
||||
|
||||
try {
|
||||
acc[field.key] = JSON.parse(raw)
|
||||
} catch {
|
||||
throw new Error(`配置项「${field.label || field.key}」不是有效 JSON`)
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
acc[field.key] = String(value ?? '')
|
||||
return acc
|
||||
}, {} as Record<string, unknown>)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type {
|
||||
AdminThemeConfigField,
|
||||
AdminThemeConfigRecord,
|
||||
AdminThemeListResult,
|
||||
AdminThemeSummary,
|
||||
} from '@/types/api'
|
||||
|
||||
export type ThemeConfigFormState = Record<string, string>
|
||||
|
||||
export interface ResolvedThemeSummary extends AdminThemeSummary {
|
||||
key: string
|
||||
}
|
||||
|
||||
function normalizeThemeValue(
|
||||
value: string | number | boolean | null | undefined,
|
||||
field: AdminThemeConfigField,
|
||||
): string {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
if (field.default_value === null || field.default_value === undefined) {
|
||||
return ''
|
||||
}
|
||||
return String(field.default_value)
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export function resolveThemes(result?: AdminThemeListResult | null): ResolvedThemeSummary[] {
|
||||
const activeTheme = result?.active ?? ''
|
||||
return Object.entries(result?.themes ?? {})
|
||||
.map(([key, theme]) => ({ ...theme, key }))
|
||||
.sort((left, right) => {
|
||||
if (left.name === activeTheme) return -1
|
||||
if (right.name === activeTheme) return 1
|
||||
if (Boolean(left.is_system) !== Boolean(right.is_system)) {
|
||||
return left.is_system ? -1 : 1
|
||||
}
|
||||
return left.name.localeCompare(right.name, 'zh-CN')
|
||||
})
|
||||
}
|
||||
|
||||
export function createThemeConfigFormState(
|
||||
fields: AdminThemeConfigField[],
|
||||
config: AdminThemeConfigRecord | null | undefined,
|
||||
): ThemeConfigFormState {
|
||||
return fields.reduce<ThemeConfigFormState>((state, field) => {
|
||||
state[field.field_name] = normalizeThemeValue(config?.[field.field_name], field)
|
||||
return state
|
||||
}, {})
|
||||
}
|
||||
|
||||
export function serializeThemeConfigForm(
|
||||
form: ThemeConfigFormState,
|
||||
fields: AdminThemeConfigField[],
|
||||
): AdminThemeConfigRecord {
|
||||
return fields.reduce<AdminThemeConfigRecord>((state, field) => {
|
||||
state[field.field_name] = form[field.field_name] ?? ''
|
||||
return state
|
||||
}, {})
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
.dialog-shell,
|
||||
.dialog-form {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.dialog-copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dialog-copy p {
|
||||
font-size: 12px;
|
||||
color: var(--xboard-text-muted);
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dialog-copy h2 {
|
||||
font-size: 30px;
|
||||
line-height: 1.08;
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.dialog-copy span,
|
||||
.field-helper {
|
||||
color: var(--xboard-text-secondary);
|
||||
line-height: 1.47;
|
||||
}
|
||||
|
||||
.field-helper {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dialog-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px 16px;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.full-span {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.value-row {
|
||||
display: grid;
|
||||
grid-template-columns: 180px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.value-type,
|
||||
.value-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.dialog-grid,
|
||||
.value-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { saveCoupon } from '@/api/admin'
|
||||
import type { AdminCouponListItem, AdminPlanOption } from '@/types/api'
|
||||
import {
|
||||
COUPON_PERIOD_OPTIONS,
|
||||
COUPON_TYPE_OPTIONS,
|
||||
createEmptyCouponForm,
|
||||
getCouponTypeLabel,
|
||||
toCouponFormModel,
|
||||
toCouponSavePayload,
|
||||
type CouponFormModel,
|
||||
} from '@/utils/coupons'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
mode: 'create' | 'edit'
|
||||
coupon?: AdminCouponListItem | null
|
||||
plans: AdminPlanOption[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
success: [message: string]
|
||||
}>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const form = reactive<CouponFormModel>(createEmptyCouponForm())
|
||||
|
||||
const dialogTitle = computed(() => props.mode === 'create' ? '添加优惠券' : '编辑优惠券')
|
||||
const valueHelper = computed(() => form.type === 1 ? '按金额优惠时请输入元,例如 50 表示减免 50 元。' : '按比例优惠时请输入百分比,例如 85 表示 85 折。')
|
||||
|
||||
const rules = computed<FormRules<CouponFormModel>>(() => ({
|
||||
name: [{ required: true, message: '请输入优惠券名称', trigger: 'blur' }],
|
||||
value: [
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (!Number.isFinite(Number(value)) || Number(value) <= 0) {
|
||||
callback(new Error(`请输入有效的${getCouponTypeLabel(form.type)}值`))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
dateRange: [
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (!Array.isArray(value) || value.length !== 2 || !value[0] || !value[1]) {
|
||||
callback(new Error('请选择优惠券有效期'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: 'change',
|
||||
},
|
||||
],
|
||||
code: [
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (form.generateCount && form.generateCount > 1 && String(value || '').trim()) {
|
||||
callback(new Error('批量生成时请留空自定义优惠码'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
function closeDialog() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
function syncForm() {
|
||||
Object.assign(form, toCouponFormModel(props.coupon))
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const instance = formRef.value
|
||||
if (!instance) {
|
||||
return
|
||||
}
|
||||
|
||||
const valid = await instance.validate().catch(() => false)
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await saveCoupon(toCouponSavePayload(form))
|
||||
const message = props.mode === 'create' ? '优惠券已创建' : '优惠券已更新'
|
||||
ElMessage.success(message)
|
||||
emit('success', message)
|
||||
closeDialog()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '优惠券保存失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.visible, props.coupon, props.mode],
|
||||
([visible]) => {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
|
||||
syncForm()
|
||||
nextTick(() => {
|
||||
formRef.value?.clearValidate()
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDialog
|
||||
:model-value="props.visible"
|
||||
:title="dialogTitle"
|
||||
width="min(860px, calc(100vw - 32px))"
|
||||
top="5vh"
|
||||
destroy-on-close
|
||||
class="coupon-dialog"
|
||||
@close="closeDialog"
|
||||
@update:model-value="emit('update:visible', $event)"
|
||||
>
|
||||
<div class="dialog-shell">
|
||||
<div class="dialog-copy">
|
||||
<p>订阅管理</p>
|
||||
<h2>{{ dialogTitle }}</h2>
|
||||
<span>创建或调整优惠券策略,支持金额、折扣、批量生成与订阅限制。</span>
|
||||
</div>
|
||||
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
class="dialog-form"
|
||||
>
|
||||
<div class="dialog-grid">
|
||||
<ElFormItem label="优惠券名称" prop="name">
|
||||
<ElInput v-model="form.name" placeholder="请输入优惠券名称" />
|
||||
<p class="field-helper">用于后台识别优惠活动,建议使用可读性更强的运营命名。</p>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="批量生成数量">
|
||||
<ElInputNumber
|
||||
v-model="form.generateCount"
|
||||
:min="2"
|
||||
:max="500"
|
||||
:controls="false"
|
||||
class="full-width"
|
||||
placeholder="留空则仅生成单个"
|
||||
/>
|
||||
<p class="field-helper">批量生成时会自动生成随机券码,最多支持 500 个。</p>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="自定义优惠码" prop="code">
|
||||
<ElInput v-model="form.code" placeholder="留空则自动生成" />
|
||||
<p class="field-helper">单张优惠券可指定券码;批量生成时请保持为空。</p>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="优惠券类型和值" prop="value">
|
||||
<div class="value-row">
|
||||
<ElSelect v-model="form.type" class="value-type">
|
||||
<ElOption
|
||||
v-for="option in COUPON_TYPE_OPTIONS"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
|
||||
<ElInputNumber
|
||||
v-model="form.value"
|
||||
:min="0"
|
||||
:precision="form.type === 1 ? 2 : 0"
|
||||
:controls="false"
|
||||
class="value-input"
|
||||
/>
|
||||
</div>
|
||||
<p class="field-helper">{{ valueHelper }}</p>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="优惠券有效期" prop="dateRange" class="full-span">
|
||||
<ElDatePicker
|
||||
v-model="form.dateRange"
|
||||
type="datetimerange"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
value-format="x"
|
||||
class="full-width"
|
||||
/>
|
||||
<p class="field-helper">列表中的有效期和过期提示将直接依据这里的时间范围计算。</p>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="最大使用次数">
|
||||
<ElInputNumber
|
||||
v-model="form.limitUse"
|
||||
:min="0"
|
||||
:controls="false"
|
||||
class="full-width"
|
||||
placeholder="留空则不限"
|
||||
/>
|
||||
<p class="field-helper">设置优惠券总共可被使用的次数;留空表示不限次数。</p>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="每个用户可使用次数">
|
||||
<ElInputNumber
|
||||
v-model="form.limitUseWithUser"
|
||||
:min="0"
|
||||
:controls="false"
|
||||
class="full-width"
|
||||
placeholder="留空则不限"
|
||||
/>
|
||||
<p class="field-helper">用于限制单个用户重复使用同一优惠券的次数。</p>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="指定周期">
|
||||
<ElSelect
|
||||
v-model="form.limitPeriod"
|
||||
multiple
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
clearable
|
||||
placeholder="留空则不限周期"
|
||||
>
|
||||
<ElOption
|
||||
v-for="option in COUPON_PERIOD_OPTIONS"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
<p class="field-helper">仅允许在所选订阅周期中使用,留空表示所有周期均可使用。</p>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="指定订阅">
|
||||
<ElSelect
|
||||
v-model="form.limitPlanIds"
|
||||
multiple
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
clearable
|
||||
placeholder="留空则不限订阅"
|
||||
>
|
||||
<ElOption
|
||||
v-for="plan in props.plans"
|
||||
:key="plan.id"
|
||||
:label="plan.name"
|
||||
:value="plan.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
<p class="field-helper">只在指定套餐下生效,适合为活动套餐或定向促销设置专属优惠。</p>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
</ElForm>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<ElButton @click="closeDialog">取消</ElButton>
|
||||
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
|
||||
{{ props.mode === 'create' ? '确认' : '保存修改' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss" src="./CouponEditorDialog.scss"></style>
|
||||
@@ -0,0 +1,179 @@
|
||||
.coupons-page {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.coupons-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 30px 32px;
|
||||
border-radius: 28px;
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.coupons-copy {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-width: 620px;
|
||||
}
|
||||
|
||||
.coupons-kicker {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
}
|
||||
|
||||
.coupons-copy h1 {
|
||||
font-size: clamp(34px, 5vw, 52px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.28px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.coupons-copy span,
|
||||
.hero-stats span,
|
||||
.table-footer span,
|
||||
.name-cell span,
|
||||
.validity-cell span:last-child {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
line-height: 1.47;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
min-width: 360px;
|
||||
}
|
||||
|
||||
.hero-stats article {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.hero-stats strong {
|
||||
color: #ffffff;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.table-shell {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 24px;
|
||||
border-radius: 26px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--xboard-shadow);
|
||||
}
|
||||
|
||||
.table-toolbar,
|
||||
.toolbar-left,
|
||||
.table-footer,
|
||||
.action-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.table-toolbar,
|
||||
.table-footer {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-search {
|
||||
width: min(320px, 100%);
|
||||
}
|
||||
|
||||
.toolbar-filter {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.coupons-table :deep(th.el-table__cell) {
|
||||
color: var(--xboard-text-secondary);
|
||||
background: #fbfbfd;
|
||||
}
|
||||
|
||||
.coupons-table :deep(.el-table__row td.el-table__cell) {
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.name-cell,
|
||||
.validity-cell {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.name-cell strong {
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.coupon-code {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.validity-cell span:last-child,
|
||||
.table-footer span,
|
||||
.name-cell span {
|
||||
color: var(--xboard-text-muted);
|
||||
}
|
||||
|
||||
.expiry-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.expiry-pill--danger {
|
||||
background: rgba(201, 52, 40, 0.08);
|
||||
color: var(--xboard-danger);
|
||||
}
|
||||
|
||||
.expiry-pill--success {
|
||||
background: rgba(35, 134, 63, 0.08);
|
||||
color: var(--xboard-success);
|
||||
}
|
||||
|
||||
.expiry-pill--info {
|
||||
background: rgba(0, 113, 227, 0.08);
|
||||
color: var(--xboard-primary);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.danger-btn {
|
||||
color: var(--xboard-danger);
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.coupons-hero,
|
||||
.table-toolbar,
|
||||
.table-footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
min-width: 0;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { TableColumnCtx } from 'element-plus'
|
||||
import {
|
||||
Delete,
|
||||
EditPen,
|
||||
Plus,
|
||||
Search,
|
||||
} from '@element-plus/icons-vue'
|
||||
import {
|
||||
deleteCoupon,
|
||||
fetchCoupons,
|
||||
getPlans,
|
||||
updateCoupon,
|
||||
} from '@/api/admin'
|
||||
import type {
|
||||
AdminCouponListItem,
|
||||
AdminCouponType,
|
||||
AdminPlanOption,
|
||||
} from '@/types/api'
|
||||
import CouponEditorDialog from './CouponEditorDialog.vue'
|
||||
import {
|
||||
COUPON_TYPE_OPTIONS,
|
||||
countEnabledCoupons,
|
||||
countExpiredCoupons,
|
||||
filterCoupons,
|
||||
formatCouponDateRange,
|
||||
formatCouponLimit,
|
||||
formatCouponValue,
|
||||
getCouponExpiryMeta,
|
||||
getCouponTypeShortLabel,
|
||||
normalizeCoupon,
|
||||
sortCoupons,
|
||||
type CouponSortKey,
|
||||
type CouponSortOrder,
|
||||
type CouponTypeFilter,
|
||||
} from '@/utils/coupons'
|
||||
|
||||
type DialogMode = 'create' | 'edit'
|
||||
|
||||
const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<DialogMode>('create')
|
||||
const activeCoupon = ref<AdminCouponListItem | null>(null)
|
||||
const keyword = ref('')
|
||||
const typeFilter = ref<CouponTypeFilter>('all')
|
||||
const current = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const sortKey = ref<CouponSortKey>('id')
|
||||
const sortOrder = ref<CouponSortOrder>('descending')
|
||||
|
||||
const coupons = ref<AdminCouponListItem[]>([])
|
||||
const plans = ref<AdminPlanOption[]>([])
|
||||
const toggleLoadingMap = ref<Record<number, boolean>>({})
|
||||
|
||||
const filteredCoupons = computed(() => filterCoupons(coupons.value, keyword.value, typeFilter.value))
|
||||
const sortedCoupons = computed(() => sortCoupons(filteredCoupons.value, sortKey.value, sortOrder.value))
|
||||
const visibleCoupons = computed(() => {
|
||||
const start = (current.value - 1) * pageSize.value
|
||||
return sortedCoupons.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
|
||||
const heroStats = computed(() => [
|
||||
{ label: '优惠券总数', value: String(coupons.value.length) },
|
||||
{ label: '已启用', value: String(countEnabledCoupons(coupons.value)) },
|
||||
{ label: '已过期', value: String(countExpiredCoupons(coupons.value)) },
|
||||
])
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [couponResult, planResult] = await Promise.all([
|
||||
fetchCoupons({ current: 1, pageSize: 500 }),
|
||||
getPlans(),
|
||||
])
|
||||
coupons.value = (couponResult.data ?? []).map((item) => normalizeCoupon(item))
|
||||
plans.value = planResult.data ?? []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
dialogMode.value = 'create'
|
||||
activeCoupon.value = null
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(coupon: AdminCouponListItem) {
|
||||
dialogMode.value = 'edit'
|
||||
activeCoupon.value = coupon
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function isToggleLoading(id: number): boolean {
|
||||
return Boolean(toggleLoadingMap.value[id])
|
||||
}
|
||||
|
||||
async function handleToggle(coupon: AdminCouponListItem, nextValue: string | number | boolean) {
|
||||
const normalizedNextValue = Boolean(nextValue)
|
||||
if (coupon.show === normalizedNextValue) {
|
||||
return
|
||||
}
|
||||
|
||||
toggleLoadingMap.value[coupon.id] = true
|
||||
try {
|
||||
await updateCoupon(coupon.id, { show: normalizedNextValue })
|
||||
coupon.show = normalizedNextValue
|
||||
ElMessage.success('优惠券状态已更新')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '优惠券状态更新失败')
|
||||
} finally {
|
||||
toggleLoadingMap.value[coupon.id] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(coupon: AdminCouponListItem) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`删除优惠券「${coupon.name}」后无法恢复,确认继续吗?`, '删除优惠券', {
|
||||
type: 'warning',
|
||||
})
|
||||
await deleteCoupon(coupon.id)
|
||||
ElMessage.success('优惠券已删除')
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') {
|
||||
return
|
||||
}
|
||||
ElMessage.error(error instanceof Error ? error.message : '优惠券删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function handleSortChange(params: {
|
||||
column: TableColumnCtx<AdminCouponListItem>
|
||||
prop: string
|
||||
order: CouponSortOrder
|
||||
}) {
|
||||
sortKey.value = (params.prop || 'id') as CouponSortKey
|
||||
sortOrder.value = params.order || 'descending'
|
||||
}
|
||||
|
||||
function getExpiryClass(endedAt: number): string {
|
||||
return `expiry-pill--${getCouponExpiryMeta(endedAt).kind}`
|
||||
}
|
||||
|
||||
watch([keyword, typeFilter, pageSize], () => {
|
||||
current.value = 1
|
||||
})
|
||||
|
||||
watch(sortedCoupons, (list) => {
|
||||
const maxPage = Math.max(1, Math.ceil(list.length / pageSize.value))
|
||||
if (current.value > maxPage) {
|
||||
current.value = maxPage
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void loadData().catch((error) => {
|
||||
ElMessage.error(error instanceof Error ? error.message : '优惠券管理页面初始化失败')
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="coupons-page">
|
||||
<section class="coupons-hero">
|
||||
<div class="coupons-copy">
|
||||
<p class="coupons-kicker">Promotions</p>
|
||||
<h1>优惠券管理</h1>
|
||||
<span>在这里可以查看和维护优惠券,包括启用、筛选、批量生成、编辑与删除等操作。</span>
|
||||
</div>
|
||||
|
||||
<div class="hero-stats">
|
||||
<article v-for="item in heroStats" :key="item.label">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="table-shell">
|
||||
<header class="table-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<ElButton type="primary" @click="openCreateDialog">
|
||||
<ElIcon><Plus /></ElIcon>
|
||||
添加优惠券
|
||||
</ElButton>
|
||||
|
||||
<ElInput
|
||||
v-model="keyword"
|
||||
clearable
|
||||
placeholder="搜索优惠券..."
|
||||
class="toolbar-search"
|
||||
>
|
||||
<template #prefix>
|
||||
<ElIcon><Search /></ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
|
||||
<ElSelect v-model="typeFilter" class="toolbar-filter">
|
||||
<ElOption label="全部类型" value="all" />
|
||||
<ElOption
|
||||
v-for="option in COUPON_TYPE_OPTIONS"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="String(option.value)"
|
||||
/>
|
||||
</ElSelect>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ElTable
|
||||
:data="visibleCoupons"
|
||||
v-loading="loading"
|
||||
class="coupons-table"
|
||||
row-key="id"
|
||||
empty-text="当前筛选条件下暂无优惠券"
|
||||
@sort-change="handleSortChange"
|
||||
>
|
||||
<ElTableColumn prop="id" label="ID" width="88" sortable="custom" />
|
||||
<ElTableColumn label="启用" width="92">
|
||||
<template #default="{ row }">
|
||||
<ElSwitch
|
||||
:model-value="row.show"
|
||||
:loading="isToggleLoading(row.id)"
|
||||
@change="handleToggle(row, $event)"
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="券名称" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="name-cell">
|
||||
<strong>{{ row.name }}</strong>
|
||||
<span>{{ formatCouponValue(row) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="type" label="类型" width="126" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
<ElTag effect="plain" round>
|
||||
{{ getCouponTypeShortLabel(row.type as AdminCouponType) }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="券码" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span class="coupon-code mono">{{ row.code }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="limit_use" label="剩余次数" width="120" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
<ElTag effect="plain" round>
|
||||
{{ formatCouponLimit(row.limit_use) }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="limit_use_with_user" label="可用次数/用户" width="150" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
<ElTag effect="plain" round>
|
||||
{{ formatCouponLimit(row.limit_use_with_user, '无限制') }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="ended_at" label="有效期" min-width="260" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
<div class="validity-cell">
|
||||
<span class="expiry-pill" :class="getExpiryClass(row.ended_at)">
|
||||
{{ getCouponExpiryMeta(row.ended_at).text }}
|
||||
</span>
|
||||
<span>{{ formatCouponDateRange(row) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="110" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="action-group">
|
||||
<ElButton text class="action-btn" @click="openEditDialog(row)">
|
||||
<ElIcon><EditPen /></ElIcon>
|
||||
</ElButton>
|
||||
<ElButton text class="action-btn danger-btn" @click="handleDelete(row)">
|
||||
<ElIcon><Delete /></ElIcon>
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<footer class="table-footer">
|
||||
<span>已选择 0 项,共 {{ sortedCoupons.length }} 项</span>
|
||||
<ElPagination
|
||||
v-model:current-page="current"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="sizes, prev, pager, next"
|
||||
:total="sortedCoupons.length"
|
||||
background
|
||||
/>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<CouponEditorDialog
|
||||
v-model:visible="dialogVisible"
|
||||
:mode="dialogMode"
|
||||
:coupon="activeCoupon"
|
||||
:plans="plans"
|
||||
@success="() => loadData()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss" src="./CouponsView.scss"></style>
|
||||
@@ -0,0 +1,295 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { assignOrder } from '@/api/admin'
|
||||
import type { AdminPlanListItem } from '@/types/api'
|
||||
import {
|
||||
getAssignablePeriods,
|
||||
orderAmountToYuan,
|
||||
yuanToOrderAmount,
|
||||
} from '@/utils/orders'
|
||||
|
||||
interface AssignOrderFormModel {
|
||||
email: string
|
||||
planId: number | null
|
||||
period: string
|
||||
totalAmountYuan: number | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
plans: AdminPlanListItem[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
success: [tradeNo: string]
|
||||
}>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const form = reactive<AssignOrderFormModel>({
|
||||
email: '',
|
||||
planId: null,
|
||||
period: '',
|
||||
totalAmountYuan: null,
|
||||
})
|
||||
|
||||
const periodOptions = computed(() => {
|
||||
const activePlan = props.plans.find((item) => item.id === form.planId) ?? null
|
||||
return getAssignablePeriods(activePlan)
|
||||
})
|
||||
|
||||
const rules = computed<FormRules<AssignOrderFormModel>>(() => ({
|
||||
email: [
|
||||
{ required: true, message: '请输入用户邮箱', trigger: 'blur' },
|
||||
{ type: 'email', message: '请输入有效邮箱', trigger: ['blur', 'change'] },
|
||||
],
|
||||
planId: [{ required: true, message: '请选择订阅计划', trigger: 'change' }],
|
||||
period: [{ required: true, message: '请选择周期', trigger: 'change' }],
|
||||
totalAmountYuan: [{ required: true, message: '请输入支付金额', trigger: 'blur' }],
|
||||
}))
|
||||
|
||||
function resetForm() {
|
||||
form.email = ''
|
||||
form.planId = null
|
||||
form.period = ''
|
||||
form.totalAmountYuan = null
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
function syncAmountFromPeriod(periodValue: string) {
|
||||
const matched = periodOptions.value.find((item) => item.value === periodValue)
|
||||
form.totalAmountYuan = matched ? orderAmountToYuan(yuanToOrderAmount(matched.amount)) : null
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const instance = formRef.value
|
||||
if (!instance) {
|
||||
return
|
||||
}
|
||||
|
||||
const valid = await instance.validate().catch(() => false)
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const response = await assignOrder({
|
||||
email: form.email.trim(),
|
||||
plan_id: Number(form.planId),
|
||||
period: form.period,
|
||||
total_amount: yuanToOrderAmount(form.totalAmountYuan),
|
||||
})
|
||||
|
||||
ElMessage.success('订单已分配')
|
||||
emit('success', response.data)
|
||||
closeDrawer()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '订单分配失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
|
||||
resetForm()
|
||||
form.planId = props.plans[0]?.id ?? null
|
||||
const firstPeriod = getAssignablePeriods(props.plans[0] ?? null)[0]
|
||||
if (firstPeriod) {
|
||||
form.period = firstPeriod.value
|
||||
syncAmountFromPeriod(firstPeriod.value)
|
||||
}
|
||||
formRef.value?.clearValidate()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => form.planId,
|
||||
(planId) => {
|
||||
const activePlan = props.plans.find((item) => item.id === planId) ?? null
|
||||
const firstPeriod = getAssignablePeriods(activePlan)[0]
|
||||
form.period = firstPeriod?.value ?? ''
|
||||
syncAmountFromPeriod(form.period)
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => form.period,
|
||||
(period) => {
|
||||
if (!period) {
|
||||
form.totalAmountYuan = null
|
||||
return
|
||||
}
|
||||
|
||||
syncAmountFromPeriod(period)
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDrawer
|
||||
:model-value="props.visible"
|
||||
title="分配订单"
|
||||
size="min(520px, 100vw)"
|
||||
class="order-assign-drawer"
|
||||
destroy-on-close
|
||||
@close="closeDrawer"
|
||||
@update:model-value="emit('update:visible', $event)"
|
||||
>
|
||||
<div class="drawer-shell">
|
||||
<div class="drawer-copy">
|
||||
<p>Order Assignment</p>
|
||||
<h2>为指定用户创建订单</h2>
|
||||
<span>先选择用户邮箱、订阅计划与周期,再按需调整支付金额。</span>
|
||||
</div>
|
||||
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
class="drawer-form"
|
||||
>
|
||||
<ElFormItem label="用户邮箱" prop="email">
|
||||
<ElInput v-model="form.email" placeholder="请输入要分配订单的用户邮箱" />
|
||||
</ElFormItem>
|
||||
|
||||
<div class="drawer-grid">
|
||||
<ElFormItem label="订阅计划" prop="planId">
|
||||
<ElSelect v-model="form.planId" placeholder="请选择订阅计划">
|
||||
<ElOption
|
||||
v-for="plan in props.plans"
|
||||
:key="plan.id"
|
||||
:label="plan.name"
|
||||
:value="plan.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="支付周期" prop="period">
|
||||
<ElSelect
|
||||
v-model="form.period"
|
||||
:disabled="periodOptions.length === 0"
|
||||
placeholder="请选择周期"
|
||||
>
|
||||
<ElOption
|
||||
v-for="item in periodOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
|
||||
<ElFormItem label="支付金额(元)" prop="totalAmountYuan">
|
||||
<ElInputNumber
|
||||
v-model="form.totalAmountYuan"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.1"
|
||||
:controls="false"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElAlert
|
||||
v-if="periodOptions.length === 0"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="当前套餐没有可分配的有效周期,请先在套餐管理里配置售价。"
|
||||
/>
|
||||
<p v-else class="amount-tip">
|
||||
默认金额会按所选周期售价回填,你也可以手动调整为运营侧需要的金额。
|
||||
</p>
|
||||
</ElForm>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="drawer-actions">
|
||||
<ElButton @click="closeDrawer">取消</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
:disabled="periodOptions.length === 0"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
提交分配
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.drawer-shell {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.drawer-copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.drawer-copy p {
|
||||
font-size: 12px;
|
||||
color: var(--xboard-text-muted);
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.drawer-copy h2 {
|
||||
font-size: 30px;
|
||||
line-height: 1.08;
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.drawer-copy span {
|
||||
color: var(--xboard-text-secondary);
|
||||
line-height: 1.47;
|
||||
}
|
||||
|
||||
.drawer-form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.drawer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px 16px;
|
||||
}
|
||||
|
||||
.amount-tip {
|
||||
color: var(--xboard-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.drawer-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.drawer-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,492 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { AdminOrderDetail } from '@/types/api'
|
||||
import {
|
||||
COMMISSION_STATUS_UPDATE_OPTIONS,
|
||||
canCancelOrder,
|
||||
canMarkOrderPaid,
|
||||
canUpdateCommissionStatus,
|
||||
formatOrderAmount,
|
||||
formatOrderDateTime,
|
||||
getCommissionStatusMeta,
|
||||
getOrderPeriodLabel,
|
||||
getOrderStatusMeta,
|
||||
getOrderTypeMeta,
|
||||
} from '@/utils/orders'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
loading: boolean
|
||||
order?: AdminOrderDetail | null
|
||||
paying?: boolean
|
||||
cancelling?: boolean
|
||||
updatingCommission?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
paid: []
|
||||
cancel: []
|
||||
'update-commission-status': [value: number]
|
||||
}>()
|
||||
|
||||
const commissionStatusDraft = ref<number | null>(null)
|
||||
|
||||
const statusMeta = computed(() => getOrderStatusMeta(props.order?.status))
|
||||
const typeMeta = computed(() => getOrderTypeMeta(props.order?.type))
|
||||
const commissionMeta = computed(() => getCommissionStatusMeta(props.order?.commission_status, props.order?.commission_balance))
|
||||
|
||||
const summaryCards = computed(() => [
|
||||
{ label: '订单状态', value: statusMeta.value.label, detail: typeMeta.value.label },
|
||||
{ label: '支付金额', value: formatOrderAmount(props.order?.total_amount), detail: '订单总额' },
|
||||
{ label: '佣金金额', value: formatOrderAmount(props.order?.commission_balance), detail: commissionMeta.value.label },
|
||||
{ label: '创建时间', value: formatOrderDateTime(props.order?.created_at), detail: '按后台记录时间展示' },
|
||||
])
|
||||
|
||||
const basicFields = computed(() => [
|
||||
{ label: '订单号', value: props.order?.trade_no || '-' },
|
||||
{ label: '用户邮箱', value: props.order?.user?.email || '-' },
|
||||
{ label: '邀请人', value: props.order?.invite_user?.email || '-' },
|
||||
{ label: '订阅计划', value: props.order?.plan?.name || '-' },
|
||||
{ label: '订单类型', value: typeMeta.value.label },
|
||||
{ label: '订阅周期', value: getOrderPeriodLabel(props.order?.period) },
|
||||
{ label: '回调编号', value: props.order?.callback_no || '-' },
|
||||
{ label: '支付时间', value: formatOrderDateTime(props.order?.paid_at) },
|
||||
])
|
||||
|
||||
const amountFields = computed(() => [
|
||||
{ label: '订单金额', value: formatOrderAmount(props.order?.total_amount) },
|
||||
{ label: '手续费', value: formatOrderAmount(props.order?.handling_amount) },
|
||||
{ label: '余额支付', value: formatOrderAmount(props.order?.balance_amount) },
|
||||
{ label: '优惠金额', value: formatOrderAmount(props.order?.discount_amount) },
|
||||
{ label: '旧订阅折抵', value: formatOrderAmount(props.order?.surplus_amount) },
|
||||
{ label: '退款金额', value: formatOrderAmount(props.order?.refund_amount) },
|
||||
])
|
||||
|
||||
const actionState = computed(() => ({
|
||||
canPay: canMarkOrderPaid(props.order),
|
||||
canCancel: canCancelOrder(props.order),
|
||||
canUpdateCommission: canUpdateCommissionStatus(props.order),
|
||||
}))
|
||||
|
||||
function closeDrawer() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
function submitCommissionStatus() {
|
||||
if (commissionStatusDraft.value === null) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('update-commission-status', commissionStatusDraft.value)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.visible, props.order?.commission_status],
|
||||
([visible]) => {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
|
||||
commissionStatusDraft.value = props.order?.commission_status ?? 0
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDrawer
|
||||
:model-value="props.visible"
|
||||
title="订单详情"
|
||||
size="min(720px, 100vw)"
|
||||
class="order-detail-drawer"
|
||||
destroy-on-close
|
||||
@close="closeDrawer"
|
||||
@update:model-value="emit('update:visible', $event)"
|
||||
>
|
||||
<div v-if="props.loading" class="detail-loading">
|
||||
<ElSkeleton :rows="5" animated />
|
||||
<ElSkeleton :rows="6" animated />
|
||||
</div>
|
||||
|
||||
<div v-else-if="props.order" class="detail-shell">
|
||||
<div class="detail-hero">
|
||||
<div class="hero-copy">
|
||||
<p>Order Detail</p>
|
||||
<h2>{{ props.order.trade_no }}</h2>
|
||||
<div class="hero-badges">
|
||||
<span class="hero-badge" :class="`is-${statusMeta.tone}`">{{ statusMeta.label }}</span>
|
||||
<span class="hero-badge is-neutral">{{ typeMeta.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-grid">
|
||||
<article v-for="item in summaryCards" :key="item.label">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
<p>{{ item.detail }}</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="detail-card">
|
||||
<header class="card-header">
|
||||
<div>
|
||||
<h3>基础信息</h3>
|
||||
<p>集中查看当前订单、用户与套餐的关键字段。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="description-grid">
|
||||
<article v-for="item in basicFields" :key="item.label">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="detail-card">
|
||||
<header class="card-header">
|
||||
<div>
|
||||
<h3>金额拆解</h3>
|
||||
<p>订单金额按后端分为单位存储,这里统一换算成人类可读金额。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="description-grid">
|
||||
<article v-for="item in amountFields" :key="item.label">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="detail-card">
|
||||
<header class="card-header">
|
||||
<div>
|
||||
<h3>佣金状态</h3>
|
||||
<p>仅对存在佣金金额的订单开放状态维护。</p>
|
||||
</div>
|
||||
<span class="hero-badge" :class="`is-${commissionMeta.tone}`">{{ commissionMeta.label }}</span>
|
||||
</header>
|
||||
|
||||
<div class="commission-grid">
|
||||
<article>
|
||||
<span>佣金金额</span>
|
||||
<strong>{{ formatOrderAmount(props.order.commission_balance) }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>实际发放</span>
|
||||
<strong>{{ formatOrderAmount(props.order.actual_commission_balance) }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-if="actionState.canUpdateCommission" class="commission-actions">
|
||||
<ElSelect v-model="commissionStatusDraft" placeholder="请选择佣金状态">
|
||||
<ElOption
|
||||
v-for="item in COMMISSION_STATUS_UPDATE_OPTIONS"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:loading="props.updatingCommission"
|
||||
@click="submitCommissionStatus"
|
||||
>
|
||||
保存佣金状态
|
||||
</ElButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="props.order.surplus_orders?.length" class="detail-card">
|
||||
<header class="card-header">
|
||||
<div>
|
||||
<h3>折抵订单</h3>
|
||||
<p>升级单会展示被折抵的旧订单记录,便于人工追踪。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="list-shell">
|
||||
<article
|
||||
v-for="item in props.order.surplus_orders"
|
||||
:key="item.id"
|
||||
class="list-row"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ item.trade_no }}</strong>
|
||||
<span>{{ getOrderPeriodLabel(item.period) }} · {{ getOrderStatusMeta(item.status).label }}</span>
|
||||
</div>
|
||||
<strong>{{ formatOrderAmount(item.total_amount) }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="props.order.commission_log?.length" class="detail-card">
|
||||
<header class="card-header">
|
||||
<div>
|
||||
<h3>佣金记录</h3>
|
||||
<p>展示当前订单已生成的佣金流水,便于核对发放链路。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="list-shell">
|
||||
<article
|
||||
v-for="item in props.order.commission_log"
|
||||
:key="item.id"
|
||||
class="list-row"
|
||||
>
|
||||
<div>
|
||||
<strong>#{{ item.id }} · 用户 {{ item.invite_user_id }}</strong>
|
||||
<span>{{ formatOrderDateTime(item.created_at) }}</span>
|
||||
</div>
|
||||
<strong>{{ formatOrderAmount(item.get_amount) }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<ElEmpty v-else description="暂无订单详情" />
|
||||
|
||||
<template #footer>
|
||||
<div class="drawer-actions">
|
||||
<ElButton @click="closeDrawer">关闭</ElButton>
|
||||
<ElButton
|
||||
v-if="actionState.canCancel"
|
||||
type="danger"
|
||||
plain
|
||||
:loading="props.cancelling"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
取消订单
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="actionState.canPay"
|
||||
type="primary"
|
||||
:loading="props.paying"
|
||||
@click="emit('paid')"
|
||||
>
|
||||
标记已支付
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-loading,
|
||||
.detail-shell {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.detail-hero {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 26px 28px;
|
||||
border-radius: 28px;
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hero-copy p {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.64);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-copy h2 {
|
||||
margin: 0;
|
||||
color: #ffffff;
|
||||
font-size: clamp(28px, 4vw, 38px);
|
||||
line-height: 1.08;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.hero-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hero-badge.is-success {
|
||||
background: rgba(35, 134, 63, 0.18);
|
||||
color: #74d692;
|
||||
}
|
||||
|
||||
.hero-badge.is-warning {
|
||||
background: rgba(224, 124, 35, 0.2);
|
||||
color: #ffcb87;
|
||||
}
|
||||
|
||||
.hero-badge.is-danger {
|
||||
background: rgba(201, 52, 40, 0.2);
|
||||
color: #ffb4aa;
|
||||
}
|
||||
|
||||
.hero-badge.is-info {
|
||||
background: rgba(0, 113, 227, 0.2);
|
||||
color: #8cc6ff;
|
||||
}
|
||||
|
||||
.hero-badge.is-neutral {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.84);
|
||||
}
|
||||
|
||||
.summary-grid,
|
||||
.description-grid,
|
||||
.commission-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.summary-grid article,
|
||||
.description-grid article,
|
||||
.commission-grid article {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.summary-grid span,
|
||||
.summary-grid p {
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
}
|
||||
|
||||
.summary-grid strong {
|
||||
color: #ffffff;
|
||||
font-size: 22px;
|
||||
line-height: 1.14;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 24px 26px;
|
||||
border-radius: 24px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--xboard-shadow);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
margin: 0;
|
||||
color: var(--xboard-text-strong);
|
||||
font-size: 24px;
|
||||
line-height: 1.12;
|
||||
}
|
||||
|
||||
.card-header p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--xboard-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.description-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.description-grid article,
|
||||
.commission-grid article {
|
||||
background: #fbfbfd;
|
||||
}
|
||||
|
||||
.description-grid span,
|
||||
.commission-grid span,
|
||||
.list-row span {
|
||||
color: var(--xboard-text-muted);
|
||||
}
|
||||
|
||||
.description-grid strong,
|
||||
.commission-grid strong,
|
||||
.list-row strong {
|
||||
color: var(--xboard-text-strong);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.commission-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.commission-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.commission-actions :deep(.el-select) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.list-shell {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
background: #fbfbfd;
|
||||
}
|
||||
|
||||
.list-row > div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.drawer-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.summary-grid,
|
||||
.description-grid,
|
||||
.commission-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.card-header,
|
||||
.commission-actions,
|
||||
.list-row,
|
||||
.drawer-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,189 @@
|
||||
.orders-page {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.orders-intro {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.orders-copy {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.orders-kicker {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: var(--xboard-text-muted);
|
||||
}
|
||||
|
||||
.orders-copy h1 {
|
||||
font-size: clamp(34px, 5vw, 48px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.28px;
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.orders-copy span {
|
||||
color: var(--xboard-text-secondary);
|
||||
line-height: 1.47;
|
||||
}
|
||||
|
||||
.orders-shell {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 24px;
|
||||
border-radius: 24px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--xboard-shadow);
|
||||
}
|
||||
|
||||
.orders-toolbar,
|
||||
.toolbar-left,
|
||||
.toolbar-right,
|
||||
.table-footer,
|
||||
.order-link,
|
||||
.status-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.orders-toolbar,
|
||||
.table-footer {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-search {
|
||||
width: min(260px, 100%);
|
||||
}
|
||||
|
||||
.filter-pill {
|
||||
border-radius: 999px;
|
||||
border-color: var(--xboard-border);
|
||||
background: #ffffff;
|
||||
color: var(--xboard-text-secondary);
|
||||
}
|
||||
|
||||
.filter-pill:hover,
|
||||
.toolbar-ghost:hover {
|
||||
color: #0071e3;
|
||||
border-color: rgba(0, 113, 227, 0.18);
|
||||
}
|
||||
|
||||
.toolbar-ghost {
|
||||
color: var(--xboard-text-secondary);
|
||||
}
|
||||
|
||||
.orders-alert {
|
||||
margin-bottom: -4px;
|
||||
}
|
||||
|
||||
.orders-table :deep(th.el-table__cell) {
|
||||
background: #fbfbfd;
|
||||
color: var(--xboard-text-secondary);
|
||||
}
|
||||
|
||||
.orders-table :deep(.el-table__row td.el-table__cell) {
|
||||
padding-top: 14px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
.order-link {
|
||||
justify-content: flex-start;
|
||||
max-width: 100%;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.order-link__code {
|
||||
display: inline-block;
|
||||
max-width: 112px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.plan-cell {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.plan-cell strong,
|
||||
.amount-cell {
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.plan-cell span {
|
||||
color: var(--xboard-text-muted);
|
||||
}
|
||||
|
||||
.type-pill,
|
||||
.period-pill,
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 30px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
background: #f5f5f7;
|
||||
color: var(--xboard-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.type-pill.is-warning,
|
||||
.status-pill.is-warning {
|
||||
background: rgba(224, 124, 35, 0.12);
|
||||
color: var(--xboard-warning);
|
||||
}
|
||||
|
||||
.type-pill.is-info,
|
||||
.status-pill.is-info {
|
||||
background: rgba(0, 113, 227, 0.12);
|
||||
color: #0071e3;
|
||||
}
|
||||
|
||||
.status-pill.is-success {
|
||||
background: rgba(35, 134, 63, 0.12);
|
||||
color: var(--xboard-success);
|
||||
}
|
||||
|
||||
.status-pill.is-danger {
|
||||
background: rgba(201, 52, 40, 0.12);
|
||||
color: var(--xboard-danger);
|
||||
}
|
||||
|
||||
.status-pill.is-neutral,
|
||||
.type-pill.is-neutral {
|
||||
background: #f5f5f7;
|
||||
color: var(--xboard-text-secondary);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.table-footer span {
|
||||
color: var(--xboard-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.orders-toolbar,
|
||||
.table-footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, RefreshRight, Search, TopRight } from '@element-plus/icons-vue'
|
||||
import {
|
||||
cancelOrder,
|
||||
fetchOrders,
|
||||
getOrderDetail,
|
||||
getPlans,
|
||||
markOrderPaid,
|
||||
updateOrderCommissionStatus,
|
||||
} from '@/api/admin'
|
||||
import type {
|
||||
AdminOrderDetail,
|
||||
AdminOrderListItem,
|
||||
AdminPlanListItem,
|
||||
AdminTableSort,
|
||||
} from '@/types/api'
|
||||
import {
|
||||
COMMISSION_STATUS_OPTIONS,
|
||||
ORDER_PERIOD_OPTIONS,
|
||||
ORDER_STATUS_OPTIONS,
|
||||
ORDER_TYPE_OPTIONS,
|
||||
buildOrderFetchFilters,
|
||||
formatOrderAmount,
|
||||
formatOrderDateTime,
|
||||
getCommissionStatusFilterLabel,
|
||||
getCommissionStatusMeta,
|
||||
getOrderFilterLabel,
|
||||
getOrderPeriodFilterLabel,
|
||||
getOrderPeriodLabel,
|
||||
getOrderStatusFilterLabel,
|
||||
getOrderStatusMeta,
|
||||
getOrderTypeMeta,
|
||||
type OrderFilterValue,
|
||||
type OrderPeriodKey,
|
||||
} from '@/utils/orders'
|
||||
import OrderAssignDrawer from './OrderAssignDrawer.vue'
|
||||
import OrderDetailDrawer from './OrderDetailDrawer.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const metaLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const orders = ref<AdminOrderListItem[]>([])
|
||||
const plans = ref<AdminPlanListItem[]>([])
|
||||
const total = ref(0)
|
||||
const current = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const keyword = ref('')
|
||||
const typeFilter = ref<OrderFilterValue<number>>('all')
|
||||
const periodFilter = ref<OrderFilterValue<OrderPeriodKey>>('all')
|
||||
const statusFilter = ref<OrderFilterValue<number>>('all')
|
||||
const commissionFilter = ref<OrderFilterValue<number>>('all')
|
||||
const sortState = ref<AdminTableSort>({ id: 'created_at', desc: true })
|
||||
|
||||
const assignVisible = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailOrder = ref<AdminOrderDetail | null>(null)
|
||||
const paying = ref(false)
|
||||
const cancelling = ref(false)
|
||||
const updatingCommission = ref(false)
|
||||
|
||||
const filterButtonLabels = computed(() => ({
|
||||
type: typeFilter.value === 'all' ? '类型' : `类型 · ${getOrderFilterLabel(typeFilter.value)}`,
|
||||
period: periodFilter.value === 'all' ? '周期' : `周期 · ${getOrderPeriodFilterLabel(periodFilter.value)}`,
|
||||
status: statusFilter.value === 'all' ? '订单状态' : `订单状态 · ${getOrderStatusFilterLabel(statusFilter.value)}`,
|
||||
commission: commissionFilter.value === 'all'
|
||||
? '佣金状态'
|
||||
: `佣金状态 · ${getCommissionStatusFilterLabel(commissionFilter.value)}`,
|
||||
}))
|
||||
|
||||
async function loadPlans() {
|
||||
metaLoading.value = true
|
||||
try {
|
||||
const response = await getPlans()
|
||||
plans.value = response.data ?? []
|
||||
} catch (error) {
|
||||
ElMessage.warning(error instanceof Error ? error.message : '套餐列表加载失败,分配订单将暂时不可用')
|
||||
} finally {
|
||||
metaLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOrders() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const response = await fetchOrders({
|
||||
current: current.value,
|
||||
pageSize: pageSize.value,
|
||||
filter: buildOrderFetchFilters({
|
||||
keyword: keyword.value,
|
||||
type: typeFilter.value,
|
||||
period: periodFilter.value,
|
||||
status: statusFilter.value,
|
||||
commissionStatus: commissionFilter.value,
|
||||
}),
|
||||
sort: sortState.value ? [sortState.value] : undefined,
|
||||
})
|
||||
|
||||
orders.value = response.data ?? []
|
||||
total.value = response.total ?? 0
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '订单列表加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function refreshOrders(resetPage: boolean = false) {
|
||||
if (resetPage && current.value !== 1) {
|
||||
current.value = 1
|
||||
return
|
||||
}
|
||||
|
||||
void loadOrders()
|
||||
}
|
||||
|
||||
function handleDropdownSelect(kind: 'type' | 'period' | 'status' | 'commission', value: string) {
|
||||
if (kind === 'type') {
|
||||
typeFilter.value = value === 'all' ? 'all' : Number(value)
|
||||
}
|
||||
|
||||
if (kind === 'period') {
|
||||
periodFilter.value = value as OrderFilterValue<OrderPeriodKey>
|
||||
}
|
||||
|
||||
if (kind === 'status') {
|
||||
statusFilter.value = value === 'all' ? 'all' : Number(value)
|
||||
}
|
||||
|
||||
if (kind === 'commission') {
|
||||
commissionFilter.value = value === 'all' ? 'all' : Number(value)
|
||||
}
|
||||
|
||||
refreshOrders(true)
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
keyword.value = ''
|
||||
typeFilter.value = 'all'
|
||||
periodFilter.value = 'all'
|
||||
statusFilter.value = 'all'
|
||||
commissionFilter.value = 'all'
|
||||
sortState.value = { id: 'created_at', desc: true }
|
||||
refreshOrders(true)
|
||||
}
|
||||
|
||||
async function openDetail(order: AdminOrderListItem) {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
detailOrder.value = null
|
||||
|
||||
try {
|
||||
const response = await getOrderDetail(order.id)
|
||||
detailOrder.value = response.data
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '订单详情加载失败')
|
||||
detailVisible.value = false
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadDetail() {
|
||||
if (!detailOrder.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const response = await getOrderDetail(detailOrder.value.id)
|
||||
detailOrder.value = response.data
|
||||
}
|
||||
|
||||
async function handleMarkPaid() {
|
||||
if (!detailOrder.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认将订单 ${detailOrder.value.trade_no} 标记为已支付吗?`, '标记已支付', {
|
||||
type: 'warning',
|
||||
})
|
||||
paying.value = true
|
||||
await markOrderPaid(detailOrder.value.trade_no)
|
||||
ElMessage.success('订单已标记为支付成功')
|
||||
await Promise.all([loadOrders(), reloadDetail()])
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') {
|
||||
return
|
||||
}
|
||||
ElMessage.error(error instanceof Error ? error.message : '订单支付状态更新失败')
|
||||
} finally {
|
||||
paying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancelOrder() {
|
||||
if (!detailOrder.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认取消订单 ${detailOrder.value.trade_no} 吗?`, '取消订单', {
|
||||
type: 'warning',
|
||||
})
|
||||
cancelling.value = true
|
||||
await cancelOrder(detailOrder.value.trade_no)
|
||||
ElMessage.success('订单已取消')
|
||||
await Promise.all([loadOrders(), reloadDetail()])
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') {
|
||||
return
|
||||
}
|
||||
ElMessage.error(error instanceof Error ? error.message : '订单取消失败')
|
||||
} finally {
|
||||
cancelling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCommissionStatusUpdate(value: number) {
|
||||
if (!detailOrder.value) {
|
||||
return
|
||||
}
|
||||
|
||||
updatingCommission.value = true
|
||||
try {
|
||||
await updateOrderCommissionStatus(detailOrder.value.trade_no, value)
|
||||
ElMessage.success('佣金状态已更新')
|
||||
await Promise.all([loadOrders(), reloadDetail()])
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '佣金状态更新失败')
|
||||
} finally {
|
||||
updatingCommission.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleAssignSuccess() {
|
||||
assignVisible.value = false
|
||||
refreshOrders(true)
|
||||
}
|
||||
|
||||
function handleSortChange(payload: { prop: string; order: 'ascending' | 'descending' | null }) {
|
||||
if (!payload.prop || !payload.order) {
|
||||
sortState.value = { id: 'created_at', desc: true }
|
||||
refreshOrders(false)
|
||||
return
|
||||
}
|
||||
|
||||
sortState.value = {
|
||||
id: payload.prop,
|
||||
desc: payload.order === 'descending',
|
||||
}
|
||||
refreshOrders(false)
|
||||
}
|
||||
|
||||
watch([current, pageSize], () => {
|
||||
void loadOrders()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void Promise.all([loadPlans(), loadOrders()]).catch(() => {
|
||||
ElMessage.error('订单管理页面初始化失败')
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="orders-page">
|
||||
<section class="orders-intro">
|
||||
<div class="orders-copy">
|
||||
<p class="orders-kicker">Subscriptions</p>
|
||||
<h1>订单管理</h1>
|
||||
<span>在这里可以查看用户订单,包括分配、查看、删除等操作。</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="orders-shell">
|
||||
<header class="orders-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<ElButton type="primary" @click="assignVisible = true">
|
||||
<ElIcon><Plus /></ElIcon>
|
||||
添加订单
|
||||
</ElButton>
|
||||
|
||||
<ElInput
|
||||
v-model="keyword"
|
||||
clearable
|
||||
placeholder="搜索订单..."
|
||||
class="toolbar-search"
|
||||
@keyup.enter="refreshOrders(true)"
|
||||
>
|
||||
<template #prefix>
|
||||
<ElIcon><Search /></ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
|
||||
<ElDropdown trigger="click" @command="handleDropdownSelect('type', $event)">
|
||||
<ElButton class="filter-pill">
|
||||
<ElIcon><Plus /></ElIcon>
|
||||
{{ filterButtonLabels.type }}
|
||||
</ElButton>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem command="all">全部类型</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
v-for="item in ORDER_TYPE_OPTIONS"
|
||||
:key="item.value"
|
||||
:command="String(item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
|
||||
<ElDropdown trigger="click" @command="handleDropdownSelect('period', $event)">
|
||||
<ElButton class="filter-pill">
|
||||
<ElIcon><Plus /></ElIcon>
|
||||
{{ filterButtonLabels.period }}
|
||||
</ElButton>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem command="all">全部周期</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
v-for="item in ORDER_PERIOD_OPTIONS"
|
||||
:key="item.value"
|
||||
:command="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
|
||||
<ElDropdown trigger="click" @command="handleDropdownSelect('status', $event)">
|
||||
<ElButton class="filter-pill">
|
||||
<ElIcon><Plus /></ElIcon>
|
||||
{{ filterButtonLabels.status }}
|
||||
</ElButton>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem command="all">全部订单状态</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
v-for="item in ORDER_STATUS_OPTIONS"
|
||||
:key="item.value"
|
||||
:command="String(item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
|
||||
<ElDropdown trigger="click" @command="handleDropdownSelect('commission', $event)">
|
||||
<ElButton class="filter-pill">
|
||||
<ElIcon><Plus /></ElIcon>
|
||||
{{ filterButtonLabels.commission }}
|
||||
</ElButton>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem command="all">全部佣金状态</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
v-for="item in COMMISSION_STATUS_OPTIONS"
|
||||
:key="item.value"
|
||||
:command="String(item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-right">
|
||||
<ElButton class="toolbar-ghost" @click="clearFilters">
|
||||
重置筛选
|
||||
</ElButton>
|
||||
<ElButton class="toolbar-ghost" :loading="loading || metaLoading" @click="refreshOrders(false)">
|
||||
<ElIcon><RefreshRight /></ElIcon>
|
||||
刷新
|
||||
</ElButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ElAlert
|
||||
v-if="errorMessage"
|
||||
class="orders-alert"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="errorMessage"
|
||||
>
|
||||
<template #default>
|
||||
<ElButton size="small" @click="refreshOrders(false)">重新加载</ElButton>
|
||||
</template>
|
||||
</ElAlert>
|
||||
|
||||
<ElTable
|
||||
:data="orders"
|
||||
v-loading="loading"
|
||||
class="orders-table"
|
||||
row-key="id"
|
||||
empty-text="当前筛选条件下暂无订单"
|
||||
@sort-change="handleSortChange"
|
||||
>
|
||||
<ElTableColumn label="订单号" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<ElButton text class="order-link" @click="openDetail(row)">
|
||||
<span class="order-link__code mono">{{ row.trade_no }}</span>
|
||||
<ElIcon><TopRight /></ElIcon>
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="类型" width="112">
|
||||
<template #default="{ row }">
|
||||
<span class="type-pill" :class="`is-${getOrderTypeMeta(row.type).tone}`">
|
||||
{{ getOrderTypeMeta(row.type).label }}
|
||||
</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="订阅计划" min-width="230">
|
||||
<template #default="{ row }">
|
||||
<div class="plan-cell">
|
||||
<strong>{{ row.plan?.name || '未绑定套餐' }}</strong>
|
||||
<span>{{ getOrderPeriodLabel(row.period) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn prop="period" label="周期" width="110">
|
||||
<template #default="{ row }">
|
||||
<span class="period-pill">{{ getOrderPeriodLabel(row.period) }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn prop="total_amount" label="支付金额" min-width="140" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
<strong class="amount-cell">{{ formatOrderAmount(row.total_amount) }}</strong>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn prop="status" label="订单状态" min-width="150" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
<span class="status-pill" :class="`is-${getOrderStatusMeta(row.status).tone}`">
|
||||
<span class="status-dot" />
|
||||
{{ getOrderStatusMeta(row.status).label }}
|
||||
</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn prop="commission_balance" label="佣金金额" min-width="140" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.commission_balance ? formatOrderAmount(row.commission_balance) : '-' }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn prop="commission_status" label="佣金状态" min-width="150" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
<span class="status-pill" :class="`is-${getCommissionStatusMeta(row.commission_status, row.commission_balance).tone}`">
|
||||
<span class="status-dot" />
|
||||
{{ getCommissionStatusMeta(row.commission_status, row.commission_balance).label }}
|
||||
</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn prop="created_at" label="创建时间" min-width="180" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
<span>{{ formatOrderDateTime(row.created_at) }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<footer class="table-footer">
|
||||
<span>已选择 0 项,共 {{ total }} 项</span>
|
||||
<ElPagination
|
||||
v-model:current-page="current"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="sizes, prev, pager, next"
|
||||
:total="total"
|
||||
background
|
||||
/>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<OrderAssignDrawer
|
||||
:visible="assignVisible"
|
||||
:plans="plans"
|
||||
@update:visible="assignVisible = $event"
|
||||
@success="handleAssignSuccess"
|
||||
/>
|
||||
|
||||
<OrderDetailDrawer
|
||||
:visible="detailVisible"
|
||||
:loading="detailLoading"
|
||||
:order="detailOrder"
|
||||
:paying="paying"
|
||||
:cancelling="cancelling"
|
||||
:updating-commission="updatingCommission"
|
||||
@update:visible="detailVisible = $event"
|
||||
@paid="handleMarkPaid"
|
||||
@cancel="handleCancelOrder"
|
||||
@update-commission-status="handleCommissionStatusUpdate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss" src="./OrdersView.scss"></style>
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
formatPlanTraffic,
|
||||
getPlanPriceBadges,
|
||||
movePlanOrder,
|
||||
normalizePlanToggleFields,
|
||||
} from '@/utils/plans'
|
||||
import PlanEditorDrawer from './PlanEditorDrawer.vue'
|
||||
|
||||
@@ -70,7 +71,9 @@ async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [plansResponse, groupsResponse] = await Promise.all([getPlans(), getServerGroups()])
|
||||
plans.value = [...(plansResponse.data ?? [])].sort((left, right) => (left.sort || 0) - (right.sort || 0))
|
||||
plans.value = (plansResponse.data ?? [])
|
||||
.map((plan) => normalizePlanToggleFields(plan))
|
||||
.sort((left, right) => (left.sort || 0) - (right.sort || 0))
|
||||
groups.value = groupsResponse.data ?? []
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -91,10 +94,14 @@ function openEditDrawer(plan: AdminPlanListItem) {
|
||||
|
||||
async function handleToggle(plan: AdminPlanListItem, field: PlanToggleField, nextValue: boolean | string | number) {
|
||||
const key = getToggleKey(plan.id, field)
|
||||
const normalizedNextValue = Boolean(nextValue)
|
||||
if (plan[field] === normalizedNextValue) {
|
||||
return
|
||||
}
|
||||
toggleLoadingMap.value[key] = true
|
||||
try {
|
||||
await updatePlan(plan.id, { [field]: Boolean(nextValue) })
|
||||
plan[field] = Boolean(nextValue)
|
||||
await updatePlan(plan.id, { [field]: normalizedNextValue })
|
||||
plan[field] = normalizedNextValue
|
||||
ElMessage.success('套餐状态已更新')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '套餐状态更新失败')
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
.dialog-shell,
|
||||
.dialog-form {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.dialog-copy {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dialog-copy p {
|
||||
font-size: 12px;
|
||||
color: var(--xboard-text-muted);
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dialog-copy h2 {
|
||||
font-size: 30px;
|
||||
line-height: 1.08;
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.dialog-copy span {
|
||||
color: var(--xboard-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.dialog-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px 16px;
|
||||
}
|
||||
|
||||
.is-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.visibility-panel,
|
||||
.editor-panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.visibility-panel {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
padding: 16px 18px;
|
||||
border-radius: 18px;
|
||||
background: #fbfbfd;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.visibility-copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.visibility-copy strong {
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.visibility-copy span {
|
||||
color: var(--xboard-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
padding: 18px;
|
||||
border-radius: 22px;
|
||||
background: #fbfbfd;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.editor-header,
|
||||
.editor-actions,
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.editor-header,
|
||||
.dialog-footer {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.editor-header h3 {
|
||||
font-size: 18px;
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.editor-header span {
|
||||
color: var(--xboard-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.editor-counter {
|
||||
color: var(--xboard-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editor-toolbar button {
|
||||
min-width: 42px;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
background: #ffffff;
|
||||
color: var(--xboard-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.18s ease, color 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.editor-toolbar button:hover {
|
||||
color: #0071e3;
|
||||
border-color: rgba(0, 113, 227, 0.24);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.editor-textarea,
|
||||
.editor-preview {
|
||||
min-height: 320px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.editor-textarea {
|
||||
width: 100%;
|
||||
padding: 18px;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
color: var(--xboard-text-strong);
|
||||
font: inherit;
|
||||
line-height: 1.68;
|
||||
}
|
||||
|
||||
.editor-preview {
|
||||
padding: 18px;
|
||||
overflow: auto;
|
||||
color: var(--xboard-text-secondary);
|
||||
}
|
||||
|
||||
.markdown-body :deep(h1),
|
||||
.markdown-body :deep(h2),
|
||||
.markdown-body :deep(h3) {
|
||||
margin: 0 0 12px;
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.markdown-body :deep(p),
|
||||
.markdown-body :deep(ul),
|
||||
.markdown-body :deep(ol),
|
||||
.markdown-body :deep(blockquote) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.markdown-body :deep(blockquote) {
|
||||
padding-left: 14px;
|
||||
border-left: 3px solid rgba(0, 113, 227, 0.18);
|
||||
color: var(--xboard-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.dialog-grid,
|
||||
.visibility-panel,
|
||||
.editor-header,
|
||||
.dialog-footer {
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { saveKnowledge } from '@/api/admin'
|
||||
import type { AdminKnowledgeDetail } from '@/types/api'
|
||||
import {
|
||||
KNOWLEDGE_LANGUAGE_OPTIONS,
|
||||
createEmptyKnowledgeForm,
|
||||
renderKnowledgeBody,
|
||||
toKnowledgeFormModel,
|
||||
toKnowledgeSavePayload,
|
||||
type KnowledgeFormModel,
|
||||
} from '@/utils/knowledge'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
mode: 'create' | 'edit'
|
||||
knowledge?: AdminKnowledgeDetail | null
|
||||
categories: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
success: [message: string]
|
||||
}>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const previewVisible = ref(false)
|
||||
const contentEditorRef = ref<HTMLTextAreaElement | null>(null)
|
||||
const form = reactive<KnowledgeFormModel>(createEmptyKnowledgeForm())
|
||||
|
||||
const dialogTitle = computed(() => props.mode === 'create' ? '添加知识' : '编辑知识')
|
||||
const renderedBody = computed(() => renderKnowledgeBody(form.body))
|
||||
const bodyLength = computed(() => form.body.trim().length)
|
||||
const categoryOptions = computed(() => props.categories.filter(Boolean))
|
||||
|
||||
const rules = computed<FormRules<KnowledgeFormModel>>(() => ({
|
||||
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
|
||||
category: [{ required: true, message: '请输入分类', trigger: 'change' }],
|
||||
language: [{ required: true, message: '请选择语言', trigger: 'change' }],
|
||||
body: [{ required: true, message: '请输入内容', trigger: 'blur' }],
|
||||
}))
|
||||
|
||||
function closeDialog() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
function syncForm() {
|
||||
Object.assign(form, createEmptyKnowledgeForm(), toKnowledgeFormModel(props.knowledge))
|
||||
previewVisible.value = false
|
||||
}
|
||||
|
||||
function insertSnippet(prefix: string, suffix = '', placeholder = '内容') {
|
||||
const textarea = contentEditorRef.value
|
||||
const content = form.body
|
||||
if (!textarea) {
|
||||
form.body = `${content}${prefix}${placeholder}${suffix}`
|
||||
return
|
||||
}
|
||||
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = content.slice(start, end) || placeholder
|
||||
form.body = `${content.slice(0, start)}${prefix}${selected}${suffix}${content.slice(end)}`
|
||||
|
||||
nextTick(() => {
|
||||
textarea.focus()
|
||||
const cursor = start + prefix.length + selected.length + suffix.length
|
||||
textarea.setSelectionRange(cursor, cursor)
|
||||
})
|
||||
}
|
||||
|
||||
function insertTextAtCursor(text: string, cursorOffsetFromEnd = 0) {
|
||||
const textarea = contentEditorRef.value
|
||||
const content = form.body
|
||||
if (!textarea) {
|
||||
form.body = `${content}${text}`
|
||||
return
|
||||
}
|
||||
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
form.body = `${content.slice(0, start)}${text}${content.slice(end)}`
|
||||
|
||||
nextTick(() => {
|
||||
textarea.focus()
|
||||
const cursor = start + text.length - cursorOffsetFromEnd
|
||||
textarea.setSelectionRange(cursor, cursor)
|
||||
})
|
||||
}
|
||||
|
||||
function insertHeading() {
|
||||
insertSnippet('# ', '', '一级标题')
|
||||
}
|
||||
|
||||
function insertList() {
|
||||
insertSnippet('- ', '', '列表项')
|
||||
}
|
||||
|
||||
function insertQuote() {
|
||||
insertSnippet('> ', '', '引用内容')
|
||||
}
|
||||
|
||||
function insertCode() {
|
||||
insertSnippet('`', '`', '代码')
|
||||
}
|
||||
|
||||
function insertLink() {
|
||||
insertTextAtCursor('[链接文本](https://)', 1)
|
||||
}
|
||||
|
||||
function insertImage() {
|
||||
insertTextAtCursor('', 1)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const instance = formRef.value
|
||||
if (!instance) {
|
||||
return
|
||||
}
|
||||
|
||||
const valid = await instance.validate().catch(() => false)
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await saveKnowledge(toKnowledgeSavePayload(form))
|
||||
const message = props.mode === 'create' ? '知识已创建' : '知识已更新'
|
||||
ElMessage.success(message)
|
||||
emit('success', message)
|
||||
closeDialog()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '知识保存失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.visible, props.knowledge, props.mode],
|
||||
([visible]) => {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
|
||||
syncForm()
|
||||
nextTick(() => {
|
||||
formRef.value?.clearValidate()
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDialog
|
||||
:model-value="props.visible"
|
||||
:title="dialogTitle"
|
||||
width="min(860px, calc(100vw - 32px))"
|
||||
top="5vh"
|
||||
destroy-on-close
|
||||
class="knowledge-editor-dialog"
|
||||
@close="closeDialog"
|
||||
@update:model-value="emit('update:visible', $event)"
|
||||
>
|
||||
<div class="dialog-shell">
|
||||
<div class="dialog-copy">
|
||||
<p>Knowledge Base</p>
|
||||
<h2>{{ dialogTitle }}</h2>
|
||||
<span>发布或维护知识库文案,支持分类、语言、显示状态和 Markdown 正文编辑。</span>
|
||||
</div>
|
||||
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
class="dialog-form"
|
||||
>
|
||||
<div class="dialog-grid">
|
||||
<ElFormItem label="标题" prop="title" class="is-full">
|
||||
<ElInput v-model="form.title" placeholder="请输入标题" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="分类" prop="category">
|
||||
<ElSelect
|
||||
v-model="form.category"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
placeholder="请选择或输入分类"
|
||||
>
|
||||
<ElOption
|
||||
v-for="item in categoryOptions"
|
||||
:key="item"
|
||||
:label="item"
|
||||
:value="item"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="语言" prop="language">
|
||||
<ElSelect v-model="form.language" placeholder="请选择语言">
|
||||
<ElOption
|
||||
v-for="item in KNOWLEDGE_LANGUAGE_OPTIONS"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
|
||||
<section class="visibility-panel">
|
||||
<div class="visibility-copy">
|
||||
<strong>显示状态</strong>
|
||||
<span>关闭后仍保留内容,但不会在用户侧展示。</span>
|
||||
</div>
|
||||
|
||||
<ElSwitch v-model="form.show" />
|
||||
</section>
|
||||
|
||||
<ElFormItem label="内容" prop="body">
|
||||
<section class="editor-panel">
|
||||
<header class="editor-header">
|
||||
<div>
|
||||
<h3>正文编辑</h3>
|
||||
<span>采用轻量 Markdown 编辑方案,适合教程、常见问题和知识说明文档。</span>
|
||||
</div>
|
||||
|
||||
<div class="editor-actions">
|
||||
<span class="editor-counter">{{ bodyLength }} 字</span>
|
||||
<ElButton @click="previewVisible = !previewVisible">
|
||||
{{ previewVisible ? '继续编辑' : '显示预览' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="editor-toolbar">
|
||||
<button type="button" @click="insertHeading">H1</button>
|
||||
<button type="button" @click="insertSnippet('**', '**', '加粗文本')">B</button>
|
||||
<button type="button" @click="insertSnippet('*', '*', '斜体文本')">I</button>
|
||||
<button type="button" @click="insertSnippet('<u>', '</u>', '下划线')">U</button>
|
||||
<button type="button" @click="insertList">列表</button>
|
||||
<button type="button" @click="insertQuote">引用</button>
|
||||
<button type="button" @click="insertCode">代码</button>
|
||||
<button type="button" @click="insertLink">链接</button>
|
||||
<button type="button" @click="insertImage">图片</button>
|
||||
<button type="button" @click="insertTextAtCursor('<br>')">换行</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="previewVisible"
|
||||
class="editor-preview markdown-body"
|
||||
v-html="renderedBody"
|
||||
/>
|
||||
<textarea
|
||||
v-else
|
||||
ref="contentEditorRef"
|
||||
v-model="form.body"
|
||||
class="editor-textarea"
|
||||
placeholder="请输入知识内容,支持 Markdown 与基础 HTML。"
|
||||
/>
|
||||
</section>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<ElButton @click="closeDialog">取消</ElButton>
|
||||
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
|
||||
{{ props.mode === 'create' ? '提交' : '保存修改' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss" src="./KnowledgeEditorDialog.scss"></style>
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
.plugin-card {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 24px 24px 22px;
|
||||
border-radius: 24px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--xboard-shadow);
|
||||
}
|
||||
|
||||
.plugin-card__header,
|
||||
.plugin-card__actions,
|
||||
.plugin-card__summary,
|
||||
.title-row,
|
||||
.meta-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.plugin-card__header {
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.plugin-card__title {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: var(--xboard-text-strong);
|
||||
font-size: 32px;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.28px;
|
||||
}
|
||||
}
|
||||
|
||||
.title-tags,
|
||||
.meta-row,
|
||||
.plugin-card__summary,
|
||||
.plugin-card__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
color: var(--xboard-text-muted);
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
|
||||
code {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: #f5f5f7;
|
||||
color: var(--xboard-text-secondary);
|
||||
font-family: var(--xboard-font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid rgba(0, 113, 227, 0.12);
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 113, 227, 0.06);
|
||||
color: #0071e3;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.plugin-card__description {
|
||||
margin: 0;
|
||||
color: var(--xboard-text-secondary);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.plugin-card__summary {
|
||||
align-items: center;
|
||||
color: var(--xboard-text-muted);
|
||||
font-size: 13px;
|
||||
|
||||
span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: #f5f5f7;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-card__actions {
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.plugin-card__header,
|
||||
.plugin-card__actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.plugin-card__actions :deep(.el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Plus, Setting } from '@element-plus/icons-vue'
|
||||
import type { AdminPluginItem } from '@/types/api'
|
||||
import { getPluginStatusMeta, getPluginTypeLabel, hasPluginConfig, hasPluginReadme } from '@/utils/plugins'
|
||||
|
||||
type PluginAction = 'install' | 'enable' | 'disable' | 'upgrade' | 'uninstall'
|
||||
|
||||
const props = defineProps<{
|
||||
plugin: AdminPluginItem
|
||||
typeLabels: Array<{ value: string; label: string }>
|
||||
actionLoadingMap: Record<string, boolean>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
detail: [plugin: AdminPluginItem]
|
||||
action: [payload: { plugin: AdminPluginItem; action: PluginAction }]
|
||||
}>()
|
||||
|
||||
const statusMeta = computed(() => getPluginStatusMeta(props.plugin))
|
||||
const typeLabel = computed(() => getPluginTypeLabel(props.plugin.type, props.typeLabels))
|
||||
|
||||
function isActionLoading(action: PluginAction): boolean {
|
||||
return Boolean(props.actionLoadingMap[`${props.plugin.code}:${action}`])
|
||||
}
|
||||
|
||||
function triggerAction(action: PluginAction) {
|
||||
emit('action', { plugin: props.plugin, action })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="plugin-card">
|
||||
<div class="plugin-card__header">
|
||||
<div class="plugin-card__title">
|
||||
<div class="title-row">
|
||||
<h2>{{ props.plugin.name }}</h2>
|
||||
<div class="title-tags">
|
||||
<ElTag round effect="plain">{{ typeLabel }}</ElTag>
|
||||
<ElTag round :type="statusMeta.tone || undefined">
|
||||
{{ statusMeta.label }}
|
||||
</ElTag>
|
||||
<ElTag v-if="props.plugin.is_protected" round type="warning">核心插件</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="meta-row">
|
||||
<code>{{ props.plugin.code }}</code>
|
||||
<span>v{{ props.plugin.version }}</span>
|
||||
<span>{{ props.plugin.author || '未知作者' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="detail-button" @click="emit('detail', props.plugin)">
|
||||
<ElIcon><Setting /></ElIcon>
|
||||
详情
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="plugin-card__description">
|
||||
{{ props.plugin.description || '当前插件未提供描述信息。' }}
|
||||
</p>
|
||||
|
||||
<div class="plugin-card__summary">
|
||||
<span>{{ statusMeta.helper }}</span>
|
||||
<span v-if="hasPluginConfig(props.plugin)">含配置项</span>
|
||||
<span v-if="hasPluginReadme(props.plugin)">含 README</span>
|
||||
</div>
|
||||
|
||||
<div class="plugin-card__actions">
|
||||
<ElButton
|
||||
v-if="!props.plugin.is_installed"
|
||||
type="primary"
|
||||
:loading="isActionLoading('install')"
|
||||
@click="triggerAction('install')"
|
||||
>
|
||||
<ElIcon><Plus /></ElIcon>
|
||||
安装
|
||||
</ElButton>
|
||||
|
||||
<ElButton
|
||||
v-if="props.plugin.is_installed && !props.plugin.is_enabled"
|
||||
:loading="isActionLoading('enable')"
|
||||
@click="triggerAction('enable')"
|
||||
>
|
||||
启用
|
||||
</ElButton>
|
||||
|
||||
<ElButton
|
||||
v-if="props.plugin.is_installed && props.plugin.is_enabled"
|
||||
type="danger"
|
||||
plain
|
||||
:loading="isActionLoading('disable')"
|
||||
@click="triggerAction('disable')"
|
||||
>
|
||||
禁用
|
||||
</ElButton>
|
||||
|
||||
<ElButton
|
||||
v-if="props.plugin.need_upgrade"
|
||||
type="warning"
|
||||
plain
|
||||
:loading="isActionLoading('upgrade')"
|
||||
@click="triggerAction('upgrade')"
|
||||
>
|
||||
升级
|
||||
</ElButton>
|
||||
|
||||
<ElButton
|
||||
v-if="props.plugin.is_installed && !props.plugin.is_enabled"
|
||||
plain
|
||||
:disabled="props.plugin.is_protected"
|
||||
:loading="isActionLoading('uninstall')"
|
||||
@click="triggerAction('uninstall')"
|
||||
>
|
||||
卸载
|
||||
</ElButton>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss" src="./PluginCard.scss"></style>
|
||||
@@ -0,0 +1,167 @@
|
||||
.drawer-header {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.drawer-copy {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: var(--xboard-text-muted);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: var(--xboard-text-strong);
|
||||
font-size: 34px;
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.28px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--xboard-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.drawer-shell {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.overview-card,
|
||||
.panel-card {
|
||||
border-radius: 24px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--xboard-shadow);
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
padding: 20px;
|
||||
|
||||
article {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: #f5f5f7;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--xboard-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--xboard-text-strong);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tab-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
padding: 11px 16px;
|
||||
color: var(--xboard-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.18s ease, background-color 0.18s ease, color 0.18s ease;
|
||||
|
||||
&.active {
|
||||
color: #0071e3;
|
||||
border-color: rgba(0, 113, 227, 0.2);
|
||||
background: rgba(0, 113, 227, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.panel-card {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.markdown-shell {
|
||||
color: var(--xboard-text-secondary);
|
||||
line-height: 1.7;
|
||||
|
||||
:deep(h1),
|
||||
:deep(h2),
|
||||
:deep(h3) {
|
||||
color: var(--xboard-text-strong);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
:deep(pre) {
|
||||
overflow-x: auto;
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
background: #f5f5f7;
|
||||
}
|
||||
|
||||
:deep(code) {
|
||||
font-family: var(--xboard-font-mono);
|
||||
}
|
||||
}
|
||||
|
||||
.config-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.config-field.is-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.field-number,
|
||||
.field-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field-helper {
|
||||
margin-top: 8px;
|
||||
color: var(--xboard-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.overview-card,
|
||||
.config-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tab-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Document, Setting } from '@element-plus/icons-vue'
|
||||
import type { AdminPluginItem } from '@/types/api'
|
||||
import {
|
||||
createPluginConfigDraft,
|
||||
getPluginConfigFields,
|
||||
getPluginStatusMeta,
|
||||
getPluginTypeLabel,
|
||||
hasPluginConfig,
|
||||
hasPluginReadme,
|
||||
renderPluginReadme,
|
||||
serializePluginConfigDraft,
|
||||
type PluginConfigDraft,
|
||||
} from '@/utils/plugins'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
plugin: AdminPluginItem | null
|
||||
loading?: boolean
|
||||
saving?: boolean
|
||||
typeLabels?: Array<{ value: string; label: string }>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
saveConfig: [value: Record<string, unknown>]
|
||||
}>()
|
||||
|
||||
const activeTab = reactive<{ value: 'readme' | 'config' }>({ value: 'readme' })
|
||||
const configDraft = reactive<PluginConfigDraft>({})
|
||||
|
||||
const statusMeta = computed(() => props.plugin ? getPluginStatusMeta(props.plugin) : null)
|
||||
const pluginTypeLabel = computed(() => props.plugin
|
||||
? getPluginTypeLabel(props.plugin.type, props.typeLabels || [])
|
||||
: '未知类型')
|
||||
const configFields = computed(() => getPluginConfigFields(props.plugin))
|
||||
const readmeHtml = computed(() => renderPluginReadme(props.plugin?.readme || ''))
|
||||
const readmeAvailable = computed(() => hasPluginReadme(props.plugin))
|
||||
const configAvailable = computed(() => hasPluginConfig(props.plugin))
|
||||
const canEditConfig = computed(() => Boolean(props.plugin?.is_installed && configAvailable.value))
|
||||
|
||||
function resetDraft() {
|
||||
Object.keys(configDraft).forEach((key) => {
|
||||
delete configDraft[key]
|
||||
})
|
||||
|
||||
const nextDraft = createPluginConfigDraft(props.plugin)
|
||||
Object.entries(nextDraft).forEach(([key, value]) => {
|
||||
configDraft[key] = value
|
||||
})
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!props.plugin || !configAvailable.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
emit('saveConfig', serializePluginConfigDraft(props.plugin, configDraft))
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '插件配置保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.visible, props.plugin?.code, props.plugin?.is_installed, props.plugin?.config] as const,
|
||||
() => {
|
||||
activeTab.value = readmeAvailable.value ? 'readme' : 'config'
|
||||
resetDraft()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDrawer
|
||||
:model-value="props.visible"
|
||||
size="min(620px, 100vw)"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
class="plugin-detail-drawer"
|
||||
@close="emit('update:visible', false)"
|
||||
@update:model-value="emit('update:visible', $event)"
|
||||
>
|
||||
<template #header>
|
||||
<div class="drawer-header">
|
||||
<div class="drawer-copy">
|
||||
<p>Plugin Workspace</p>
|
||||
<h2>{{ props.plugin?.name || '插件详情' }}</h2>
|
||||
<span>{{ props.plugin?.description || '查看插件说明、状态与配置。' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="props.plugin" class="drawer-meta">
|
||||
<ElTag round effect="plain">{{ pluginTypeLabel }}</ElTag>
|
||||
<ElTag round :type="statusMeta?.tone || undefined">{{ statusMeta?.label }}</ElTag>
|
||||
<ElTag v-if="props.plugin.is_protected" round type="warning">核心插件</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="drawer-shell" v-loading="props.loading">
|
||||
<template v-if="props.plugin">
|
||||
<section class="overview-card">
|
||||
<article>
|
||||
<span>插件代号</span>
|
||||
<strong>{{ props.plugin.code }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>当前版本</span>
|
||||
<strong>v{{ props.plugin.version }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>作者</span>
|
||||
<strong>{{ props.plugin.author || '未知作者' }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>状态说明</span>
|
||||
<strong>{{ statusMeta?.helper }}</strong>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div class="tab-row">
|
||||
<button
|
||||
type="button"
|
||||
class="tab-pill"
|
||||
:class="{ active: activeTab.value === 'readme' }"
|
||||
@click="activeTab.value = 'readme'"
|
||||
>
|
||||
<ElIcon><Document /></ElIcon>
|
||||
说明文档
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="tab-pill"
|
||||
:class="{ active: activeTab.value === 'config' }"
|
||||
@click="activeTab.value = 'config'"
|
||||
>
|
||||
<ElIcon><Setting /></ElIcon>
|
||||
插件配置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section v-if="activeTab.value === 'readme'" class="panel-card">
|
||||
<div v-if="readmeAvailable" class="markdown-shell markdown-body" v-html="readmeHtml" />
|
||||
<ElEmpty v-else description="当前插件未提供 README 说明" />
|
||||
</section>
|
||||
|
||||
<section v-else class="panel-card">
|
||||
<ElAlert
|
||||
v-if="!props.plugin.is_installed"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
title="安装后才可保存配置,当前先展示配置结构预览。"
|
||||
class="config-alert"
|
||||
/>
|
||||
|
||||
<ElForm v-if="configAvailable" label-position="top" class="config-form">
|
||||
<div class="config-grid">
|
||||
<div
|
||||
v-for="field in configFields"
|
||||
:key="field.key"
|
||||
class="config-field"
|
||||
:class="{ 'is-wide': field.type === 'text' || field.type === 'json' }"
|
||||
>
|
||||
<ElFormItem :label="field.label || field.key">
|
||||
<ElSwitch
|
||||
v-if="field.type === 'boolean'"
|
||||
:model-value="Boolean(configDraft[field.key])"
|
||||
:disabled="!canEditConfig || props.saving"
|
||||
@update:model-value="configDraft[field.key] = $event"
|
||||
/>
|
||||
|
||||
<ElInputNumber
|
||||
v-else-if="field.type === 'number'"
|
||||
:model-value="Number(configDraft[field.key] ?? 0)"
|
||||
:disabled="!canEditConfig || props.saving"
|
||||
controls-position="right"
|
||||
class="field-number"
|
||||
@update:model-value="configDraft[field.key] = $event ?? 0"
|
||||
/>
|
||||
|
||||
<ElSelect
|
||||
v-else-if="field.type === 'select'"
|
||||
:model-value="configDraft[field.key]"
|
||||
:disabled="!canEditConfig || props.saving"
|
||||
class="field-select"
|
||||
@update:model-value="configDraft[field.key] = $event as string | number | boolean"
|
||||
>
|
||||
<ElOption
|
||||
v-for="option in field.options"
|
||||
:key="`${field.key}-${option.value}`"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
|
||||
<ElInput
|
||||
v-else-if="field.type === 'text' || field.type === 'json'"
|
||||
:model-value="String(configDraft[field.key] ?? '')"
|
||||
type="textarea"
|
||||
:rows="field.type === 'json' ? 7 : 4"
|
||||
:placeholder="field.placeholder"
|
||||
:disabled="!canEditConfig || props.saving"
|
||||
@update:model-value="configDraft[field.key] = $event"
|
||||
/>
|
||||
|
||||
<ElInput
|
||||
v-else
|
||||
:model-value="String(configDraft[field.key] ?? '')"
|
||||
:placeholder="field.placeholder"
|
||||
:disabled="!canEditConfig || props.saving"
|
||||
clearable
|
||||
@update:model-value="configDraft[field.key] = $event"
|
||||
/>
|
||||
|
||||
<p v-if="field.description" class="field-helper">
|
||||
{{ field.description }}
|
||||
</p>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
</div>
|
||||
</ElForm>
|
||||
|
||||
<ElEmpty v-else description="当前插件没有可编辑配置项" />
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<ElButton @click="emit('update:visible', false)">关闭</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:disabled="!canEditConfig"
|
||||
:loading="props.saving"
|
||||
@click="handleSave"
|
||||
>
|
||||
保存配置
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDrawer>
|
||||
</template>
|
||||
<style scoped lang="scss" src="./PluginDetailDrawer.scss"></style>
|
||||
@@ -0,0 +1,178 @@
|
||||
.plugins-page {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.plugins-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 34px;
|
||||
border-radius: 28px;
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
max-width: 640px;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
color: #ffffff;
|
||||
font-size: clamp(34px, 5vw, 52px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.28px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-kicker {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
min-width: 360px;
|
||||
|
||||
article {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
span {
|
||||
color: rgba(255, 255, 255, 0.64);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #ffffff;
|
||||
font-size: 22px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-shell,
|
||||
.empty-shell {
|
||||
border-radius: 24px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--xboard-shadow);
|
||||
}
|
||||
|
||||
.toolbar-shell {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px 22px;
|
||||
}
|
||||
|
||||
.toolbar-main,
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.toolbar-main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toolbar-search {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.plugin-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 999px;
|
||||
background: #f5f5f7;
|
||||
color: var(--xboard-text-secondary);
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.18s ease, background-color 0.18s ease, color 0.18s ease;
|
||||
|
||||
&.active {
|
||||
color: #0071e3;
|
||||
border-color: rgba(0, 113, 227, 0.22);
|
||||
background: rgba(0, 113, 227, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.status-select {
|
||||
width: 168px;
|
||||
}
|
||||
|
||||
.page-alert {
|
||||
margin-bottom: -6px;
|
||||
}
|
||||
|
||||
.plugin-grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.plugin-grid--loading {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.plugin-card--skeleton {
|
||||
border-radius: 24px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--xboard-shadow);
|
||||
min-height: 240px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.empty-shell {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 14px;
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.plugins-hero,
|
||||
.toolbar-shell {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolbar-main,
|
||||
.toolbar-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.hero-stats,
|
||||
.plugin-grid--loading {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.toolbar-search,
|
||||
.status-select {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { UploadRequestOptions } from 'element-plus'
|
||||
import { RefreshRight, Search, UploadFilled } from '@element-plus/icons-vue'
|
||||
import {
|
||||
disablePlugin,
|
||||
enablePlugin,
|
||||
getPluginConfig,
|
||||
getPlugins,
|
||||
getPluginTypes,
|
||||
installPlugin,
|
||||
savePluginConfig,
|
||||
uninstallPlugin,
|
||||
upgradePlugin,
|
||||
uploadPluginPackage,
|
||||
} from '@/api/admin'
|
||||
import type {
|
||||
AdminPluginConfigField,
|
||||
AdminPluginItem,
|
||||
AdminPluginTypeItem,
|
||||
} from '@/types/api'
|
||||
import PluginCard from './PluginCard.vue'
|
||||
import PluginDetailDrawer from './PluginDetailDrawer.vue'
|
||||
import {
|
||||
buildPluginTabs,
|
||||
countEnabledPlugins,
|
||||
countUpgradeablePlugins,
|
||||
countUserPlugins,
|
||||
filterPlugins,
|
||||
hasPluginConfig,
|
||||
type PluginStatusFilter,
|
||||
type PluginTabValue,
|
||||
PLUGIN_STATUS_FILTER_OPTIONS,
|
||||
} from '@/utils/plugins'
|
||||
|
||||
type PluginAction = 'install' | 'enable' | 'disable' | 'upgrade' | 'uninstall'
|
||||
type UploadError = Parameters<UploadRequestOptions['onError']>[0]
|
||||
|
||||
const loading = ref(true)
|
||||
const reloading = ref(false)
|
||||
const uploadLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const keyword = ref('')
|
||||
const typeFilter = ref<PluginTabValue>('all')
|
||||
const statusFilter = ref<PluginStatusFilter>('all')
|
||||
|
||||
const pluginTypes = ref<AdminPluginTypeItem[]>([])
|
||||
const plugins = ref<AdminPluginItem[]>([])
|
||||
const actionLoadingMap = ref<Record<string, boolean>>({})
|
||||
|
||||
const drawerVisible = ref(false)
|
||||
const drawerLoading = ref(false)
|
||||
const drawerSaving = ref(false)
|
||||
const activePlugin = ref<AdminPluginItem | null>(null)
|
||||
|
||||
const tabs = computed(() => buildPluginTabs(pluginTypes.value))
|
||||
const filteredPlugins = computed(() => filterPlugins(plugins.value, keyword.value, statusFilter.value))
|
||||
const heroStats = computed(() => [
|
||||
{ label: '插件总数', value: String(plugins.value.length) },
|
||||
{ label: '已启用', value: String(countEnabledPlugins(plugins.value)) },
|
||||
{ label: '可升级', value: String(countUpgradeablePlugins(plugins.value)) },
|
||||
{ label: '用户上传', value: String(countUserPlugins(plugins.value)) },
|
||||
])
|
||||
|
||||
function getActionKey(code: string, action: PluginAction): string {
|
||||
return `${code}:${action}`
|
||||
}
|
||||
|
||||
async function loadPluginTypes() {
|
||||
const response = await getPluginTypes()
|
||||
pluginTypes.value = response.data ?? []
|
||||
}
|
||||
|
||||
async function syncActivePlugin(code?: string, refreshConfig = false) {
|
||||
const targetCode = code ?? activePlugin.value?.code
|
||||
if (!targetCode) return
|
||||
|
||||
const latest = plugins.value.find((item) => item.code === targetCode)
|
||||
if (!latest) {
|
||||
activePlugin.value = null
|
||||
drawerVisible.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (refreshConfig && latest.is_installed && hasPluginConfig(latest)) {
|
||||
const configResponse = await getPluginConfig(latest.code)
|
||||
activePlugin.value = {
|
||||
...latest,
|
||||
config: configResponse.data as Record<string, AdminPluginConfigField>,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
activePlugin.value = latest
|
||||
}
|
||||
|
||||
async function loadPlugins(mode: 'initial' | 'reload' = 'initial') {
|
||||
if (mode === 'initial') {
|
||||
loading.value = true
|
||||
} else {
|
||||
reloading.value = true
|
||||
}
|
||||
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await getPlugins(typeFilter.value === 'all' ? {} : { type: typeFilter.value })
|
||||
plugins.value = response.data ?? []
|
||||
await syncActivePlugin(undefined, drawerVisible.value && Boolean(activePlugin.value?.is_installed))
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '插件列表加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
reloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrapPage() {
|
||||
try {
|
||||
await loadPluginTypes()
|
||||
} catch (error) {
|
||||
ElMessage.warning(error instanceof Error ? error.message : '插件类型加载失败,将回退到默认文案')
|
||||
}
|
||||
|
||||
await loadPlugins()
|
||||
}
|
||||
|
||||
async function openDetail(plugin: AdminPluginItem) {
|
||||
drawerVisible.value = true
|
||||
drawerLoading.value = true
|
||||
activePlugin.value = plugin
|
||||
|
||||
try {
|
||||
if (plugin.is_installed && hasPluginConfig(plugin)) {
|
||||
const response = await getPluginConfig(plugin.code)
|
||||
activePlugin.value = {
|
||||
...plugin,
|
||||
config: response.data as Record<string, AdminPluginConfigField>,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
activePlugin.value = plugin
|
||||
} catch (error) {
|
||||
activePlugin.value = plugin
|
||||
ElMessage.warning(error instanceof Error ? error.message : '插件配置读取失败,已展示列表快照')
|
||||
} finally {
|
||||
drawerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runPluginAction(plugin: AdminPluginItem, action: PluginAction) {
|
||||
const key = getActionKey(plugin.code, action)
|
||||
actionLoadingMap.value[key] = true
|
||||
|
||||
try {
|
||||
if (action === 'install') {
|
||||
await installPlugin(plugin.code)
|
||||
ElMessage.success(`已安装 ${plugin.name}`)
|
||||
}
|
||||
|
||||
if (action === 'enable') {
|
||||
await enablePlugin(plugin.code)
|
||||
ElMessage.success(`已启用 ${plugin.name}`)
|
||||
}
|
||||
|
||||
if (action === 'disable') {
|
||||
await disablePlugin(plugin.code)
|
||||
ElMessage.success(`已禁用 ${plugin.name}`)
|
||||
}
|
||||
|
||||
if (action === 'upgrade') {
|
||||
await upgradePlugin(plugin.code)
|
||||
ElMessage.success(`已升级 ${plugin.name}`)
|
||||
}
|
||||
|
||||
if (action === 'uninstall') {
|
||||
await ElMessageBox.confirm(`卸载插件「${plugin.name}」后,将移除其当前安装状态。确认继续吗?`, '卸载插件', {
|
||||
type: 'warning',
|
||||
})
|
||||
await uninstallPlugin(plugin.code)
|
||||
ElMessage.success(`已卸载 ${plugin.name}`)
|
||||
}
|
||||
|
||||
await loadPlugins('reload')
|
||||
await syncActivePlugin(plugin.code, drawerVisible.value)
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') {
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.error(error instanceof Error ? error.message : '插件操作失败')
|
||||
} finally {
|
||||
actionLoadingMap.value[key] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveConfig(payload: Record<string, unknown>) {
|
||||
if (!activePlugin.value) return
|
||||
|
||||
drawerSaving.value = true
|
||||
try {
|
||||
await savePluginConfig(activePlugin.value.code, payload)
|
||||
ElMessage.success('插件配置已保存')
|
||||
await loadPlugins('reload')
|
||||
await syncActivePlugin(activePlugin.value.code, true)
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '插件配置保存失败')
|
||||
} finally {
|
||||
drawerSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUploadRequest(options: UploadRequestOptions) {
|
||||
uploadLoading.value = true
|
||||
try {
|
||||
await uploadPluginPackage(options.file as File)
|
||||
options.onSuccess?.({ success: true })
|
||||
ElMessage.success('插件上传成功')
|
||||
typeFilter.value = 'all'
|
||||
await loadPlugins('reload')
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '插件上传失败'
|
||||
options.onError?.(Object.assign(new Error(message), {
|
||||
status: 500,
|
||||
method: 'POST',
|
||||
url: '/plugin/upload',
|
||||
}) as UploadError)
|
||||
ElMessage.error(message)
|
||||
} finally {
|
||||
uploadLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(typeFilter, () => {
|
||||
void loadPlugins('reload')
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void bootstrapPage()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="plugins-page">
|
||||
<section class="plugins-hero">
|
||||
<div class="hero-copy">
|
||||
<p class="hero-kicker">System Management</p>
|
||||
<h1>插件管理。</h1>
|
||||
<span>
|
||||
在同一个工作台里查看插件状态、执行安装 / 启停 / 升级动作,并补齐 README 与动态配置编辑。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="hero-stats">
|
||||
<article v-for="item in heroStats" :key="item.label">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="toolbar-shell">
|
||||
<div class="toolbar-main">
|
||||
<ElInput
|
||||
v-model="keyword"
|
||||
class="toolbar-search"
|
||||
clearable
|
||||
placeholder="搜索插件名称、代号或描述..."
|
||||
>
|
||||
<template #prefix>
|
||||
<ElIcon><Search /></ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
|
||||
<div class="plugin-tabs">
|
||||
<button
|
||||
v-for="item in tabs"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
class="tab-button"
|
||||
:class="{ active: item.value === typeFilter }"
|
||||
@click="typeFilter = item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-actions">
|
||||
<ElButton :loading="reloading" @click="loadPlugins('reload')">
|
||||
<ElIcon><RefreshRight /></ElIcon>
|
||||
刷新列表
|
||||
</ElButton>
|
||||
|
||||
<ElSelect v-model="statusFilter" class="status-select">
|
||||
<ElOption
|
||||
v-for="item in PLUGIN_STATUS_FILTER_OPTIONS"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
|
||||
<ElUpload
|
||||
:show-file-list="false"
|
||||
accept=".zip,application/zip"
|
||||
:http-request="handleUploadRequest"
|
||||
>
|
||||
<ElButton type="primary" :loading="uploadLoading">
|
||||
<ElIcon><UploadFilled /></ElIcon>
|
||||
上传插件
|
||||
</ElButton>
|
||||
</ElUpload>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ElAlert
|
||||
v-if="errorMessage"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
:title="errorMessage"
|
||||
class="page-alert"
|
||||
>
|
||||
<template #default>
|
||||
<ElButton size="small" @click="loadPlugins('reload')">重新加载</ElButton>
|
||||
</template>
|
||||
</ElAlert>
|
||||
|
||||
<section v-if="loading" class="plugin-grid plugin-grid--loading">
|
||||
<article v-for="index in 3" :key="index" class="plugin-card plugin-card--skeleton">
|
||||
<ElSkeleton animated :rows="5" />
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section v-else-if="filteredPlugins.length" class="plugin-grid">
|
||||
<PluginCard
|
||||
v-for="plugin in filteredPlugins"
|
||||
:key="plugin.code"
|
||||
:plugin="plugin"
|
||||
:type-labels="pluginTypes"
|
||||
:action-loading-map="actionLoadingMap"
|
||||
@detail="openDetail"
|
||||
@action="runPluginAction($event.plugin, $event.action)"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section v-else class="empty-shell">
|
||||
<ElEmpty description="当前筛选条件下暂无插件" />
|
||||
<ElButton @click="statusFilter = 'all'">重置状态筛选</ElButton>
|
||||
</section>
|
||||
|
||||
<PluginDetailDrawer
|
||||
v-model:visible="drawerVisible"
|
||||
:plugin="activePlugin"
|
||||
:loading="drawerLoading"
|
||||
:saving="drawerSaving"
|
||||
:type-labels="pluginTypes"
|
||||
@save-config="handleSaveConfig"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss" src="./PluginManagementView.scss"></style>
|
||||
@@ -0,0 +1,203 @@
|
||||
.knowledge-page {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.knowledge-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 32px;
|
||||
border-radius: 28px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--xboard-shadow);
|
||||
}
|
||||
|
||||
.knowledge-copy {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.knowledge-kicker {
|
||||
color: var(--xboard-text-muted);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.knowledge-copy h1 {
|
||||
font-size: clamp(34px, 5vw, 52px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.28px;
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.knowledge-copy span {
|
||||
color: var(--xboard-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 160px));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hero-stats article {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
background: #fbfbfd;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.hero-stats span {
|
||||
color: var(--xboard-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hero-stats strong {
|
||||
color: var(--xboard-text-strong);
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.table-shell {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 24px;
|
||||
border-radius: 26px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--xboard-shadow);
|
||||
}
|
||||
|
||||
.table-toolbar,
|
||||
.toolbar-left,
|
||||
.toolbar-right,
|
||||
.table-footer,
|
||||
.action-group,
|
||||
.sort-item,
|
||||
.sort-item__main,
|
||||
.sort-actions,
|
||||
.sort-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.table-toolbar,
|
||||
.table-footer,
|
||||
.sort-footer {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-search {
|
||||
width: min(320px, 100%);
|
||||
}
|
||||
|
||||
.toolbar-filter {
|
||||
width: 172px;
|
||||
}
|
||||
|
||||
.table-alert {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.knowledge-table :deep(th.el-table__cell) {
|
||||
color: var(--xboard-text-secondary);
|
||||
background: #fbfbfd;
|
||||
}
|
||||
|
||||
.knowledge-table :deep(.el-table__row td.el-table__cell) {
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.title-cell,
|
||||
.sort-shell,
|
||||
.sort-list,
|
||||
.sort-meta {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.title-cell strong,
|
||||
.sort-meta strong {
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.title-cell span,
|
||||
.table-footer span,
|
||||
.sort-copy,
|
||||
.sort-meta span {
|
||||
color: var(--xboard-text-muted);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.danger-btn {
|
||||
color: var(--xboard-danger);
|
||||
}
|
||||
|
||||
.sort-copy {
|
||||
line-height: 1.47;
|
||||
}
|
||||
|
||||
.sort-item {
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
background: #fbfbfd;
|
||||
}
|
||||
|
||||
.sort-index {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(0, 113, 227, 0.08);
|
||||
color: #0071e3;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.knowledge-hero,
|
||||
.table-toolbar,
|
||||
.table-footer,
|
||||
.sort-item,
|
||||
.sort-item__main,
|
||||
.sort-actions,
|
||||
.sort-footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.table-shell,
|
||||
.knowledge-hero {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.toolbar-filter {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import SystemPlaceholderView from './SystemPlaceholderView.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SystemPlaceholderView />
|
||||
</template>
|
||||
@@ -0,0 +1,190 @@
|
||||
.dialog-shell,
|
||||
.dialog-form {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.dialog-copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dialog-copy p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--xboard-text-muted);
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dialog-copy h2 {
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
line-height: 1.08;
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.dialog-copy span {
|
||||
color: var(--xboard-text-secondary);
|
||||
line-height: 1.47;
|
||||
}
|
||||
|
||||
.dialog-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px 16px;
|
||||
}
|
||||
|
||||
.is-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
border-radius: 20px;
|
||||
border: 1px dashed rgba(0, 0, 0, 0.08);
|
||||
background: #fbfbfd;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.section-header span {
|
||||
color: var(--xboard-text-muted);
|
||||
line-height: 1.47;
|
||||
}
|
||||
|
||||
.section-actions,
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editor-toolbar button {
|
||||
min-width: 40px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
background: #ffffff;
|
||||
color: var(--xboard-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.editor-toolbar button:hover {
|
||||
color: #0071e3;
|
||||
border-color: rgba(0, 113, 227, 0.24);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.editor-field :deep(.el-form-item__content) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.content-editor,
|
||||
.content-preview {
|
||||
min-height: 260px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.content-editor {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
color: var(--xboard-text-strong);
|
||||
font: inherit;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.content-preview {
|
||||
padding: 18px;
|
||||
overflow: auto;
|
||||
color: var(--xboard-text-secondary);
|
||||
}
|
||||
|
||||
.markdown-body :deep(p),
|
||||
.markdown-body :deep(ul),
|
||||
.markdown-body :deep(ol),
|
||||
.markdown-body :deep(blockquote) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tag-input-shell,
|
||||
.switch-panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.switch-panel {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.switch-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
background: #fbfbfd;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.switch-card strong {
|
||||
display: block;
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.switch-card span {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: var(--xboard-text-muted);
|
||||
line-height: 1.47;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.dialog-grid,
|
||||
.switch-panel,
|
||||
.section-header {
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-actions,
|
||||
.dialog-footer {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { saveNotice } from '@/api/admin'
|
||||
import type { AdminNoticeItem } from '@/types/api'
|
||||
import {
|
||||
createEmptyNoticeForm,
|
||||
normalizeNoticeTag,
|
||||
renderNoticeContent,
|
||||
toNoticeFormModel,
|
||||
toNoticeSavePayload,
|
||||
type NoticeFormModel,
|
||||
} from '@/utils/notices'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
mode: 'create' | 'edit'
|
||||
notice?: AdminNoticeItem | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
success: [message: string]
|
||||
}>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const previewVisible = ref(false)
|
||||
const tagInput = ref('')
|
||||
const contentEditorRef = ref<HTMLTextAreaElement | null>(null)
|
||||
const form = reactive<NoticeFormModel>(createEmptyNoticeForm())
|
||||
|
||||
const dialogTitle = computed(() => props.mode === 'create' ? '添加公告' : '编辑公告')
|
||||
const renderedContent = computed(() => renderNoticeContent(form.content))
|
||||
|
||||
function validateImageUrl(_rule: unknown, value: string, callback: (error?: Error) => void) {
|
||||
if (!value.trim()) {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(value)
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
callback(new Error('公告背景必须使用 http 或 https 链接'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
} catch {
|
||||
callback(new Error('公告背景链接格式不正确'))
|
||||
}
|
||||
}
|
||||
|
||||
const rules = computed<FormRules<NoticeFormModel>>(() => ({
|
||||
title: [{ required: true, message: '请输入公告标题', trigger: 'blur' }],
|
||||
content: [{ required: true, message: '请输入公告内容', trigger: 'blur' }],
|
||||
imgUrl: [{ validator: validateImageUrl, trigger: 'blur' }],
|
||||
}))
|
||||
|
||||
function closeDialog() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
function syncForm() {
|
||||
Object.assign(form, toNoticeFormModel(props.notice))
|
||||
tagInput.value = ''
|
||||
previewVisible.value = false
|
||||
}
|
||||
|
||||
function handleTagConfirm() {
|
||||
const nextTag = normalizeNoticeTag(tagInput.value)
|
||||
if (!nextTag) {
|
||||
tagInput.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (!form.tags.includes(nextTag)) {
|
||||
form.tags.push(nextTag)
|
||||
}
|
||||
|
||||
tagInput.value = ''
|
||||
}
|
||||
|
||||
function removeTag(tag: string) {
|
||||
form.tags = form.tags.filter((item) => item !== tag)
|
||||
}
|
||||
|
||||
function insertSnippet(prefix: string, suffix = '', placeholder = '内容') {
|
||||
const textarea = contentEditorRef.value
|
||||
if (!textarea) {
|
||||
form.content = `${form.content}${prefix}${placeholder}${suffix}`
|
||||
return
|
||||
}
|
||||
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = form.content.slice(start, end) || placeholder
|
||||
form.content = `${form.content.slice(0, start)}${prefix}${selected}${suffix}${form.content.slice(end)}`
|
||||
|
||||
nextTick(() => {
|
||||
textarea.focus()
|
||||
const cursor = start + prefix.length + selected.length + suffix.length
|
||||
textarea.setSelectionRange(cursor, cursor)
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const instance = formRef.value
|
||||
if (!instance) {
|
||||
return
|
||||
}
|
||||
|
||||
const valid = await instance.validate().catch(() => false)
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await saveNotice(toNoticeSavePayload(form))
|
||||
const message = props.mode === 'create' ? '公告已创建' : '公告已更新'
|
||||
ElMessage.success(message)
|
||||
emit('success', message)
|
||||
closeDialog()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '公告保存失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.visible, props.notice, props.mode],
|
||||
([visible]) => {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
|
||||
syncForm()
|
||||
nextTick(() => {
|
||||
formRef.value?.clearValidate()
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDialog
|
||||
:model-value="props.visible"
|
||||
:title="dialogTitle"
|
||||
width="min(820px, calc(100vw - 32px))"
|
||||
destroy-on-close
|
||||
class="notice-editor-dialog"
|
||||
@close="closeDialog"
|
||||
@update:model-value="emit('update:visible', $event)"
|
||||
>
|
||||
<div class="dialog-shell">
|
||||
<div class="dialog-copy">
|
||||
<p>系统管理</p>
|
||||
<h2>{{ dialogTitle }}</h2>
|
||||
<span>发布或编辑系统公告,支持标题、内容、背景图、标签与显隐状态维护。</span>
|
||||
</div>
|
||||
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
class="dialog-form"
|
||||
>
|
||||
<div class="dialog-grid">
|
||||
<ElFormItem label="标题" prop="title" class="is-full">
|
||||
<ElInput v-model="form.title" placeholder="请输入公告标题" maxlength="120" show-word-limit />
|
||||
</ElFormItem>
|
||||
</div>
|
||||
|
||||
<section class="editor-panel">
|
||||
<header class="section-header">
|
||||
<div>
|
||||
<h3>公告内容</h3>
|
||||
<span>支持 Markdown 与基础 HTML 换行,优先保证内容表达清晰。</span>
|
||||
</div>
|
||||
|
||||
<div class="section-actions">
|
||||
<ElButton @click="previewVisible = !previewVisible">
|
||||
{{ previewVisible ? '继续编辑' : '预览公告' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="editor-toolbar">
|
||||
<button type="button" @click="insertSnippet('**', '**', '加粗文本')">B</button>
|
||||
<button type="button" @click="insertSnippet('*', '*', '斜体文本')">I</button>
|
||||
<button type="button" @click="insertSnippet('<u>', '</u>', '下划线文本')">U</button>
|
||||
<button type="button" @click="insertSnippet('- ', '', '列表项')">列表</button>
|
||||
<button type="button" @click="insertSnippet('> ', '', '引用内容')">引用</button>
|
||||
<button type="button" @click="insertSnippet('`', '`', '代码')">代码</button>
|
||||
<button type="button" @click="insertSnippet('[', '](https://)', '链接文本')">链接</button>
|
||||
<button type="button" @click="insertSnippet('<br>', '', '')">换行</button>
|
||||
</div>
|
||||
|
||||
<ElFormItem prop="content" class="editor-field">
|
||||
<div v-if="previewVisible" class="content-preview markdown-body" v-html="renderedContent" />
|
||||
<textarea
|
||||
v-else
|
||||
ref="contentEditorRef"
|
||||
v-model="form.content"
|
||||
class="content-editor"
|
||||
placeholder="请输入公告内容,支持 Markdown 或 <br> 换行"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</section>
|
||||
|
||||
<div class="dialog-grid">
|
||||
<ElFormItem label="公告背景" prop="imgUrl" class="is-full">
|
||||
<ElInput
|
||||
v-model="form.imgUrl"
|
||||
placeholder="https://example.com/cover.png"
|
||||
clearable
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="节点标签" class="is-full">
|
||||
<div class="tag-input-shell">
|
||||
<div v-if="form.tags.length" class="tag-list">
|
||||
<ElTag
|
||||
v-for="tag in form.tags"
|
||||
:key="tag"
|
||||
closable
|
||||
effect="plain"
|
||||
round
|
||||
@close="removeTag(tag)"
|
||||
>
|
||||
{{ tag }}
|
||||
</ElTag>
|
||||
</div>
|
||||
|
||||
<ElInput
|
||||
v-model="tagInput"
|
||||
placeholder="输入后回车添加标签"
|
||||
@keyup.enter.prevent="handleTagConfirm"
|
||||
@blur="handleTagConfirm"
|
||||
/>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
|
||||
<section class="switch-panel">
|
||||
<article class="switch-card">
|
||||
<div>
|
||||
<strong>显示</strong>
|
||||
<span>关闭后公告仍保留,但不会在前台继续展示。</span>
|
||||
</div>
|
||||
<ElSwitch v-model="form.show" />
|
||||
</article>
|
||||
|
||||
<article class="switch-card">
|
||||
<div>
|
||||
<strong>弹窗公告</strong>
|
||||
<span>开启后公告会以弹窗优先展示,适合重要通知或紧急维护提示。</span>
|
||||
</div>
|
||||
<ElSwitch v-model="form.popup" />
|
||||
</article>
|
||||
</section>
|
||||
</ElForm>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<ElButton @click="closeDialog">取消</ElButton>
|
||||
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
|
||||
{{ props.mode === 'create' ? '提交' : '保存修改' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss" src="./SystemNoticeEditorDialog.scss"></style>
|
||||
@@ -0,0 +1,249 @@
|
||||
.notices-page {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.notices-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 34px;
|
||||
border-radius: 28px;
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
max-width: 620px;
|
||||
}
|
||||
|
||||
.hero-kicker {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-copy h1 {
|
||||
margin: 0;
|
||||
color: #ffffff;
|
||||
font-size: clamp(34px, 5vw, 52px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.28px;
|
||||
}
|
||||
|
||||
.hero-copy span {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
min-width: 440px;
|
||||
}
|
||||
|
||||
.hero-stats article {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.hero-stats span {
|
||||
color: rgba(255, 255, 255, 0.64);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hero-stats strong {
|
||||
color: #ffffff;
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.table-shell {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 22px 22px 18px;
|
||||
border-radius: 24px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--xboard-shadow);
|
||||
}
|
||||
|
||||
.table-toolbar,
|
||||
.toolbar-left,
|
||||
.table-footer,
|
||||
.action-group,
|
||||
.sort-actions,
|
||||
.sort-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.table-toolbar,
|
||||
.table-footer {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.toolbar-search {
|
||||
width: min(280px, 100%);
|
||||
}
|
||||
|
||||
.notices-table :deep(.el-table__cell) {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.title-cell {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.title-main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.title-main strong {
|
||||
color: var(--xboard-text-strong);
|
||||
font-size: 16px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.title-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title-cell span {
|
||||
color: var(--xboard-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.title-cell small {
|
||||
color: var(--xboard-text-muted);
|
||||
}
|
||||
|
||||
.action-group {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
color: var(--xboard-text-secondary);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
color: #0071e3;
|
||||
background: rgba(0, 113, 227, 0.08);
|
||||
}
|
||||
|
||||
.danger-btn:hover {
|
||||
color: #d92d20;
|
||||
background: rgba(217, 45, 32, 0.08);
|
||||
}
|
||||
|
||||
.sort-shell {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sort-copy {
|
||||
margin: 0;
|
||||
color: var(--xboard-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.sort-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sort-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 18px;
|
||||
background: #fbfbfd;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.sort-item__main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sort-index {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 113, 227, 0.08);
|
||||
color: #0071e3;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sort-meta {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sort-meta strong {
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.sort-meta span {
|
||||
color: var(--xboard-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.notices-hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.hero-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.table-toolbar,
|
||||
.toolbar-left,
|
||||
.table-footer,
|
||||
.sort-item,
|
||||
.sort-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.sort-item__main {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Delete,
|
||||
EditPen,
|
||||
Plus,
|
||||
Search,
|
||||
} from '@element-plus/icons-vue'
|
||||
import {
|
||||
deleteNotice,
|
||||
fetchNotices,
|
||||
sortNotices,
|
||||
toggleNoticeVisibility,
|
||||
} from '@/api/admin'
|
||||
import type { AdminNoticeItem } from '@/types/api'
|
||||
import { formatDateTime } from '@/utils/dashboard'
|
||||
import {
|
||||
countEnabledNotices,
|
||||
filterNotices,
|
||||
moveNoticeOrder,
|
||||
normalizeNoticeItem,
|
||||
summarizeNoticeContent,
|
||||
} from '@/utils/notices'
|
||||
import SystemNoticeEditorDialog from './SystemNoticeEditorDialog.vue'
|
||||
|
||||
type EditorMode = 'create' | 'edit'
|
||||
|
||||
const loading = ref(false)
|
||||
const sortSubmitting = ref(false)
|
||||
const editorVisible = ref(false)
|
||||
const editorMode = ref<EditorMode>('create')
|
||||
const activeNotice = ref<AdminNoticeItem | null>(null)
|
||||
const sortDialogVisible = ref(false)
|
||||
const keyword = ref('')
|
||||
const current = ref(1)
|
||||
const pageSize = ref(50)
|
||||
|
||||
const notices = ref<AdminNoticeItem[]>([])
|
||||
const sortDraft = ref<AdminNoticeItem[]>([])
|
||||
const toggleLoadingMap = ref<Record<number, boolean>>({})
|
||||
|
||||
const filteredNotices = computed(() => filterNotices(notices.value, keyword.value))
|
||||
const visibleNotices = computed(() => {
|
||||
const start = (current.value - 1) * pageSize.value
|
||||
return filteredNotices.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
|
||||
const heroStats = computed(() => [
|
||||
{ label: '公告总数', value: String(notices.value.length) },
|
||||
{ label: '显示中', value: String(countEnabledNotices(notices.value, 'show')) },
|
||||
{ label: '弹窗公告', value: String(countEnabledNotices(notices.value, 'popup')) },
|
||||
{ label: '带标签', value: String(notices.value.filter((notice) => notice.tags?.length).length) },
|
||||
])
|
||||
|
||||
function isToggleLoading(id: number): boolean {
|
||||
return Boolean(toggleLoadingMap.value[id])
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await fetchNotices()
|
||||
notices.value = (response.data ?? []).map((notice) => normalizeNoticeItem(notice))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
editorMode.value = 'create'
|
||||
activeNotice.value = null
|
||||
editorVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(notice: AdminNoticeItem) {
|
||||
editorMode.value = 'edit'
|
||||
activeNotice.value = notice
|
||||
editorVisible.value = true
|
||||
}
|
||||
|
||||
async function handleToggle(notice: AdminNoticeItem, nextValue: boolean | string | number) {
|
||||
const normalizedNextValue = Boolean(nextValue)
|
||||
if (Boolean(notice.show) === normalizedNextValue) {
|
||||
return
|
||||
}
|
||||
|
||||
toggleLoadingMap.value[notice.id] = true
|
||||
try {
|
||||
await toggleNoticeVisibility(notice.id)
|
||||
notice.show = normalizedNextValue
|
||||
ElMessage.success('公告显示状态已更新')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '公告显示状态更新失败')
|
||||
} finally {
|
||||
toggleLoadingMap.value[notice.id] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(notice: AdminNoticeItem) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`删除公告「${notice.title}」后无法恢复,确认继续吗?`, '删除公告', {
|
||||
type: 'warning',
|
||||
})
|
||||
await deleteNotice(notice.id)
|
||||
ElMessage.success('公告已删除')
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') {
|
||||
return
|
||||
}
|
||||
ElMessage.error(error instanceof Error ? error.message : '公告删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function openSortEditor() {
|
||||
sortDraft.value = notices.value.map((notice) => ({ ...notice }))
|
||||
sortDialogVisible.value = true
|
||||
}
|
||||
|
||||
function moveDraft(index: number, direction: -1 | 1) {
|
||||
sortDraft.value = moveNoticeOrder(sortDraft.value, index, direction)
|
||||
}
|
||||
|
||||
async function submitSort() {
|
||||
sortSubmitting.value = true
|
||||
try {
|
||||
await sortNotices(sortDraft.value.map((item) => item.id))
|
||||
ElMessage.success('公告排序已保存')
|
||||
sortDialogVisible.value = false
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '公告排序保存失败')
|
||||
} finally {
|
||||
sortSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(keyword, () => {
|
||||
current.value = 1
|
||||
})
|
||||
|
||||
watch(filteredNotices, (list) => {
|
||||
const maxPage = Math.max(1, Math.ceil(list.length / pageSize.value))
|
||||
if (current.value > maxPage) {
|
||||
current.value = maxPage
|
||||
}
|
||||
})
|
||||
|
||||
watch(pageSize, () => {
|
||||
current.value = 1
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void loadData().catch((error) => {
|
||||
ElMessage.error(error instanceof Error ? error.message : '公告管理页面初始化失败')
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="notices-page">
|
||||
<section class="notices-hero">
|
||||
<div class="hero-copy">
|
||||
<p class="hero-kicker">System Management</p>
|
||||
<h1>公告管理</h1>
|
||||
<span>在这里可以配置公告,包括添加、删除、编辑、显隐切换与排序维护。</span>
|
||||
</div>
|
||||
|
||||
<div class="hero-stats">
|
||||
<article v-for="item in heroStats" :key="item.label">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="table-shell">
|
||||
<header class="table-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<ElButton type="primary" @click="openCreateDialog">
|
||||
<ElIcon><Plus /></ElIcon>
|
||||
添加公告
|
||||
</ElButton>
|
||||
|
||||
<ElInput
|
||||
v-model="keyword"
|
||||
clearable
|
||||
placeholder="搜索公告标题..."
|
||||
class="toolbar-search"
|
||||
>
|
||||
<template #prefix>
|
||||
<ElIcon><Search /></ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
</div>
|
||||
|
||||
<ElButton @click="openSortEditor">编辑排序</ElButton>
|
||||
</header>
|
||||
|
||||
<ElTable
|
||||
:data="visibleNotices"
|
||||
v-loading="loading"
|
||||
class="notices-table"
|
||||
row-key="id"
|
||||
empty-text="当前筛选条件下暂无公告"
|
||||
>
|
||||
<ElTableColumn prop="id" label="ID" width="88" />
|
||||
<ElTableColumn label="显示状态" width="112">
|
||||
<template #default="{ row }">
|
||||
<ElSwitch
|
||||
:model-value="Boolean(row.show)"
|
||||
:loading="isToggleLoading(row.id)"
|
||||
@change="handleToggle(row, $event)"
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="标题" min-width="560">
|
||||
<template #default="{ row }">
|
||||
<div class="title-cell">
|
||||
<div class="title-main">
|
||||
<strong>{{ row.title }}</strong>
|
||||
<div class="title-tags">
|
||||
<ElTag v-if="row.popup" type="warning" effect="plain" round>
|
||||
弹窗公告
|
||||
</ElTag>
|
||||
<ElTag
|
||||
v-for="tag in row.tags ?? []"
|
||||
:key="`${row.id}-${tag}`"
|
||||
effect="plain"
|
||||
round
|
||||
>
|
||||
{{ tag }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
<span>{{ summarizeNoticeContent(row.content) }}</span>
|
||||
<small>最近更新 {{ formatDateTime(row.updated_at) }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="110" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="action-group">
|
||||
<ElButton text class="action-btn" @click="openEditDialog(row)">
|
||||
<ElIcon><EditPen /></ElIcon>
|
||||
</ElButton>
|
||||
<ElButton text class="action-btn danger-btn" @click="handleDelete(row)">
|
||||
<ElIcon><Delete /></ElIcon>
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<footer class="table-footer">
|
||||
<span>已选择 0 项,共 {{ filteredNotices.length }} 项</span>
|
||||
<ElPagination
|
||||
v-model:current-page="current"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="sizes, prev, pager, next"
|
||||
:total="filteredNotices.length"
|
||||
background
|
||||
/>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<SystemNoticeEditorDialog
|
||||
v-model:visible="editorVisible"
|
||||
:mode="editorMode"
|
||||
:notice="activeNotice"
|
||||
@success="() => loadData()"
|
||||
/>
|
||||
|
||||
<ElDialog
|
||||
v-model="sortDialogVisible"
|
||||
width="min(640px, calc(100vw - 32px))"
|
||||
title="编辑排序"
|
||||
class="sort-dialog"
|
||||
>
|
||||
<div class="sort-shell">
|
||||
<p class="sort-copy">按照当前展示顺序调整公告排序,保存后会同步到后台 `/notice/sort`。</p>
|
||||
|
||||
<div class="sort-list">
|
||||
<article
|
||||
v-for="(item, index) in sortDraft"
|
||||
:key="item.id"
|
||||
class="sort-item"
|
||||
>
|
||||
<div class="sort-item__main">
|
||||
<span class="sort-index">{{ index + 1 }}</span>
|
||||
<div class="sort-meta">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span>{{ summarizeNoticeContent(item.content, 56) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sort-actions">
|
||||
<ElButton :disabled="index === 0" @click="moveDraft(index, -1)">
|
||||
<ElIcon><ArrowUp /></ElIcon>
|
||||
上移
|
||||
</ElButton>
|
||||
<ElButton :disabled="index === sortDraft.length - 1" @click="moveDraft(index, 1)">
|
||||
<ElIcon><ArrowDown /></ElIcon>
|
||||
下移
|
||||
</ElButton>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="sort-footer">
|
||||
<ElButton @click="sortDialogVisible = false">取消</ElButton>
|
||||
<ElButton type="primary" :loading="sortSubmitting" @click="submitSort">
|
||||
保存排序
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss" src="./SystemNoticesView.scss"></style>
|
||||
@@ -0,0 +1,119 @@
|
||||
.drawer-shell,
|
||||
.drawer-form,
|
||||
.config-panel,
|
||||
.config-empty,
|
||||
.drawer-copy {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.drawer-copy {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.drawer-copy p {
|
||||
font-size: 12px;
|
||||
color: var(--xboard-text-muted);
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.drawer-copy h2 {
|
||||
font-size: 30px;
|
||||
line-height: 1.08;
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.drawer-copy span,
|
||||
.field-helper,
|
||||
.config-empty span {
|
||||
color: var(--xboard-text-secondary);
|
||||
line-height: 1.47;
|
||||
}
|
||||
|
||||
.field-helper {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.drawer-grid,
|
||||
.config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px 16px;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.section-header,
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section-header h3,
|
||||
.config-empty strong {
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.config-panel {
|
||||
padding: 18px;
|
||||
border-radius: 20px;
|
||||
border: 1px dashed rgba(0, 0, 0, 0.08);
|
||||
background: #fbfbfd;
|
||||
}
|
||||
|
||||
.config-field.is-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.config-empty {
|
||||
padding: 18px;
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.icon-preview {
|
||||
margin-top: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: fit-content;
|
||||
padding: 8px 10px;
|
||||
border-radius: 999px;
|
||||
background: #f5f5f7;
|
||||
}
|
||||
|
||||
.icon-preview img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.icon-preview span {
|
||||
color: var(--xboard-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.drawer-grid,
|
||||
.config-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.section-header,
|
||||
.drawer-footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { getPaymentForm, savePayment } from '@/api/admin'
|
||||
import type {
|
||||
AdminPaymentConfigFields,
|
||||
AdminPaymentListItem,
|
||||
} from '@/types/api'
|
||||
import {
|
||||
createEmptyPaymentForm,
|
||||
extractPaymentConfigValues,
|
||||
normalizePaymentConfigFields,
|
||||
toPaymentFormModel,
|
||||
toPaymentSavePayload,
|
||||
type PaymentFormModel,
|
||||
} from '@/utils/payments'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
mode: 'create' | 'edit'
|
||||
payment?: AdminPaymentListItem | null
|
||||
paymentMethods: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
success: [message: string]
|
||||
}>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const configLoading = ref(false)
|
||||
const hydrating = ref(false)
|
||||
const currentFields = ref<AdminPaymentConfigFields>({})
|
||||
const initialPaymentMethod = ref('')
|
||||
const form = reactive<PaymentFormModel>(createEmptyPaymentForm())
|
||||
|
||||
const drawerTitle = computed(() => props.mode === 'create' ? '添加支付方式' : '编辑支付方式')
|
||||
const configEntries = computed(() => Object.entries(currentFields.value))
|
||||
const iconPreview = computed(() => form.icon.trim())
|
||||
|
||||
const rules = computed<FormRules<PaymentFormModel>>(() => ({
|
||||
name: [{ required: true, message: '请输入显示名称', trigger: 'blur' }],
|
||||
payment: [{ required: true, message: '请选择支付接口', trigger: 'change' }],
|
||||
notifyDomain: [
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const target = new URL(normalized)
|
||||
if (!/^https?:$/.test(target.protocol)) {
|
||||
callback(new Error('通知域名仅支持 http 或 https'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
} catch {
|
||||
callback(new Error('请输入有效的通知域名'))
|
||||
}
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
handlingFeePercent: [
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric) || numeric < 0 || numeric > 100) {
|
||||
callback(new Error('百分比手续费需在 0-100 之间'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
handlingFeeFixed: [
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric) || numeric < 0 || !Number.isInteger(numeric)) {
|
||||
callback(new Error('固定手续费需为大于等于 0 的整数'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
function closeDrawer() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
async function loadDynamicConfig(method: string, paymentId?: number) {
|
||||
if (!method) {
|
||||
currentFields.value = {}
|
||||
form.config = {}
|
||||
return
|
||||
}
|
||||
|
||||
configLoading.value = true
|
||||
try {
|
||||
const response = await getPaymentForm({
|
||||
payment: method,
|
||||
...(paymentId ? { id: paymentId } : {}),
|
||||
})
|
||||
const normalizedFields = normalizePaymentConfigFields(response.data)
|
||||
currentFields.value = normalizedFields
|
||||
form.config = extractPaymentConfigValues(normalizedFields)
|
||||
} catch (error) {
|
||||
currentFields.value = {}
|
||||
form.config = {}
|
||||
ElMessage.error(error instanceof Error ? error.message : '支付接口配置加载失败')
|
||||
} finally {
|
||||
configLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeForm() {
|
||||
hydrating.value = true
|
||||
Object.assign(form, createEmptyPaymentForm())
|
||||
Object.assign(form, toPaymentFormModel(props.payment))
|
||||
initialPaymentMethod.value = props.payment?.payment || form.payment
|
||||
await loadDynamicConfig(form.payment, props.payment?.id)
|
||||
await nextTick()
|
||||
formRef.value?.clearValidate()
|
||||
hydrating.value = false
|
||||
}
|
||||
|
||||
function updateConfigValue(key: string, value: string) {
|
||||
form.config = {
|
||||
...form.config,
|
||||
[key]: value,
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadCurrentConfig() {
|
||||
if (!form.payment) {
|
||||
return
|
||||
}
|
||||
const paymentId = props.mode === 'edit' && form.payment === initialPaymentMethod.value
|
||||
? props.payment?.id
|
||||
: undefined
|
||||
await loadDynamicConfig(form.payment, paymentId)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const instance = formRef.value
|
||||
if (!instance) {
|
||||
return
|
||||
}
|
||||
|
||||
const valid = await instance.validate().catch(() => false)
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
|
||||
if (configLoading.value) {
|
||||
ElMessage.warning('支付接口配置仍在加载,请稍后再试')
|
||||
return
|
||||
}
|
||||
|
||||
if (!configEntries.value.length) {
|
||||
ElMessage.error('当前支付接口配置未加载成功,请重新选择支付接口')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await savePayment(toPaymentSavePayload(form, currentFields.value))
|
||||
const message = props.mode === 'create' ? '支付方式已创建' : '支付方式已更新'
|
||||
ElMessage.success(message)
|
||||
emit('success', message)
|
||||
closeDrawer()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '支付方式保存失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
void initializeForm()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => form.payment,
|
||||
(nextValue, previousValue) => {
|
||||
if (!props.visible || hydrating.value || !nextValue || nextValue === previousValue) {
|
||||
return
|
||||
}
|
||||
|
||||
const paymentId = props.mode === 'edit' && nextValue === initialPaymentMethod.value
|
||||
? props.payment?.id
|
||||
: undefined
|
||||
void loadDynamicConfig(nextValue, paymentId)
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDrawer
|
||||
:model-value="props.visible"
|
||||
:title="drawerTitle"
|
||||
size="min(560px, 100vw)"
|
||||
destroy-on-close
|
||||
class="payment-editor-drawer"
|
||||
@close="closeDrawer"
|
||||
@update:model-value="emit('update:visible', $event)"
|
||||
>
|
||||
<div class="drawer-shell">
|
||||
<div class="drawer-copy">
|
||||
<p>支付配置</p>
|
||||
<h2>{{ drawerTitle }}</h2>
|
||||
<span>根据当前 Laravel `/payment/*` 接口维护支付方式与网关参数。</span>
|
||||
</div>
|
||||
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
class="drawer-form"
|
||||
>
|
||||
<div class="drawer-grid">
|
||||
<ElFormItem label="显示名称" prop="name">
|
||||
<ElInput v-model="form.name" placeholder="请输入支付方式显示名称" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="图标URL">
|
||||
<ElInput v-model="form.icon" placeholder="https://cdn.example.com/payment.png" />
|
||||
<div v-if="iconPreview" class="icon-preview">
|
||||
<img :src="iconPreview" alt="支付图标预览" />
|
||||
<span>图标预览</span>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="通知域名" prop="notifyDomain">
|
||||
<ElInput v-model="form.notifyDomain" placeholder="https://pay.example.com" />
|
||||
<p class="field-helper">仅填写通知域名与协议,实际回调路径会由后端自动拼接。</p>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="百分比手续费 (%)" prop="handlingFeePercent">
|
||||
<ElInputNumber
|
||||
v-model="form.handlingFeePercent"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
class="full-width"
|
||||
placeholder="0-100"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="固定手续费" prop="handlingFeeFixed">
|
||||
<ElInputNumber
|
||||
v-model="form.handlingFeeFixed"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
:controls="false"
|
||||
class="full-width"
|
||||
placeholder="请输入固定手续费"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="支付接口" prop="payment">
|
||||
<ElSelect v-model="form.payment" placeholder="请选择支付接口">
|
||||
<ElOption
|
||||
v-for="method in props.paymentMethods"
|
||||
:key="method"
|
||||
:label="method"
|
||||
:value="method"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
|
||||
<section class="config-panel" v-loading="configLoading">
|
||||
<header class="section-header">
|
||||
<div>
|
||||
<h3>支付配置</h3>
|
||||
<span>根据当前支付接口动态加载配置字段,保持与后端插件表单契约一致。</span>
|
||||
</div>
|
||||
|
||||
<ElButton :disabled="!form.payment" @click="reloadCurrentConfig">
|
||||
重新拉取配置
|
||||
</ElButton>
|
||||
</header>
|
||||
|
||||
<div v-if="!form.payment" class="config-empty">
|
||||
<strong>请选择支付接口</strong>
|
||||
<span>选择接口后会在这里加载对应的支付网关配置字段。</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="configEntries.length" class="config-grid">
|
||||
<div
|
||||
v-for="[key, field] in configEntries"
|
||||
:key="key"
|
||||
class="config-field"
|
||||
:class="{ 'is-full': field.type === 'text' }"
|
||||
>
|
||||
<ElFormItem :label="field.label">
|
||||
<ElInput
|
||||
v-if="field.type !== 'text'"
|
||||
:model-value="form.config[key]"
|
||||
:placeholder="field.placeholder || `请输入${field.label}`"
|
||||
@update:model-value="updateConfigValue(key, String($event || ''))"
|
||||
/>
|
||||
|
||||
<ElInput
|
||||
v-else
|
||||
:model-value="form.config[key]"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:placeholder="field.placeholder || `请输入${field.label}`"
|
||||
@update:model-value="updateConfigValue(key, String($event || ''))"
|
||||
/>
|
||||
|
||||
<p v-if="field.description" class="field-helper">
|
||||
{{ field.description }}
|
||||
</p>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="config-empty">
|
||||
<strong>当前接口未返回配置字段</strong>
|
||||
<span>请确认该支付插件已启用,或点击“重新拉取配置”重试。</span>
|
||||
</div>
|
||||
</section>
|
||||
</ElForm>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<ElButton @click="closeDrawer">取消</ElButton>
|
||||
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
|
||||
{{ props.mode === 'create' ? '提交' : '保存修改' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss" src="./SystemPaymentEditorDrawer.scss"></style>
|
||||
@@ -0,0 +1,224 @@
|
||||
.payments-page {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.payments-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 30px 32px;
|
||||
border-radius: 28px;
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.payments-copy {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-width: 620px;
|
||||
}
|
||||
|
||||
.payments-kicker {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
}
|
||||
|
||||
.payments-copy h1 {
|
||||
font-size: clamp(34px, 5vw, 52px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.28px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.payments-copy span {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
line-height: 1.47;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.hero-stats article {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.hero-stats span {
|
||||
color: rgba(255, 255, 255, 0.64);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hero-stats strong {
|
||||
color: #ffffff;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.table-shell {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 24px;
|
||||
border-radius: 26px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--xboard-shadow);
|
||||
}
|
||||
|
||||
.table-toolbar,
|
||||
.toolbar-left,
|
||||
.table-footer,
|
||||
.action-group,
|
||||
.sort-item,
|
||||
.sort-item__main,
|
||||
.sort-actions,
|
||||
.sort-footer,
|
||||
.name-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.table-toolbar,
|
||||
.table-footer {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-search {
|
||||
width: min(320px, 100%);
|
||||
}
|
||||
|
||||
.payments-table :deep(th.el-table__cell) {
|
||||
color: var(--xboard-text-secondary);
|
||||
background: #fbfbfd;
|
||||
}
|
||||
|
||||
.payments-table :deep(.el-table__row td.el-table__cell) {
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.name-cell,
|
||||
.notify-cell,
|
||||
.gateway-cell,
|
||||
.sort-shell,
|
||||
.sort-list,
|
||||
.sort-meta,
|
||||
.name-copy {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.name-copy strong,
|
||||
.sort-meta strong,
|
||||
.gateway-cell strong {
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.name-copy span,
|
||||
.table-footer span,
|
||||
.notify-cell span,
|
||||
.sort-copy,
|
||||
.sort-meta span,
|
||||
.gateway-cell span {
|
||||
color: var(--xboard-text-muted);
|
||||
}
|
||||
|
||||
.icon-preview,
|
||||
.icon-fallback {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: #f5f5f7;
|
||||
}
|
||||
|
||||
.icon-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.icon-fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #0071e3;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notify-cell code {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: #f5f5f7;
|
||||
color: var(--xboard-text-secondary);
|
||||
font-family: var(--xboard-font-mono);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.danger-btn {
|
||||
color: var(--xboard-danger);
|
||||
}
|
||||
|
||||
.sort-copy {
|
||||
line-height: 1.47;
|
||||
}
|
||||
|
||||
.sort-item {
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
background: #fbfbfd;
|
||||
}
|
||||
|
||||
.sort-index {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(0, 113, 227, 0.08);
|
||||
color: #0071e3;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.payments-hero,
|
||||
.table-toolbar,
|
||||
.table-footer,
|
||||
.sort-item,
|
||||
.sort-item__main,
|
||||
.sort-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
min-width: 0;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sort-index {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Delete,
|
||||
EditPen,
|
||||
Plus,
|
||||
Search,
|
||||
} from '@element-plus/icons-vue'
|
||||
import {
|
||||
deletePayment,
|
||||
fetchPayments,
|
||||
getPaymentMethods,
|
||||
sortPayments,
|
||||
togglePaymentVisibility,
|
||||
} from '@/api/admin'
|
||||
import type { AdminPaymentListItem } from '@/types/api'
|
||||
import {
|
||||
countCustomNotifyDomains,
|
||||
countEnabledPayments,
|
||||
filterPayments,
|
||||
formatPaymentFee,
|
||||
movePaymentOrder,
|
||||
normalizePayment,
|
||||
sortPaymentsByOrder,
|
||||
} from '@/utils/payments'
|
||||
import SystemPaymentEditorDrawer from './SystemPaymentEditorDrawer.vue'
|
||||
|
||||
type DrawerMode = 'create' | 'edit'
|
||||
|
||||
const loading = ref(true)
|
||||
const sortSubmitting = ref(false)
|
||||
const drawerVisible = ref(false)
|
||||
const drawerMode = ref<DrawerMode>('create')
|
||||
const activePayment = ref<AdminPaymentListItem | null>(null)
|
||||
const sortDialogVisible = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const keyword = ref('')
|
||||
const current = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const payments = ref<AdminPaymentListItem[]>([])
|
||||
const paymentMethods = ref<string[]>([])
|
||||
const sortDraft = ref<AdminPaymentListItem[]>([])
|
||||
const toggleLoadingMap = ref<Record<string, boolean>>({})
|
||||
|
||||
const filteredPayments = computed(() => filterPayments(payments.value, keyword.value))
|
||||
const visiblePayments = computed(() => {
|
||||
const start = (current.value - 1) * pageSize.value
|
||||
return filteredPayments.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
|
||||
const summaryCards = computed(() => [
|
||||
{ label: '支付方式数', value: String(payments.value.length) },
|
||||
{ label: '已启用', value: String(countEnabledPayments(payments.value)) },
|
||||
{ label: '接口种类', value: String(new Set(payments.value.map((item) => item.payment)).size) },
|
||||
{ label: '自定义通知域名', value: String(countCustomNotifyDomains(payments.value)) },
|
||||
])
|
||||
|
||||
function getToggleKey(id: number): string {
|
||||
return `payment:${id}`
|
||||
}
|
||||
|
||||
function isToggleLoading(id: number): boolean {
|
||||
return Boolean(toggleLoadingMap.value[getToggleKey(id)])
|
||||
}
|
||||
|
||||
async function loadPage() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const [paymentsResult, methodsResult] = await Promise.allSettled([
|
||||
fetchPayments(),
|
||||
getPaymentMethods(),
|
||||
])
|
||||
|
||||
if (paymentsResult.status === 'rejected') {
|
||||
throw paymentsResult.reason
|
||||
}
|
||||
|
||||
payments.value = sortPaymentsByOrder((paymentsResult.value.data ?? []).map((item) => normalizePayment(item)))
|
||||
|
||||
if (methodsResult.status === 'fulfilled') {
|
||||
paymentMethods.value = methodsResult.value.data ?? []
|
||||
} else {
|
||||
paymentMethods.value = []
|
||||
ElMessage.warning('支付接口列表加载失败,创建或切换支付接口时需要重新拉取')
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '支付配置加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDrawer() {
|
||||
drawerMode.value = 'create'
|
||||
activePayment.value = null
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDrawer(payment: AdminPaymentListItem) {
|
||||
drawerMode.value = 'edit'
|
||||
activePayment.value = payment
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
function handleDrawerSuccess() {
|
||||
void loadPage()
|
||||
}
|
||||
|
||||
async function handleToggle(payment: AdminPaymentListItem, nextValue: boolean | string | number) {
|
||||
const normalizedNextValue = Boolean(nextValue)
|
||||
if (Boolean(payment.enable) === normalizedNextValue) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = getToggleKey(payment.id)
|
||||
toggleLoadingMap.value[key] = true
|
||||
try {
|
||||
await togglePaymentVisibility(payment.id)
|
||||
payment.enable = normalizedNextValue
|
||||
ElMessage.success('支付方式状态已更新')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '支付方式状态更新失败')
|
||||
} finally {
|
||||
toggleLoadingMap.value[key] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(payment: AdminPaymentListItem) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`删除支付方式「${payment.name}」后无法恢复,确认继续吗?`, '删除支付方式', {
|
||||
type: 'warning',
|
||||
})
|
||||
await deletePayment(payment.id)
|
||||
ElMessage.success('支付方式已删除')
|
||||
await loadPage()
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') {
|
||||
return
|
||||
}
|
||||
ElMessage.error(error instanceof Error ? error.message : '支付方式删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function openSortEditor() {
|
||||
sortDraft.value = payments.value.map((item) => ({
|
||||
...item,
|
||||
config: item.config ? { ...item.config } : {},
|
||||
}))
|
||||
sortDialogVisible.value = true
|
||||
}
|
||||
|
||||
function moveDraft(index: number, direction: -1 | 1) {
|
||||
sortDraft.value = movePaymentOrder(sortDraft.value, index, direction)
|
||||
}
|
||||
|
||||
async function submitSort() {
|
||||
sortSubmitting.value = true
|
||||
try {
|
||||
await sortPayments(sortDraft.value.map((item) => item.id))
|
||||
ElMessage.success('支付方式排序已保存')
|
||||
sortDialogVisible.value = false
|
||||
await loadPage()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '支付方式排序保存失败')
|
||||
} finally {
|
||||
sortSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(keyword, () => {
|
||||
current.value = 1
|
||||
})
|
||||
|
||||
watch(filteredPayments, (list) => {
|
||||
const maxPage = Math.max(1, Math.ceil(list.length / pageSize.value))
|
||||
if (current.value > maxPage) {
|
||||
current.value = maxPage
|
||||
}
|
||||
})
|
||||
|
||||
watch(pageSize, () => {
|
||||
current.value = 1
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void loadPage()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="payments-page">
|
||||
<section class="payments-hero">
|
||||
<div class="payments-copy">
|
||||
<p class="payments-kicker">System Management</p>
|
||||
<h1>支付配置</h1>
|
||||
<span>在这里可以配置支付方式,包括添加、编辑、启停、排序与通知地址管理。</span>
|
||||
</div>
|
||||
|
||||
<div class="hero-stats">
|
||||
<article v-for="item in summaryCards" :key="item.label">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="table-shell">
|
||||
<ElAlert
|
||||
v-if="errorMessage"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
:title="errorMessage"
|
||||
>
|
||||
<template #default>
|
||||
<ElButton size="small" @click="loadPage">重新加载</ElButton>
|
||||
</template>
|
||||
</ElAlert>
|
||||
|
||||
<header class="table-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<ElButton type="primary" @click="openCreateDrawer">
|
||||
<ElIcon><Plus /></ElIcon>
|
||||
添加支付方式
|
||||
</ElButton>
|
||||
|
||||
<ElInput
|
||||
v-model="keyword"
|
||||
clearable
|
||||
placeholder="搜索支付方式..."
|
||||
class="toolbar-search"
|
||||
>
|
||||
<template #prefix>
|
||||
<ElIcon><Search /></ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
</div>
|
||||
|
||||
<ElButton @click="openSortEditor">编辑排序</ElButton>
|
||||
</header>
|
||||
|
||||
<ElTable
|
||||
:data="visiblePayments"
|
||||
v-loading="loading"
|
||||
class="payments-table"
|
||||
row-key="id"
|
||||
empty-text="当前筛选条件下暂无支付方式"
|
||||
>
|
||||
<ElTableColumn prop="id" label="ID" width="86" />
|
||||
<ElTableColumn label="启用" width="92">
|
||||
<template #default="{ row }">
|
||||
<ElSwitch
|
||||
:model-value="Boolean(row.enable)"
|
||||
:loading="isToggleLoading(row.id)"
|
||||
@change="handleToggle(row, $event)"
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="显示名称" min-width="320">
|
||||
<template #default="{ row }">
|
||||
<div class="name-cell">
|
||||
<div class="name-main">
|
||||
<div v-if="row.icon" class="icon-preview">
|
||||
<img :src="row.icon" :alt="row.name" />
|
||||
</div>
|
||||
<div v-else class="icon-fallback">
|
||||
{{ row.name.slice(0, 1) || 'P' }}
|
||||
</div>
|
||||
|
||||
<div class="name-copy">
|
||||
<strong>{{ row.name }}</strong>
|
||||
<span>{{ formatPaymentFee(row) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="支付接口" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<div class="gateway-cell">
|
||||
<strong>{{ row.payment }}</strong>
|
||||
<span>排序 #{{ row.sort || 0 }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="通知地址" min-width="320">
|
||||
<template #default="{ row }">
|
||||
<div class="notify-cell">
|
||||
<code>{{ row.notify_url || '未生成通知地址' }}</code>
|
||||
<span v-if="row.notify_domain">{{ row.notify_domain }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="108" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="action-group">
|
||||
<ElButton text class="action-btn" @click="openEditDrawer(row)">
|
||||
<ElIcon><EditPen /></ElIcon>
|
||||
</ElButton>
|
||||
<ElButton text class="action-btn danger-btn" @click="handleDelete(row)">
|
||||
<ElIcon><Delete /></ElIcon>
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<footer class="table-footer">
|
||||
<span>共 {{ filteredPayments.length }} 项</span>
|
||||
<ElPagination
|
||||
v-model:current-page="current"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="sizes, prev, pager, next"
|
||||
:total="filteredPayments.length"
|
||||
background
|
||||
/>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<SystemPaymentEditorDrawer
|
||||
v-model:visible="drawerVisible"
|
||||
:mode="drawerMode"
|
||||
:payment="activePayment"
|
||||
:payment-methods="paymentMethods"
|
||||
@success="handleDrawerSuccess"
|
||||
/>
|
||||
|
||||
<ElDialog
|
||||
v-model="sortDialogVisible"
|
||||
width="min(640px, calc(100vw - 32px))"
|
||||
title="编辑排序"
|
||||
class="sort-dialog"
|
||||
>
|
||||
<div class="sort-shell">
|
||||
<p class="sort-copy">按照当前展示顺序调整支付方式排序,保存后会同步到后台 `/payment/sort`。</p>
|
||||
|
||||
<div class="sort-list">
|
||||
<article
|
||||
v-for="(item, index) in sortDraft"
|
||||
:key="item.id"
|
||||
class="sort-item"
|
||||
>
|
||||
<div class="sort-item__main">
|
||||
<span class="sort-index">{{ index + 1 }}</span>
|
||||
<div class="sort-meta">
|
||||
<strong>{{ item.name }}</strong>
|
||||
<span>{{ item.payment }} · {{ formatPaymentFee(item) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sort-actions">
|
||||
<ElButton :disabled="index === 0" @click="moveDraft(index, -1)">
|
||||
<ElIcon><ArrowUp /></ElIcon>
|
||||
上移
|
||||
</ElButton>
|
||||
<ElButton :disabled="index === sortDraft.length - 1" @click="moveDraft(index, 1)">
|
||||
<ElIcon><ArrowDown /></ElIcon>
|
||||
下移
|
||||
</ElButton>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="sort-footer">
|
||||
<ElButton @click="sortDialogVisible = false">取消</ElButton>
|
||||
<ElButton type="primary" :loading="sortSubmitting" @click="submitSort">
|
||||
保存排序
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss" src="./SystemPaymentsView.scss"></style>
|
||||
@@ -0,0 +1,350 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Brush, CircleCheckFilled, MagicStick } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
AdminThemeConfigRecord,
|
||||
AdminThemeConfigField,
|
||||
AdminThemeSummary,
|
||||
} from '@/types/api'
|
||||
import {
|
||||
createThemeConfigFormState,
|
||||
serializeThemeConfigForm,
|
||||
type ThemeConfigFormState,
|
||||
} from '@/utils/themes'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
theme: AdminThemeSummary | null
|
||||
config: AdminThemeConfigRecord | null
|
||||
loading: boolean
|
||||
saving: boolean
|
||||
applying: boolean
|
||||
errorMessage?: string
|
||||
isActive: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void
|
||||
(e: 'save', payload: { name: string; config: AdminThemeConfigRecord }): void
|
||||
(e: 'apply', name: string): void
|
||||
}>()
|
||||
|
||||
const form = ref<ThemeConfigFormState>({})
|
||||
|
||||
const fields = computed<AdminThemeConfigField[]>(() => props.theme?.configs ?? [])
|
||||
const hasFields = computed(() => fields.value.length > 0)
|
||||
|
||||
function syncFormState() {
|
||||
form.value = createThemeConfigFormState(fields.value, props.config)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.theme, props.config, props.visible] as const,
|
||||
() => {
|
||||
syncFormState()
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
function handleSave() {
|
||||
if (!props.theme) return
|
||||
emit('save', {
|
||||
name: props.theme.name,
|
||||
config: serializeThemeConfigForm(form.value, fields.value),
|
||||
})
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
if (!props.theme) return
|
||||
emit('apply', props.theme.name)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDrawer
|
||||
:model-value="visible"
|
||||
size="min(560px, calc(100vw - 32px))"
|
||||
class="theme-config-drawer"
|
||||
@update:model-value="emit('update:visible', $event)"
|
||||
>
|
||||
<template #header>
|
||||
<div class="drawer-header">
|
||||
<div class="drawer-title">
|
||||
<p>Theme Settings</p>
|
||||
<h2>{{ theme?.name || '主题设置' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="drawer-badges">
|
||||
<ElTag v-if="theme?.is_system" effect="plain" round>系统主题</ElTag>
|
||||
<ElTag v-if="isActive" type="success" effect="light" round>当前主题</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="theme" class="drawer-body">
|
||||
<section class="drawer-hero">
|
||||
<div class="drawer-hero__icon">
|
||||
<ElIcon><Brush /></ElIcon>
|
||||
</div>
|
||||
|
||||
<div class="drawer-hero__copy">
|
||||
<p>{{ theme.description || '当前主题支持独立配置字段,可按需保存并切换。' }}</p>
|
||||
<div class="drawer-hero__meta">
|
||||
<span>版本 {{ theme.version || '未标注' }}</span>
|
||||
<span>{{ fields.length }} 个配置项</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ElAlert
|
||||
v-if="errorMessage"
|
||||
:title="errorMessage"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<section v-if="loading" class="drawer-loading">
|
||||
<ElSkeleton :rows="4" animated />
|
||||
<ElSkeleton :rows="6" animated />
|
||||
</section>
|
||||
|
||||
<section v-else-if="hasFields" class="drawer-form-shell">
|
||||
<ElForm label-position="top" class="drawer-form">
|
||||
<div class="drawer-grid">
|
||||
<div
|
||||
v-for="field in fields"
|
||||
:key="field.field_name"
|
||||
class="drawer-field"
|
||||
:class="{ 'is-full': field.field_type === 'textarea' }"
|
||||
>
|
||||
<ElFormItem :label="field.label">
|
||||
<ElSelect
|
||||
v-if="field.field_type === 'select'"
|
||||
v-model="form[field.field_name]"
|
||||
class="field-select"
|
||||
>
|
||||
<ElOption
|
||||
v-for="(label, value) in field.select_options || {}"
|
||||
:key="`${field.field_name}-${value}`"
|
||||
:label="label"
|
||||
:value="value"
|
||||
/>
|
||||
</ElSelect>
|
||||
|
||||
<ElInput
|
||||
v-else-if="field.field_type === 'textarea'"
|
||||
v-model="form[field.field_name]"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
:placeholder="field.placeholder"
|
||||
resize="vertical"
|
||||
/>
|
||||
|
||||
<ElInput
|
||||
v-else
|
||||
v-model="form[field.field_name]"
|
||||
:placeholder="field.placeholder"
|
||||
/>
|
||||
|
||||
<div class="field-foot">
|
||||
<ElIcon><MagicStick /></ElIcon>
|
||||
<span class="mono">{{ field.field_name }}</span>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
</div>
|
||||
</ElForm>
|
||||
</section>
|
||||
|
||||
<section v-else class="drawer-empty">
|
||||
<ElIcon><CircleCheckFilled /></ElIcon>
|
||||
<div>
|
||||
<h3>当前主题没有额外配置项</h3>
|
||||
<p>你仍然可以将它设为当前主题;若后续主题包新增 `config.json` 字段,这里会自动显示。</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<ElButton @click="emit('update:visible', false)">关闭</ElButton>
|
||||
<ElButton
|
||||
v-if="!isActive"
|
||||
:loading="applying"
|
||||
@click="handleApply"
|
||||
>
|
||||
设为当前主题
|
||||
</ElButton>
|
||||
<ElButton v-else disabled>当前主题</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
:disabled="loading"
|
||||
@click="handleSave"
|
||||
>
|
||||
保存设置
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.drawer-title p {
|
||||
margin: 0;
|
||||
color: var(--xboard-text-muted);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.drawer-title h2 {
|
||||
margin: 0;
|
||||
color: var(--xboard-text-strong);
|
||||
font-size: 28px;
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.28px;
|
||||
}
|
||||
|
||||
.drawer-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.drawer-body {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.drawer-hero {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 18px 20px;
|
||||
border-radius: 20px;
|
||||
background: #f5f5f7;
|
||||
}
|
||||
|
||||
.drawer-hero__icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 16px;
|
||||
background: #1d1d1f;
|
||||
color: #ffffff;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.drawer-hero__copy {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.drawer-hero__copy p {
|
||||
margin: 0;
|
||||
color: var(--xboard-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.drawer-hero__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
color: var(--xboard-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.drawer-loading,
|
||||
.drawer-form-shell {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.drawer-form {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.drawer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px 16px;
|
||||
}
|
||||
|
||||
.drawer-field.is-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.field-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field-foot {
|
||||
margin-top: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--xboard-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.drawer-empty {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 18px 20px;
|
||||
border-radius: 20px;
|
||||
background: #f5f5f7;
|
||||
color: var(--xboard-text-secondary);
|
||||
}
|
||||
|
||||
.drawer-empty :deep(.el-icon) {
|
||||
margin-top: 2px;
|
||||
color: #0071e3;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.drawer-empty h3 {
|
||||
margin: 0 0 8px;
|
||||
color: var(--xboard-text-strong);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.drawer-empty p {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.drawer-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.drawer-footer :deep(.el-button) {
|
||||
flex: 1 1 140px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,537 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Brush, PictureFilled, RefreshRight, Setting, UploadFilled } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getThemeConfig,
|
||||
getThemes,
|
||||
saveAdminConfig,
|
||||
saveThemeConfig,
|
||||
uploadTheme,
|
||||
} from '@/api/admin'
|
||||
import type { AdminThemeConfigRecord } from '@/types/api'
|
||||
import ThemeConfigDrawer from './ThemeConfigDrawer.vue'
|
||||
import { resolveThemes, type ResolvedThemeSummary } from '@/utils/themes'
|
||||
|
||||
const loading = ref(true)
|
||||
const reloading = ref(false)
|
||||
const uploading = ref(false)
|
||||
const drawerVisible = ref(false)
|
||||
const drawerLoading = ref(false)
|
||||
const drawerSaving = ref(false)
|
||||
const drawerErrorMessage = ref('')
|
||||
const errorMessage = ref('')
|
||||
const applyingThemeName = ref('')
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const activeThemeName = ref('Xboard')
|
||||
const themes = ref<ResolvedThemeSummary[]>([])
|
||||
const selectedThemeName = ref<string | null>(null)
|
||||
const selectedThemeConfig = ref<AdminThemeConfigRecord | null>(null)
|
||||
|
||||
const selectedTheme = computed(
|
||||
() => themes.value.find((theme) => theme.name === selectedThemeName.value) ?? null,
|
||||
)
|
||||
|
||||
const uploadedThemeCount = computed(() => themes.value.filter((theme) => !theme.is_system).length)
|
||||
const hasThemes = computed(() => themes.value.length > 0)
|
||||
|
||||
async function loadPage(mode: 'initial' | 'reload' = 'initial') {
|
||||
if (mode === 'initial') {
|
||||
loading.value = true
|
||||
} else {
|
||||
reloading.value = true
|
||||
}
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await getThemes()
|
||||
activeThemeName.value = response.data?.active || 'Xboard'
|
||||
themes.value = resolveThemes(response.data)
|
||||
|
||||
if (selectedThemeName.value && !themes.value.some((theme) => theme.name === selectedThemeName.value)) {
|
||||
drawerVisible.value = false
|
||||
selectedThemeName.value = null
|
||||
selectedThemeConfig.value = null
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '主题列表加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
reloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openThemeSettings(theme: ResolvedThemeSummary) {
|
||||
selectedThemeName.value = theme.name
|
||||
selectedThemeConfig.value = null
|
||||
drawerErrorMessage.value = ''
|
||||
drawerVisible.value = true
|
||||
drawerLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await getThemeConfig(theme.name)
|
||||
selectedThemeConfig.value = response.data ?? {}
|
||||
} catch (error) {
|
||||
drawerErrorMessage.value = error instanceof Error ? error.message : '主题配置加载失败'
|
||||
} finally {
|
||||
drawerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveThemeConfig(payload: {
|
||||
name: string
|
||||
config: AdminThemeConfigRecord
|
||||
}) {
|
||||
drawerSaving.value = true
|
||||
try {
|
||||
const response = await saveThemeConfig(payload.name, payload.config)
|
||||
selectedThemeConfig.value = response.data ?? payload.config
|
||||
ElMessage.success('主题配置已保存')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '主题配置保存失败')
|
||||
} finally {
|
||||
drawerSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApplyTheme(name: string) {
|
||||
applyingThemeName.value = name
|
||||
try {
|
||||
await saveAdminConfig({ frontend_theme: name })
|
||||
activeThemeName.value = name
|
||||
ElMessage.success(`已切换为「${name}」`)
|
||||
await loadPage('reload')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '主题切换失败')
|
||||
} finally {
|
||||
applyingThemeName.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function triggerUpload() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
async function handleFileChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
target.value = ''
|
||||
if (!file) return
|
||||
|
||||
if (!/\.zip$/i.test(file.name)) {
|
||||
ElMessage.error('请选择 zip 格式的主题包')
|
||||
return
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
try {
|
||||
await uploadTheme(file)
|
||||
ElMessage.success('主题包上传成功')
|
||||
await loadPage('reload')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '主题上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadPage()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="themes-page">
|
||||
<section class="themes-header">
|
||||
<div class="header-copy">
|
||||
<p class="header-kicker">System Management</p>
|
||||
<h1>主题配置</h1>
|
||||
<p>
|
||||
主题配置,包括主题色、背景与自定义页脚等。如果你采用前后分离的方式部署 V2board,
|
||||
这些主题配置不会生效。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
<ElButton :loading="reloading" @click="loadPage('reload')">
|
||||
<ElIcon><RefreshRight /></ElIcon>
|
||||
刷新列表
|
||||
</ElButton>
|
||||
<ElButton type="primary" :loading="uploading" @click="triggerUpload">
|
||||
<ElIcon><UploadFilled /></ElIcon>
|
||||
上传主题
|
||||
</ElButton>
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="file-input"
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="themes-toolbar">
|
||||
<div class="toolbar-pill">
|
||||
<span>当前主题</span>
|
||||
<strong>{{ activeThemeName }}</strong>
|
||||
</div>
|
||||
<div class="toolbar-pill">
|
||||
<span>可用主题</span>
|
||||
<strong>{{ themes.length }}</strong>
|
||||
</div>
|
||||
<div class="toolbar-pill">
|
||||
<span>已上传主题</span>
|
||||
<strong>{{ uploadedThemeCount }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="loading" class="loading-shell">
|
||||
<ElSkeleton :rows="4" animated />
|
||||
<ElSkeleton :rows="4" animated />
|
||||
</section>
|
||||
|
||||
<section v-else class="themes-shell">
|
||||
<ElAlert
|
||||
v-if="errorMessage"
|
||||
:title="errorMessage"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div v-if="hasThemes" class="themes-grid">
|
||||
<article
|
||||
v-for="theme in themes"
|
||||
:key="theme.key"
|
||||
class="theme-card"
|
||||
:class="{ active: theme.name === activeThemeName }"
|
||||
>
|
||||
<div class="theme-card__top">
|
||||
<div class="theme-card__icon">
|
||||
<ElIcon v-if="theme.images"><PictureFilled /></ElIcon>
|
||||
<ElIcon v-else><Brush /></ElIcon>
|
||||
</div>
|
||||
|
||||
<div class="theme-card__meta">
|
||||
<div class="theme-card__title">
|
||||
<h2>{{ theme.name }}</h2>
|
||||
<ElTag
|
||||
v-if="theme.name === activeThemeName"
|
||||
type="success"
|
||||
effect="light"
|
||||
round
|
||||
>
|
||||
当前主题
|
||||
</ElTag>
|
||||
<ElTag v-else-if="theme.is_system" effect="plain" round>系统主题</ElTag>
|
||||
<ElTag v-else type="info" effect="plain" round>上传主题</ElTag>
|
||||
</div>
|
||||
|
||||
<p>{{ theme.description || theme.name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="theme-card__facts">
|
||||
<article>
|
||||
<span>版本</span>
|
||||
<strong>{{ theme.version || '未标注' }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>配置项</span>
|
||||
<strong>{{ theme.configs?.length || 0 }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="theme-card__actions">
|
||||
<ElButton @click="openThemeSettings(theme)">
|
||||
<ElIcon><Setting /></ElIcon>
|
||||
主题设置
|
||||
</ElButton>
|
||||
<ElButton
|
||||
:disabled="theme.name === activeThemeName"
|
||||
:loading="applyingThemeName === theme.name"
|
||||
:type="theme.name === activeThemeName ? undefined : 'primary'"
|
||||
:plain="theme.name !== activeThemeName"
|
||||
@click="handleApplyTheme(theme.name)"
|
||||
>
|
||||
{{ theme.name === activeThemeName ? '当前主题' : '设为当前' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-shell">
|
||||
<ElIcon><Brush /></ElIcon>
|
||||
<div>
|
||||
<h2>当前没有可用主题</h2>
|
||||
<p>请先上传包含 `config.json` 和 `dashboard.blade.php` 的主题包,再回到这里做配置。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ThemeConfigDrawer
|
||||
v-model:visible="drawerVisible"
|
||||
:theme="selectedTheme"
|
||||
:config="selectedThemeConfig"
|
||||
:loading="drawerLoading"
|
||||
:saving="drawerSaving"
|
||||
:applying="applyingThemeName === selectedThemeName"
|
||||
:is-active="selectedTheme?.name === activeThemeName"
|
||||
:error-message="drawerErrorMessage"
|
||||
@save="handleSaveThemeConfig"
|
||||
@apply="handleApplyTheme"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.themes-page {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.themes-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.header-copy {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.header-kicker {
|
||||
margin: 0;
|
||||
color: var(--xboard-text-muted);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.header-copy h1 {
|
||||
margin: 0;
|
||||
color: var(--xboard-text-strong);
|
||||
font-size: clamp(34px, 4vw, 46px);
|
||||
line-height: 1.06;
|
||||
letter-spacing: -0.28px;
|
||||
}
|
||||
|
||||
.header-copy > p:last-child {
|
||||
margin: 0;
|
||||
color: var(--xboard-text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.themes-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.toolbar-pill {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 18px 20px;
|
||||
border-radius: 20px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--xboard-shadow);
|
||||
}
|
||||
|
||||
.toolbar-pill span {
|
||||
color: var(--xboard-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.toolbar-pill strong {
|
||||
color: var(--xboard-text-strong);
|
||||
font-size: 22px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.loading-shell,
|
||||
.themes-shell {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.loading-shell {
|
||||
padding: 24px;
|
||||
border-radius: 24px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--xboard-shadow);
|
||||
}
|
||||
|
||||
.themes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.theme-card {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 22px 22px 20px;
|
||||
border-radius: 24px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--xboard-shadow);
|
||||
border: 1px solid transparent;
|
||||
transition: border-color 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.theme-card:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(0, 113, 227, 0.08);
|
||||
}
|
||||
|
||||
.theme-card.active {
|
||||
border-color: rgba(0, 113, 227, 0.16);
|
||||
}
|
||||
|
||||
.theme-card__top {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.theme-card__icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 18px;
|
||||
background: #f5f5f7;
|
||||
color: #1d1d1f;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.theme-card__meta {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.theme-card__title {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.theme-card__title h2 {
|
||||
margin: 0;
|
||||
color: var(--xboard-text-strong);
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.24px;
|
||||
}
|
||||
|
||||
.theme-card__meta p {
|
||||
margin: 0;
|
||||
color: var(--xboard-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.theme-card__facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.theme-card__facts article {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: #f5f5f7;
|
||||
}
|
||||
|
||||
.theme-card__facts span {
|
||||
color: var(--xboard-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.theme-card__facts strong {
|
||||
color: var(--xboard-text-strong);
|
||||
font-size: 18px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.theme-card__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.empty-shell {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
padding: 28px;
|
||||
border-radius: 24px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--xboard-shadow);
|
||||
}
|
||||
|
||||
.empty-shell :deep(.el-icon) {
|
||||
color: #0071e3;
|
||||
font-size: 20px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.empty-shell h2 {
|
||||
margin: 0 0 8px;
|
||||
color: var(--xboard-text-strong);
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.empty-shell p {
|
||||
margin: 0;
|
||||
color: var(--xboard-text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.themes-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.themes-toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.theme-card__facts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.theme-card__actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.theme-card__actions :deep(.el-button) {
|
||||
flex: 1 1 140px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user