feat(admin-frontend): 完成订阅与系统管理真实工作台

补齐订单、优惠券、主题、插件、公告与支付管理页面,
接入对应后台接口、路由入口与工具层类型定义。
同时修复套餐页开关初始化误写问题,避免浏览即触发写操作。

在订阅协议侧为 Stash 导出增加 AnyTLS 版本守卫,
未知版本或低于 3.3.0 时不再导出该协议,并补充回归测试与知识记录。
This commit is contained in:
yinjianm
2026-04-24 16:52:41 +08:00
parent 16203b14f6
commit f7cef30b9c
89 changed files with 11122 additions and 92 deletions
+229
View File
@@ -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>)
}
+2 -2
View File
@@ -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: '即将开放' },
]
+17 -5
View File
@@ -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' },
},
],
+277 -4
View File
@@ -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
+271
View File
@@ -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
}
+159
View File
@@ -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
}
+149
View File
@@ -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 || '')
}
+328
View File
@@ -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
}
+202
View File
@@ -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,
}
}
+9
View File
@@ -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) {
+279
View File
@@ -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>)
}
+59
View File
@@ -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>
+179
View File
@@ -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>
+189
View File
@@ -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('![图片描述](https://)', 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
View File
@@ -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>
+167
View File
@@ -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>
+203
View File
@@ -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>
+249
View File
@@ -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>
+224
View File
@@ -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>