feat(admin-frontend): 新增系统与订阅管理后台页面
扩展管理端侧边栏与路由,新增系统配置真实页面、订阅套餐 管理页、节点管理页及多个结构化占位页 补齐前端 API、类型与工具层,并增强仪表盘刷新、趋势切换、 失败作业详情与流量排行 limit 联动能力 同步后端 traffic rank limit 支持与知识库归档、设计约束、 验证配置及视觉验收产物
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
DataAnalysis,
|
||||
Discount,
|
||||
Download,
|
||||
RefreshRight,
|
||||
Tickets,
|
||||
Upload,
|
||||
User,
|
||||
@@ -29,6 +30,7 @@ import type {
|
||||
} from '@/types/api'
|
||||
import {
|
||||
buildTrendChart,
|
||||
formatCountLabel,
|
||||
formatCompactNumber,
|
||||
formatCurrency,
|
||||
formatDateTime,
|
||||
@@ -37,9 +39,11 @@ import {
|
||||
getDateRangeFromPreset,
|
||||
getQueueWaitName,
|
||||
getQueueWaitSeconds,
|
||||
type TrendMetric,
|
||||
type TimePreset,
|
||||
} from '@/utils/dashboard'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import QueueFailedJobsDialog from './QueueFailedJobsDialog.vue'
|
||||
|
||||
interface MetricCard {
|
||||
key: string
|
||||
@@ -56,7 +60,9 @@ const booting = ref(true)
|
||||
const trendLoading = ref(false)
|
||||
const rankLoading = ref(false)
|
||||
const systemLoading = ref(false)
|
||||
const lastRefreshedAt = ref<string | null>(null)
|
||||
const trendPreset = ref<TimePreset>('30d')
|
||||
const trendMetric = ref<TrendMetric>('amount')
|
||||
const rankPreset = ref<TimePreset>('1d')
|
||||
|
||||
const overview = ref<DashboardStats | null>(null)
|
||||
@@ -66,6 +72,7 @@ const nodeRanks = ref<TrafficRankItem[]>([])
|
||||
const userRanks = ref<TrafficRankItem[]>([])
|
||||
const systemStatus = ref<SystemStatus | null>(null)
|
||||
const queueStats = ref<QueueStats | null>(null)
|
||||
const failedJobsDialogVisible = ref(false)
|
||||
|
||||
const trendPresetOptions = [
|
||||
{ label: '7天', value: '7d' },
|
||||
@@ -73,12 +80,27 @@ const trendPresetOptions = [
|
||||
{ label: '90天', value: '90d' },
|
||||
] as const
|
||||
|
||||
const trendMetricOptions = [
|
||||
{ label: '按金额', value: 'amount' },
|
||||
{ label: '按数量', value: 'count' },
|
||||
] as const
|
||||
|
||||
const rankPresetOptions = [
|
||||
{ label: '24h', value: '1d' },
|
||||
{ label: '7天', value: '7d' },
|
||||
{ label: '30天', value: '30d' },
|
||||
] as const
|
||||
|
||||
const rankDisplayOptions = [
|
||||
{ label: '10个', value: 10 },
|
||||
{ label: '20个', value: 20 },
|
||||
] as const
|
||||
|
||||
type RankDisplayCount = (typeof rankDisplayOptions)[number]['value']
|
||||
|
||||
const nodeRankLimit = ref<RankDisplayCount>(10)
|
||||
const userRankLimit = ref<RankDisplayCount>(10)
|
||||
|
||||
const dashboardStats = computed<DashboardStats>(() => overview.value ?? {
|
||||
todayIncome: 0,
|
||||
dayIncomeGrowth: 0,
|
||||
@@ -195,7 +217,97 @@ const heroSummary = computed(() => [
|
||||
},
|
||||
])
|
||||
|
||||
const trendChart = computed(() => buildTrendChart(trendList.value))
|
||||
const refreshButtonDisabled = computed(() => (
|
||||
booting.value
|
||||
|| trendLoading.value
|
||||
|| rankLoading.value
|
||||
|| systemLoading.value
|
||||
))
|
||||
|
||||
const refreshStatusText = computed(() => {
|
||||
if (booting.value) return '正在同步全部数据'
|
||||
return '数据已同步'
|
||||
})
|
||||
|
||||
const refreshStatusMeta = computed(() => {
|
||||
if (booting.value) return '统计、趋势、排行与系统状态正在刷新'
|
||||
if (!lastRefreshedAt.value) return '首次加载完成后可再次刷新'
|
||||
return `上次刷新 ${formatDateTime(lastRefreshedAt.value)}`
|
||||
})
|
||||
|
||||
const trendChart = computed(() => buildTrendChart(trendList.value, {
|
||||
metric: trendMetric.value,
|
||||
}))
|
||||
|
||||
const trendAverageCount = computed(() => {
|
||||
if (!trendList.value.length) return 0
|
||||
const total = trendList.value.reduce((sum, point) => sum + point.paid_count, 0)
|
||||
return total / trendList.value.length
|
||||
})
|
||||
|
||||
const trendPeakCount = computed(() => {
|
||||
if (!trendList.value.length) return 0
|
||||
return Math.max(...trendList.value.map((point) => point.paid_count))
|
||||
})
|
||||
|
||||
const trendSummaryCards = computed(() => {
|
||||
const summary = trendSummary.value
|
||||
if (!summary) {
|
||||
return trendMetric.value === 'count'
|
||||
? [
|
||||
{ label: '成交订单', value: formatCountLabel(0), detail: '总成交额 ¥0.00' },
|
||||
{ label: '佣金订单', value: formatCountLabel(0), detail: '占成交 0.0%' },
|
||||
{ label: '日均成交', value: formatCountLabel(0), detail: '峰值 0 笔' },
|
||||
]
|
||||
: [
|
||||
{ label: '成交总额', value: formatCurrency(0), detail: '共 0 笔' },
|
||||
{ label: '佣金支出', value: formatCurrency(0), detail: '佣金率 0.0%' },
|
||||
{ label: '订单均价', value: formatCurrency(0), detail: '单笔均值' },
|
||||
]
|
||||
}
|
||||
|
||||
if (trendMetric.value === 'count') {
|
||||
const commissionShare = summary.paid_count
|
||||
? (summary.commission_count / summary.paid_count) * 100
|
||||
: 0
|
||||
|
||||
return [
|
||||
{
|
||||
label: '成交订单',
|
||||
value: formatCountLabel(summary.paid_count),
|
||||
detail: `总成交额 ${formatCurrency(summary.paid_total)}`,
|
||||
},
|
||||
{
|
||||
label: '佣金订单',
|
||||
value: formatCountLabel(summary.commission_count),
|
||||
detail: `占成交 ${formatPercent(commissionShare, false)}`,
|
||||
},
|
||||
{
|
||||
label: '日均成交',
|
||||
value: formatCountLabel(trendAverageCount.value),
|
||||
detail: `峰值 ${formatCountLabel(trendPeakCount.value)}`,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: '成交总额',
|
||||
value: formatCurrency(summary.paid_total),
|
||||
detail: `共 ${formatCompactNumber(summary.paid_count)} 笔`,
|
||||
},
|
||||
{
|
||||
label: '佣金支出',
|
||||
value: formatCurrency(summary.commission_total),
|
||||
detail: `佣金率 ${formatPercent(summary.commission_rate ?? 0, false)}`,
|
||||
},
|
||||
{
|
||||
label: '订单均价',
|
||||
value: formatCurrency(summary.avg_paid_amount),
|
||||
detail: '单笔均值',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const latestTrendPoint = computed(() => {
|
||||
if (!trendList.value.length) return null
|
||||
@@ -203,12 +315,22 @@ const latestTrendPoint = computed(() => {
|
||||
})
|
||||
|
||||
const trendSnapshot = computed(() => {
|
||||
if (!latestTrendPoint.value) return null
|
||||
const point = latestTrendPoint.value
|
||||
if (!point) return null
|
||||
|
||||
return {
|
||||
date: latestTrendPoint.value.date,
|
||||
orderAmount: formatCurrency(latestTrendPoint.value.paid_total),
|
||||
commissionAmount: formatCurrency(latestTrendPoint.value.commission_total),
|
||||
orderCount: latestTrendPoint.value.paid_count,
|
||||
date: point.date,
|
||||
items: trendMetric.value === 'count'
|
||||
? [
|
||||
{ label: '成交订单', value: formatCountLabel(point.paid_count) },
|
||||
{ label: '佣金订单', value: formatCountLabel(point.commission_count) },
|
||||
{ label: '成交总额', value: formatCurrency(point.paid_total) },
|
||||
]
|
||||
: [
|
||||
{ label: '收入', value: formatCurrency(point.paid_total) },
|
||||
{ label: '佣金', value: formatCurrency(point.commission_total) },
|
||||
{ label: '订单', value: formatCountLabel(point.paid_count) },
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
@@ -232,6 +354,9 @@ const queueHealthRows = computed(() => [
|
||||
},
|
||||
])
|
||||
|
||||
const displayedNodeRanks = computed(() => nodeRanks.value.slice(0, nodeRankLimit.value))
|
||||
const displayedUserRanks = computed(() => userRanks.value.slice(0, userRankLimit.value))
|
||||
|
||||
const systemRows = computed(() => [
|
||||
{
|
||||
label: '调度器',
|
||||
@@ -305,11 +430,13 @@ async function loadRankings() {
|
||||
type: 'node',
|
||||
startTime: range.startTime,
|
||||
endTime: range.endTime,
|
||||
limit: nodeRankLimit.value,
|
||||
}),
|
||||
getTrafficRank({
|
||||
type: 'user',
|
||||
startTime: range.startTime,
|
||||
endTime: range.endTime,
|
||||
limit: userRankLimit.value,
|
||||
}),
|
||||
])
|
||||
|
||||
@@ -320,35 +447,52 @@ async function loadRankings() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDashboard() {
|
||||
async function refreshDashboard(options: { silentSuccess?: boolean } = {}) {
|
||||
booting.value = true
|
||||
const results = await Promise.allSettled([
|
||||
loadOverviewPanels(),
|
||||
loadTrend(),
|
||||
loadRankings(),
|
||||
])
|
||||
try {
|
||||
const results = await Promise.allSettled([
|
||||
loadOverviewPanels(),
|
||||
loadTrend(),
|
||||
loadRankings(),
|
||||
])
|
||||
|
||||
if (results.some((item) => item.status === 'rejected')) {
|
||||
ElMessage.error('部分仪表盘数据加载失败,请稍后重试')
|
||||
if (results.some((item) => item.status === 'rejected')) {
|
||||
ElMessage.error('部分仪表盘数据加载失败,请稍后重试')
|
||||
return
|
||||
}
|
||||
|
||||
lastRefreshedAt.value = new Date().toISOString()
|
||||
if (!options.silentSuccess) {
|
||||
ElMessage.success('仪表盘数据已刷新')
|
||||
}
|
||||
} finally {
|
||||
booting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
booting.value = false
|
||||
function handleRefresh() {
|
||||
if (refreshButtonDisabled.value) return
|
||||
void refreshDashboard()
|
||||
}
|
||||
|
||||
function rankBarWidth(index: number): string {
|
||||
return `${Math.max(28, 100 - index * 12)}%`
|
||||
}
|
||||
|
||||
function rankScrollClass(limit: RankDisplayCount): string {
|
||||
return limit === 20 ? 'rank-scroll rank-scroll--extended' : 'rank-scroll'
|
||||
}
|
||||
|
||||
watch(trendPreset, () => {
|
||||
void loadTrend().catch(() => ElMessage.error('趋势数据刷新失败'))
|
||||
})
|
||||
|
||||
watch(rankPreset, () => {
|
||||
watch([rankPreset, nodeRankLimit, userRankLimit], () => {
|
||||
void loadRankings().catch(() => ElMessage.error('排行数据刷新失败'))
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void refreshDashboard()
|
||||
void refreshDashboard({ silentSuccess: true })
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -365,8 +509,23 @@ onMounted(() => {
|
||||
|
||||
<div class="dashboard-hero-side">
|
||||
<div class="hero-status">
|
||||
<span>{{ booting ? '正在同步数据' : '数据已同步' }}</span>
|
||||
<strong>/{{ app.securePath || 'admin' }}</strong>
|
||||
<div class="hero-status__copy">
|
||||
<span>{{ refreshStatusText }}</span>
|
||||
<strong>/{{ app.securePath || 'admin' }}</strong>
|
||||
<p>{{ refreshStatusMeta }}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="dashboard-refresh-button"
|
||||
:disabled="refreshButtonDisabled"
|
||||
@click="handleRefresh"
|
||||
>
|
||||
<ElIcon class="dashboard-refresh-button__icon" :class="{ spinning: booting }">
|
||||
<RefreshRight />
|
||||
</ElIcon>
|
||||
<span>{{ booting ? '正在刷新全部数据' : '刷新全部数据' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="hero-highlights">
|
||||
@@ -416,35 +575,46 @@ onMounted(() => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<button
|
||||
v-for="option in trendPresetOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="filter-pill"
|
||||
:class="{ active: option.value === trendPreset }"
|
||||
@click="trendPreset = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
<div class="panel-actions">
|
||||
<div class="filter-group filter-group--segmented" aria-label="趋势口径切换">
|
||||
<button
|
||||
v-for="option in trendMetricOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="filter-pill"
|
||||
:class="{ active: option.value === trendMetric }"
|
||||
:aria-pressed="option.value === trendMetric"
|
||||
@click="trendMetric = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<button
|
||||
v-for="option in trendPresetOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="filter-pill"
|
||||
:class="{ active: option.value === trendPreset }"
|
||||
:aria-pressed="option.value === trendPreset"
|
||||
@click="trendPreset = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="trend-summary">
|
||||
<article class="trend-stat">
|
||||
<span>成交总额</span>
|
||||
<strong>{{ formatCurrency(trendSummary?.paid_total ?? 0) }}</strong>
|
||||
<p>共 {{ formatCompactNumber(trendSummary?.paid_count ?? 0) }} 笔</p>
|
||||
</article>
|
||||
<article class="trend-stat">
|
||||
<span>佣金支出</span>
|
||||
<strong>{{ formatCurrency(trendSummary?.commission_total ?? 0) }}</strong>
|
||||
<p>佣金率 {{ formatPercent(trendSummary?.commission_rate ?? 0, false) }}</p>
|
||||
</article>
|
||||
<article class="trend-stat">
|
||||
<span>订单均价</span>
|
||||
<strong>{{ formatCurrency(trendSummary?.avg_paid_amount ?? 0) }}</strong>
|
||||
<p>单笔均值</p>
|
||||
<article
|
||||
v-for="card in trendSummaryCards"
|
||||
:key="card.label"
|
||||
class="trend-stat"
|
||||
>
|
||||
<span>{{ card.label }}</span>
|
||||
<strong>{{ card.value }}</strong>
|
||||
<p>{{ card.detail }}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -490,17 +660,12 @@ onMounted(() => {
|
||||
<span>最近记录</span>
|
||||
<strong>{{ trendSnapshot.date }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>收入</span>
|
||||
<strong>{{ trendSnapshot.orderAmount }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>佣金</span>
|
||||
<strong>{{ trendSnapshot.commissionAmount }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>订单</span>
|
||||
<strong>{{ trendSnapshot.orderCount }} 笔</strong>
|
||||
<div
|
||||
v-for="item in trendSnapshot.items"
|
||||
:key="item.label"
|
||||
>
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -514,6 +679,20 @@ onMounted(() => {
|
||||
队列、调度器和关键系统状态。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-actions panel-actions--dark">
|
||||
<button
|
||||
type="button"
|
||||
class="system-action-button"
|
||||
aria-haspopup="dialog"
|
||||
@click="failedJobsDialogVisible = true"
|
||||
>
|
||||
查看报错详情
|
||||
</button>
|
||||
<span class="system-panel__meta">
|
||||
当前失败 {{ formatCompactNumber(queueStats?.failedJobs ?? 0) }} 条
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="panel-state panel-state--dark" v-if="systemLoading">系统状态同步中…</div>
|
||||
@@ -541,35 +720,62 @@ onMounted(() => {
|
||||
<h2>节点流量排行</h2>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<button
|
||||
v-for="option in rankPresetOptions"
|
||||
:key="`node-${option.value}`"
|
||||
type="button"
|
||||
class="filter-pill"
|
||||
:class="{ active: option.value === rankPreset }"
|
||||
@click="rankPreset = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
<div class="panel-actions">
|
||||
<div class="filter-group">
|
||||
<button
|
||||
v-for="option in rankPresetOptions"
|
||||
:key="`node-${option.value}`"
|
||||
type="button"
|
||||
class="filter-pill"
|
||||
:class="{ active: option.value === rankPreset }"
|
||||
:aria-pressed="option.value === rankPreset"
|
||||
@click="rankPreset = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-group filter-group--segmented" aria-label="节点排行显示数量">
|
||||
<button
|
||||
v-for="option in rankDisplayOptions"
|
||||
:key="`node-limit-${option.value}`"
|
||||
type="button"
|
||||
class="filter-pill"
|
||||
:class="{ active: option.value === nodeRankLimit }"
|
||||
:aria-pressed="option.value === nodeRankLimit"
|
||||
@click="nodeRankLimit = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="panel-state" v-if="rankLoading">排行数据同步中…</div>
|
||||
<div v-if="nodeRanks.length" class="rank-list">
|
||||
<div v-for="(item, index) in nodeRanks.slice(0, 6)" :key="item.id" class="rank-item">
|
||||
<div class="rank-item__copy">
|
||||
<strong>{{ item.name }}</strong>
|
||||
<span>{{ formatTraffic(item.value) }}</span>
|
||||
<div
|
||||
v-else-if="nodeRanks.length"
|
||||
:class="rankScrollClass(nodeRankLimit)"
|
||||
>
|
||||
<div class="rank-list">
|
||||
<div
|
||||
v-for="(item, index) in displayedNodeRanks"
|
||||
:key="item.id"
|
||||
class="rank-item"
|
||||
>
|
||||
<div class="rank-item__copy">
|
||||
<strong>{{ item.name }}</strong>
|
||||
<span>{{ formatTraffic(item.value) }}</span>
|
||||
</div>
|
||||
<div class="rank-item__bar">
|
||||
<span :style="{ width: rankBarWidth(index) }" />
|
||||
</div>
|
||||
<em :class="Number(item.change) >= 0 ? 'positive' : 'negative'">
|
||||
{{ formatPercent(item.change) }}
|
||||
</em>
|
||||
</div>
|
||||
<div class="rank-item__bar">
|
||||
<span :style="{ width: rankBarWidth(index) }" />
|
||||
</div>
|
||||
<em :class="Number(item.change) >= 0 ? 'positive' : 'negative'">
|
||||
{{ formatPercent(item.change) }}
|
||||
</em>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="panel-state">暂无节点排行数据</div>
|
||||
</article>
|
||||
|
||||
<article class="panel rank-panel">
|
||||
@@ -579,37 +785,66 @@ onMounted(() => {
|
||||
<h2>用户流量排行</h2>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<button
|
||||
v-for="option in rankPresetOptions"
|
||||
:key="`user-${option.value}`"
|
||||
type="button"
|
||||
class="filter-pill"
|
||||
:class="{ active: option.value === rankPreset }"
|
||||
@click="rankPreset = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
<div class="panel-actions">
|
||||
<div class="filter-group">
|
||||
<button
|
||||
v-for="option in rankPresetOptions"
|
||||
:key="`user-${option.value}`"
|
||||
type="button"
|
||||
class="filter-pill"
|
||||
:class="{ active: option.value === rankPreset }"
|
||||
:aria-pressed="option.value === rankPreset"
|
||||
@click="rankPreset = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-group filter-group--segmented" aria-label="用户排行显示数量">
|
||||
<button
|
||||
v-for="option in rankDisplayOptions"
|
||||
:key="`user-limit-${option.value}`"
|
||||
type="button"
|
||||
class="filter-pill"
|
||||
:class="{ active: option.value === userRankLimit }"
|
||||
:aria-pressed="option.value === userRankLimit"
|
||||
@click="userRankLimit = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="panel-state" v-if="rankLoading">排行数据同步中…</div>
|
||||
<div v-if="userRanks.length" class="rank-list">
|
||||
<div v-for="(item, index) in userRanks.slice(0, 6)" :key="item.id" class="rank-item">
|
||||
<div class="rank-item__copy">
|
||||
<strong>{{ item.name }}</strong>
|
||||
<span>{{ formatTraffic(item.value) }}</span>
|
||||
<div
|
||||
v-else-if="userRanks.length"
|
||||
:class="rankScrollClass(userRankLimit)"
|
||||
>
|
||||
<div class="rank-list">
|
||||
<div
|
||||
v-for="(item, index) in displayedUserRanks"
|
||||
:key="item.id"
|
||||
class="rank-item"
|
||||
>
|
||||
<div class="rank-item__copy">
|
||||
<strong>{{ item.name }}</strong>
|
||||
<span>{{ formatTraffic(item.value) }}</span>
|
||||
</div>
|
||||
<div class="rank-item__bar">
|
||||
<span :style="{ width: rankBarWidth(index) }" />
|
||||
</div>
|
||||
<em :class="Number(item.change) >= 0 ? 'positive' : 'negative'">
|
||||
{{ formatPercent(item.change) }}
|
||||
</em>
|
||||
</div>
|
||||
<div class="rank-item__bar">
|
||||
<span :style="{ width: rankBarWidth(index) }" />
|
||||
</div>
|
||||
<em :class="Number(item.change) >= 0 ? 'positive' : 'negative'">
|
||||
{{ formatPercent(item.change) }}
|
||||
</em>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="panel-state">暂无用户排行数据</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<QueueFailedJobsDialog v-model:visible="failedJobsDialogVisible" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -667,18 +902,68 @@ onMounted(() => {
|
||||
.hero-status {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.hero-status__copy {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.hero-status strong {
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hero-status__copy p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
}
|
||||
|
||||
.dashboard-refresh-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #ffffff;
|
||||
padding: 11px 18px;
|
||||
min-height: 44px;
|
||||
cursor: pointer;
|
||||
transition: transform 180ms ease, background-color 180ms ease, border-color 180ms ease;
|
||||
}
|
||||
|
||||
.dashboard-refresh-button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
|
||||
.dashboard-refresh-button:focus-visible {
|
||||
outline: 2px solid rgba(0, 113, 227, 0.88);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.dashboard-refresh-button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.dashboard-refresh-button__icon {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.dashboard-refresh-button__icon.spinning {
|
||||
animation: dashboard-refresh-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.hero-highlights {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
@@ -806,6 +1091,16 @@ onMounted(() => {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.panel-actions--dark {
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
margin: 0;
|
||||
font-size: 32px;
|
||||
@@ -823,12 +1118,47 @@ onMounted(() => {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.system-action-button {
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #ffffff;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.18s ease, border-color 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.system-action-button:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
|
||||
.system-action-button:focus-visible {
|
||||
outline: 2px solid rgba(41, 151, 255, 0.72);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.system-action-button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.system-panel__meta {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-group--segmented {
|
||||
padding: 4px;
|
||||
border-radius: 999px;
|
||||
background: #f5f5f7;
|
||||
}
|
||||
|
||||
.filter-pill {
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 999px;
|
||||
@@ -836,6 +1166,7 @@ onMounted(() => {
|
||||
padding: 10px 14px;
|
||||
color: var(--xboard-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.18s ease, background-color 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
|
||||
.filter-pill.active {
|
||||
@@ -844,6 +1175,11 @@ onMounted(() => {
|
||||
background: rgba(0, 113, 227, 0.08);
|
||||
}
|
||||
|
||||
.filter-pill:focus-visible {
|
||||
outline: 2px solid rgba(0, 113, 227, 0.36);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.trend-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@@ -928,6 +1264,34 @@ onMounted(() => {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.rank-scroll {
|
||||
max-height: 368px;
|
||||
overflow-y: auto;
|
||||
padding-right: 6px;
|
||||
margin-right: -6px;
|
||||
scrollbar-gutter: stable;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0, 113, 227, 0.22) transparent;
|
||||
}
|
||||
|
||||
.rank-scroll--extended {
|
||||
max-height: 516px;
|
||||
}
|
||||
|
||||
.rank-scroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.rank-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.rank-scroll::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 113, 227, 0.22);
|
||||
}
|
||||
|
||||
.rank-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 150px auto;
|
||||
@@ -1048,6 +1412,25 @@ onMounted(() => {
|
||||
padding: 28px 24px;
|
||||
}
|
||||
|
||||
.hero-status,
|
||||
.panel-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hero-status {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dashboard-refresh-button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.rank-scroll,
|
||||
.rank-scroll--extended {
|
||||
max-height: 460px;
|
||||
}
|
||||
|
||||
.metrics-grid,
|
||||
.content-grid,
|
||||
.rank-grid,
|
||||
@@ -1061,4 +1444,14 @@ onMounted(() => {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dashboard-refresh-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getHorizonFailedJobs } from '@/api/admin'
|
||||
import type { AdminQueueFailedJob } from '@/types/api'
|
||||
import { formatCompactNumber, formatDateTime } from '@/utils/dashboard'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
}>()
|
||||
|
||||
type LooseRecord = Record<string, unknown>
|
||||
|
||||
const loading = ref(false)
|
||||
const records = ref<AdminQueueFailedJob[]>([])
|
||||
const total = ref(0)
|
||||
const current = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const latestFailedJob = computed(() => records.value[0] ?? null)
|
||||
const summaryCards = computed(() => [
|
||||
{
|
||||
label: '失败总数',
|
||||
value: formatCompactNumber(total.value),
|
||||
detail: `当前页 ${records.value.length} 条`,
|
||||
},
|
||||
{
|
||||
label: '最近失败时间',
|
||||
value: latestFailedJob.value ? formatFailedAt(latestFailedJob.value) : 'N/A',
|
||||
detail: '按最新失败时间倒序展示',
|
||||
},
|
||||
{
|
||||
label: '最近失败队列',
|
||||
value: latestFailedJob.value ? getQueueName(latestFailedJob.value) : 'N/A',
|
||||
detail: latestFailedJob.value ? getJobName(latestFailedJob.value) : '暂无失败作业',
|
||||
},
|
||||
])
|
||||
|
||||
function isLooseRecord(value: unknown): value is LooseRecord {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function getByPath(source: LooseRecord | null, path: string): unknown {
|
||||
if (!source) return undefined
|
||||
|
||||
return path.split('.').reduce<unknown>((current, segment) => {
|
||||
if (!isLooseRecord(current)) return undefined
|
||||
return current[segment]
|
||||
}, source)
|
||||
}
|
||||
|
||||
function firstText(...values: unknown[]): string | null {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getPayload(record: AdminQueueFailedJob): LooseRecord | null {
|
||||
if (isLooseRecord(record.payload)) {
|
||||
return record.payload
|
||||
}
|
||||
|
||||
if (typeof record.payload === 'string' && record.payload.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(record.payload)
|
||||
return isLooseRecord(parsed) ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getIdentifier(record: AdminQueueFailedJob): string {
|
||||
return firstText(record.id, record.uuid) ?? 'unknown'
|
||||
}
|
||||
|
||||
function getJobName(record: AdminQueueFailedJob): string {
|
||||
const payload = getPayload(record)
|
||||
|
||||
return firstText(
|
||||
record.name,
|
||||
getByPath(payload, 'displayName'),
|
||||
getByPath(payload, 'data.commandName'),
|
||||
getByPath(payload, 'job'),
|
||||
record.uuid,
|
||||
record.id,
|
||||
) ?? '未知任务'
|
||||
}
|
||||
|
||||
function getQueueName(record: AdminQueueFailedJob): string {
|
||||
const payload = getPayload(record)
|
||||
|
||||
return firstText(
|
||||
record.queue,
|
||||
getByPath(payload, 'queue'),
|
||||
record.connection,
|
||||
) ?? 'N/A'
|
||||
}
|
||||
|
||||
function getFailureTime(record: AdminQueueFailedJob): number | string | null {
|
||||
const payload = getPayload(record)
|
||||
|
||||
return (
|
||||
firstText(
|
||||
record.failed_at,
|
||||
record['failedAt'],
|
||||
getByPath(payload, 'failed_at'),
|
||||
record['completed_at'],
|
||||
record['completedAt'],
|
||||
) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
function formatFailedAt(record: AdminQueueFailedJob): string {
|
||||
return formatDateTime(getFailureTime(record))
|
||||
}
|
||||
|
||||
function getErrorMessage(record: AdminQueueFailedJob): string {
|
||||
const payload = getPayload(record)
|
||||
|
||||
return firstText(
|
||||
record.exception,
|
||||
record['message'],
|
||||
getByPath(payload, 'exception'),
|
||||
getByPath(payload, 'message'),
|
||||
) ?? '暂无错误详情'
|
||||
}
|
||||
|
||||
function getErrorSummary(record: AdminQueueFailedJob): string {
|
||||
const rawMessage = getErrorMessage(record)
|
||||
const lines = rawMessage
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
const firstLine = lines[0] ?? rawMessage
|
||||
|
||||
return firstLine.length > 180
|
||||
? `${firstLine.slice(0, 177)}…`
|
||||
: firstLine
|
||||
}
|
||||
|
||||
async function loadRecords() {
|
||||
if (!props.visible) {
|
||||
records.value = []
|
||||
total.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await getHorizonFailedJobs({
|
||||
current: current.value,
|
||||
pageSize: pageSize.value,
|
||||
})
|
||||
|
||||
records.value = response.data ?? []
|
||||
total.value = response.total ?? 0
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '失败作业列表加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
if (current.value !== 1) {
|
||||
current.value = 1
|
||||
return
|
||||
}
|
||||
|
||||
void loadRecords()
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
async (visible) => {
|
||||
if (!visible) {
|
||||
records.value = []
|
||||
total.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
current.value = 1
|
||||
await loadRecords()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch([current, pageSize], () => {
|
||||
if (!props.visible) {
|
||||
return
|
||||
}
|
||||
|
||||
void loadRecords()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDialog
|
||||
:model-value="props.visible"
|
||||
width="860px"
|
||||
class="queue-failed-jobs-dialog"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
@close="closeDialog"
|
||||
@update:model-value="emit('update:visible', $event)"
|
||||
>
|
||||
<template #header>
|
||||
<div class="dialog-header">
|
||||
<div>
|
||||
<p>Queue Failures</p>
|
||||
<h2>失败作业报错详情</h2>
|
||||
</div>
|
||||
<span>共 {{ total }} 条失败作业</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="dialog-body">
|
||||
<div class="dialog-toolbar">
|
||||
<p>集中查看 Horizon 失败作业的报错摘要、失败时间与队列信息。</p>
|
||||
<ElButton text class="ghost-action" :loading="loading" @click="handleRefresh">
|
||||
重新加载
|
||||
</ElButton>
|
||||
</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 v-if="!loading && !records.length" class="empty-shell">
|
||||
<ElEmpty description="当前没有失败作业" />
|
||||
</div>
|
||||
|
||||
<div v-else class="error-list" v-loading="loading">
|
||||
<article
|
||||
v-for="(record, index) in records"
|
||||
:key="`${getIdentifier(record)}-${getFailureTime(record) ?? index}`"
|
||||
class="error-card"
|
||||
>
|
||||
<div class="error-card__header">
|
||||
<div>
|
||||
<p>{{ getJobName(record) }}</p>
|
||||
<span>#{{ getIdentifier(record) }}</span>
|
||||
</div>
|
||||
<strong>{{ getQueueName(record) }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="error-card__meta">
|
||||
<span>失败时间</span>
|
||||
<strong>{{ formatFailedAt(record) }}</strong>
|
||||
<span>报错摘要</span>
|
||||
<strong class="error-card__summary" :title="getErrorMessage(record)">
|
||||
{{ getErrorSummary(record) }}
|
||||
</strong>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<footer class="dialog-footer">
|
||||
<span>当前第 {{ current }} 页,每页 {{ pageSize }} 条</span>
|
||||
<ElPagination
|
||||
v-model:current-page="current"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
:total="total"
|
||||
background
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dialog-header p {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: var(--xboard-text-muted);
|
||||
}
|
||||
|
||||
.dialog-header h2 {
|
||||
font-size: 30px;
|
||||
line-height: 1.08;
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.dialog-header span {
|
||||
color: var(--xboard-text-secondary);
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dialog-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dialog-toolbar p {
|
||||
margin: 0;
|
||||
color: var(--xboard-text-secondary);
|
||||
}
|
||||
|
||||
.ghost-action {
|
||||
color: #0071e3;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summary-grid article {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 16px;
|
||||
background: #f5f5f7;
|
||||
}
|
||||
|
||||
.summary-grid span,
|
||||
.summary-grid p {
|
||||
margin: 0;
|
||||
color: var(--xboard-text-muted);
|
||||
}
|
||||
|
||||
.summary-grid strong {
|
||||
color: var(--xboard-text-strong);
|
||||
font-size: 22px;
|
||||
line-height: 1.14;
|
||||
}
|
||||
|
||||
.error-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.error-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 18px 20px;
|
||||
border-radius: 18px;
|
||||
background: #fbfbfd;
|
||||
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.error-card__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.error-card__header p,
|
||||
.error-card__header span,
|
||||
.error-card__meta span {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error-card__header p {
|
||||
color: var(--xboard-text-strong);
|
||||
font-size: 18px;
|
||||
line-height: 1.24;
|
||||
}
|
||||
|
||||
.error-card__header span {
|
||||
color: var(--xboard-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.error-card__header strong,
|
||||
.error-card__meta strong {
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.error-card__meta {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
gap: 8px 14px;
|
||||
}
|
||||
|
||||
.error-card__meta span {
|
||||
color: var(--xboard-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.error-card__summary {
|
||||
line-height: 1.5;
|
||||
color: #b42318;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.empty-shell {
|
||||
padding: 24px 0;
|
||||
border-radius: 18px;
|
||||
background: #fbfbfd;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dialog-footer span {
|
||||
color: var(--xboard-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.dialog-header,
|
||||
.dialog-toolbar,
|
||||
.error-card__header,
|
||||
.dialog-footer {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.error-card__meta {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
const milestones = [
|
||||
'接入权限组列表与用户 / 节点引用统计',
|
||||
'补齐新增、编辑、删除与使用冲突提示',
|
||||
'联动节点页的权限组筛选与维护闭环',
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="placeholder-page">
|
||||
<section class="placeholder-hero">
|
||||
<div class="placeholder-copy">
|
||||
<p class="placeholder-kicker">Node Groups</p>
|
||||
<h1>权限组管理</h1>
|
||||
<span>入口已预留。本轮先完成节点列表主链路,下一阶段继续接入权限组的真实维护能力。</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="placeholder-card">
|
||||
<header>
|
||||
<h2>下一阶段计划</h2>
|
||||
<p>这一页不会空着结束,而是明确告诉你后续要接什么。</p>
|
||||
</header>
|
||||
|
||||
<ol>
|
||||
<li v-for="item in milestones" :key="item">{{ item }}</li>
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.placeholder-page {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.placeholder-hero {
|
||||
padding: 30px 32px;
|
||||
border-radius: 28px;
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.placeholder-copy {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.placeholder-kicker {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
}
|
||||
|
||||
.placeholder-copy h1 {
|
||||
font-size: clamp(34px, 5vw, 48px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.28px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.placeholder-copy span {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
line-height: 1.47;
|
||||
}
|
||||
|
||||
.placeholder-card {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 28px;
|
||||
border-radius: 24px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--xboard-shadow);
|
||||
}
|
||||
|
||||
.placeholder-card header {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.placeholder-card h2 {
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.placeholder-card p,
|
||||
.placeholder-card li {
|
||||
color: var(--xboard-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.placeholder-card ol {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
const milestones = [
|
||||
'接入路由规则列表、动作类型与备注字段',
|
||||
'补齐新增 / 编辑 / 删除路由的操作台',
|
||||
'与节点页建立路由引用可视化关系,方便运营判断影响面',
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="placeholder-page">
|
||||
<section class="placeholder-hero">
|
||||
<div class="placeholder-copy">
|
||||
<p class="placeholder-kicker">Node Routes</p>
|
||||
<h1>路由管理</h1>
|
||||
<span>侧边栏入口已对齐,下一阶段将继续补齐路由规则列表与节点引用关系。</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="placeholder-card">
|
||||
<header>
|
||||
<h2>接下来会补什么</h2>
|
||||
<p>本轮先把节点管理主链路落稳,路由管理不留空白,先把后续接入方向固定下来。</p>
|
||||
</header>
|
||||
|
||||
<ol>
|
||||
<li v-for="item in milestones" :key="item">{{ item }}</li>
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.placeholder-page {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.placeholder-hero {
|
||||
padding: 30px 32px;
|
||||
border-radius: 28px;
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.placeholder-copy {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.placeholder-kicker {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
}
|
||||
|
||||
.placeholder-copy h1 {
|
||||
font-size: clamp(34px, 5vw, 48px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.28px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.placeholder-copy span {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
line-height: 1.47;
|
||||
}
|
||||
|
||||
.placeholder-card {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 28px;
|
||||
border-radius: 24px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--xboard-shadow);
|
||||
}
|
||||
|
||||
.placeholder-card header {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.placeholder-card h2 {
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.placeholder-card p,
|
||||
.placeholder-card li {
|
||||
color: var(--xboard-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.placeholder-card ol {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,628 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Connection,
|
||||
MoreFilled,
|
||||
Plus,
|
||||
RefreshRight,
|
||||
Search,
|
||||
User,
|
||||
} from '@element-plus/icons-vue'
|
||||
import {
|
||||
copyNode,
|
||||
deleteNode,
|
||||
fetchNodes,
|
||||
getServerGroups,
|
||||
updateNode,
|
||||
} from '@/api/admin'
|
||||
import type { AdminNodeItem, AdminServerGroupItem } from '@/types/api'
|
||||
import {
|
||||
buildNodeTypeOptions,
|
||||
countOnlineNodes,
|
||||
countVisibleNodes,
|
||||
filterNodes,
|
||||
formatNodeRate,
|
||||
getNodeAddress,
|
||||
getNodeGroupNames,
|
||||
getNodeIdLabel,
|
||||
getNodeStatusMeta,
|
||||
getNodeTypeLabel,
|
||||
} from '@/utils/nodes'
|
||||
|
||||
type NodeAction = 'edit' | 'copy' | 'delete'
|
||||
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const nodes = ref<AdminNodeItem[]>([])
|
||||
const groups = ref<AdminServerGroupItem[]>([])
|
||||
const keyword = ref('')
|
||||
const typeFilter = ref('all')
|
||||
const groupFilter = ref('all')
|
||||
const switchingIds = ref<number[]>([])
|
||||
const workingIds = ref<number[]>([])
|
||||
|
||||
const filteredNodes = computed(() => filterNodes(
|
||||
nodes.value,
|
||||
keyword.value,
|
||||
typeFilter.value,
|
||||
groupFilter.value,
|
||||
))
|
||||
|
||||
const typeOptions = computed(() => buildNodeTypeOptions(nodes.value))
|
||||
const hasActiveFilters = computed(() => keyword.value !== '' || typeFilter.value !== 'all' || groupFilter.value !== 'all')
|
||||
|
||||
const summaryCards = computed(() => [
|
||||
{ label: '节点总数', value: String(nodes.value.length) },
|
||||
{ label: '在线节点', value: String(countOnlineNodes(nodes.value)) },
|
||||
{ label: '显示中', value: String(countVisibleNodes(nodes.value)) },
|
||||
{ label: '当前结果', value: String(filteredNodes.value.length) },
|
||||
])
|
||||
|
||||
function markPending(list: typeof switchingIds, id: number, pending: boolean) {
|
||||
if (pending) {
|
||||
if (!list.value.includes(id)) {
|
||||
list.value = [...list.value, id]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
list.value = list.value.filter((item) => item !== id)
|
||||
}
|
||||
|
||||
function isSwitching(id: number): boolean {
|
||||
return switchingIds.value.includes(id)
|
||||
}
|
||||
|
||||
function isWorking(id: number): boolean {
|
||||
return workingIds.value.includes(id)
|
||||
}
|
||||
|
||||
function notifyPending(scope: string) {
|
||||
ElMessage.info(`${scope} 会在下一阶段接入,本轮已先打通节点列表主链路。`)
|
||||
}
|
||||
|
||||
async function loadNodeBoard() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const [nodesResponse, groupsResponse] = await Promise.all([
|
||||
fetchNodes(),
|
||||
getServerGroups(),
|
||||
])
|
||||
|
||||
nodes.value = nodesResponse.data ?? []
|
||||
groups.value = groupsResponse.data ?? []
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '节点数据加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
keyword.value = ''
|
||||
typeFilter.value = 'all'
|
||||
groupFilter.value = 'all'
|
||||
}
|
||||
|
||||
async function handleToggleShow(node: AdminNodeItem, nextValue: boolean) {
|
||||
const previous = Boolean(node.show)
|
||||
if (previous === nextValue) {
|
||||
return
|
||||
}
|
||||
|
||||
node.show = nextValue
|
||||
markPending(switchingIds, node.id, true)
|
||||
|
||||
try {
|
||||
await updateNode({
|
||||
id: node.id,
|
||||
show: nextValue ? 1 : 0,
|
||||
})
|
||||
ElMessage.success(nextValue ? '节点已显示' : '节点已隐藏')
|
||||
} catch (error) {
|
||||
node.show = previous
|
||||
ElMessage.error(error instanceof Error ? error.message : '显隐状态更新失败')
|
||||
} finally {
|
||||
markPending(switchingIds, node.id, false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAction(action: NodeAction, node: AdminNodeItem) {
|
||||
if (action === 'edit') {
|
||||
notifyPending(`编辑节点 #${node.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
markPending(workingIds, node.id, true)
|
||||
|
||||
try {
|
||||
if (action === 'copy') {
|
||||
await copyNode(node.id)
|
||||
ElMessage.success('节点已复制')
|
||||
await loadNodeBoard()
|
||||
return
|
||||
}
|
||||
|
||||
await ElMessageBox.confirm(
|
||||
`删除节点 “${node.name}” 后无法恢复,确认继续吗?`,
|
||||
'删除节点',
|
||||
{ type: 'warning' },
|
||||
)
|
||||
|
||||
await deleteNode(node.id)
|
||||
ElMessage.success('节点已删除')
|
||||
await loadNodeBoard()
|
||||
} catch (error) {
|
||||
if (action === 'delete' && (error === 'cancel' || error === 'close')) {
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.error(error instanceof Error ? error.message : '节点操作失败')
|
||||
} finally {
|
||||
markPending(workingIds, node.id, false)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadNodeBoard()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="nodes-page">
|
||||
<section class="nodes-hero">
|
||||
<div class="nodes-copy">
|
||||
<p class="nodes-kicker">Nodes</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="nodes-board">
|
||||
<header class="board-toolbar">
|
||||
<div class="toolbar-fields">
|
||||
<ElButton type="primary" @click="notifyPending('添加节点')">
|
||||
<ElIcon><Plus /></ElIcon>
|
||||
添加节点
|
||||
</ElButton>
|
||||
|
||||
<ElInput
|
||||
v-model="keyword"
|
||||
clearable
|
||||
placeholder="搜索节点..."
|
||||
class="toolbar-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<ElIcon><Search /></ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
|
||||
<ElSelect v-model="typeFilter" class="toolbar-select" placeholder="类型">
|
||||
<ElOption label="全部类型" value="all" />
|
||||
<ElOption
|
||||
v-for="option in typeOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
|
||||
<ElSelect v-model="groupFilter" class="toolbar-select" placeholder="权限组">
|
||||
<ElOption label="全部权限组" value="all" />
|
||||
<ElOption
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
:label="group.name"
|
||||
:value="String(group.id)"
|
||||
/>
|
||||
</ElSelect>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-actions">
|
||||
<ElButton @click="handleReset" :disabled="!hasActiveFilters">
|
||||
<ElIcon><RefreshRight /></ElIcon>
|
||||
重置筛选
|
||||
</ElButton>
|
||||
<ElButton @click="notifyPending('编辑排序')">编辑排序</ElButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ElAlert
|
||||
v-if="errorMessage"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="board-alert"
|
||||
:title="errorMessage"
|
||||
>
|
||||
<template #default>
|
||||
<ElButton text @click="loadNodeBoard">重新加载</ElButton>
|
||||
</template>
|
||||
</ElAlert>
|
||||
|
||||
<ElTable
|
||||
:data="filteredNodes"
|
||||
v-loading="loading"
|
||||
row-key="id"
|
||||
class="nodes-table"
|
||||
>
|
||||
<ElTableColumn label="节点ID" width="132">
|
||||
<template #default="{ row }">
|
||||
<ElTag
|
||||
round
|
||||
effect="plain"
|
||||
:type="row.parent_id ? 'warning' : 'success'"
|
||||
class="id-tag"
|
||||
>
|
||||
{{ getNodeIdLabel(row) }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="显隐" width="96">
|
||||
<template #default="{ row }">
|
||||
<div
|
||||
class="switch-shell"
|
||||
:style="{ '--node-switch-color': row.parent_id ? '#7c5cff' : '#22c55e' }"
|
||||
>
|
||||
<ElSwitch
|
||||
:model-value="Boolean(row.show)"
|
||||
:loading="isSwitching(row.id)"
|
||||
@change="(value) => handleToggleShow(row, Boolean(value))"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="节点" min-width="280">
|
||||
<template #default="{ row }">
|
||||
<div class="node-cell">
|
||||
<div class="node-cell__main">
|
||||
<span class="node-dot" :class="getNodeStatusMeta(row).dotClass" />
|
||||
<strong>{{ row.name }}</strong>
|
||||
</div>
|
||||
<div class="node-cell__sub">
|
||||
<ElTag round effect="plain" :type="getNodeStatusMeta(row).tagType">
|
||||
{{ getNodeStatusMeta(row).label }}
|
||||
</ElTag>
|
||||
<span>{{ getNodeTypeLabel(row.type) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="地址" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<div class="stack-cell">
|
||||
<strong>{{ getNodeAddress(row).primary }}</strong>
|
||||
<span>{{ getNodeAddress(row).secondary }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="在线人数" width="116">
|
||||
<template #default="{ row }">
|
||||
<div class="online-cell">
|
||||
<span class="online-cell__primary">
|
||||
<ElIcon><User /></ElIcon>
|
||||
{{ row.online }}
|
||||
</span>
|
||||
<span>连接 {{ row.online_conn }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="倍率" width="96">
|
||||
<template #default="{ row }">
|
||||
<ElTag round effect="plain" class="rate-tag">
|
||||
{{ formatNodeRate(row.rate) }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="权限组" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="group-tags">
|
||||
<ElTag
|
||||
v-for="groupName in getNodeGroupNames(row)"
|
||||
:key="`${row.id}-${groupName}`"
|
||||
round
|
||||
effect="plain"
|
||||
>
|
||||
{{ groupName }}
|
||||
</ElTag>
|
||||
<span v-if="getNodeGroupNames(row).length === 0" class="muted-copy">未分配</span>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="操作" width="92" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<ElDropdown
|
||||
trigger="click"
|
||||
@command="(command) => handleAction(command as NodeAction, row)"
|
||||
>
|
||||
<ElButton
|
||||
text
|
||||
class="action-trigger"
|
||||
:loading="isWorking(row.id)"
|
||||
>
|
||||
<ElIcon><MoreFilled /></ElIcon>
|
||||
</ElButton>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem command="edit">编辑节点(下一阶段)</ElDropdownItem>
|
||||
<ElDropdownItem command="copy">复制节点</ElDropdownItem>
|
||||
<ElDropdownItem command="delete" divided>删除节点</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<template #empty>
|
||||
<div class="table-empty">
|
||||
<ElEmpty
|
||||
:description="hasActiveFilters ? '当前筛选条件下暂无节点。' : '暂无节点数据。'"
|
||||
>
|
||||
<ElButton v-if="hasActiveFilters" @click="handleReset">清空筛选</ElButton>
|
||||
<ElButton v-else @click="loadNodeBoard">
|
||||
<ElIcon><RefreshRight /></ElIcon>
|
||||
重新加载
|
||||
</ElButton>
|
||||
</ElEmpty>
|
||||
</div>
|
||||
</template>
|
||||
</ElTable>
|
||||
|
||||
<footer class="board-footer">
|
||||
<span>已显示 {{ filteredNodes.length }} / {{ nodes.length }} 个节点</span>
|
||||
<div class="footer-hint">
|
||||
<ElIcon><Connection /></ElIcon>
|
||||
<span>完整的节点创建、编辑与排序流程将在下一阶段补齐。</span>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nodes-page {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.nodes-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 30px 32px;
|
||||
border-radius: 28px;
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.nodes-copy {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-width: 680px;
|
||||
}
|
||||
|
||||
.nodes-kicker {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
}
|
||||
|
||||
.nodes-copy h1 {
|
||||
font-size: clamp(36px, 5vw, 52px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.28px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.nodes-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;
|
||||
}
|
||||
|
||||
.nodes-board {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 24px;
|
||||
border-radius: 26px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--xboard-shadow);
|
||||
}
|
||||
|
||||
.board-toolbar,
|
||||
.toolbar-fields,
|
||||
.toolbar-actions,
|
||||
.board-footer,
|
||||
.footer-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.board-toolbar,
|
||||
.board-footer {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.toolbar-fields {
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-input {
|
||||
width: min(320px, 100%);
|
||||
}
|
||||
|
||||
.toolbar-select {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.board-alert {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.nodes-table :deep(th.el-table__cell) {
|
||||
color: var(--xboard-text-secondary);
|
||||
background: #fbfbfd;
|
||||
}
|
||||
|
||||
.nodes-table :deep(.el-table__row td.el-table__cell) {
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.switch-shell :deep(.el-switch) {
|
||||
--el-switch-on-color: var(--node-switch-color);
|
||||
}
|
||||
|
||||
.node-cell,
|
||||
.stack-cell,
|
||||
.online-cell {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.node-cell__main,
|
||||
.node-cell__sub,
|
||||
.online-cell__primary,
|
||||
.footer-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.node-cell__main strong,
|
||||
.stack-cell strong {
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.node-cell__sub span,
|
||||
.stack-cell span,
|
||||
.online-cell span,
|
||||
.board-footer span,
|
||||
.muted-copy {
|
||||
color: var(--xboard-text-muted);
|
||||
}
|
||||
|
||||
.node-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-dot.online {
|
||||
background: #34c759;
|
||||
}
|
||||
|
||||
.node-dot.pending {
|
||||
background: #f5a623;
|
||||
}
|
||||
|
||||
.node-dot.offline {
|
||||
background: #ff5f57;
|
||||
}
|
||||
|
||||
.node-dot.disabled {
|
||||
background: #9ca3af;
|
||||
}
|
||||
|
||||
.group-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rate-tag,
|
||||
.id-tag {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.action-trigger {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.table-empty {
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.board-footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.footer-hint {
|
||||
justify-content: flex-end;
|
||||
color: var(--xboard-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.nodes-hero,
|
||||
.board-toolbar,
|
||||
.board-footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.hero-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.footer-hint {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,159 @@
|
||||
.drawer-shell,
|
||||
.drawer-form {
|
||||
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-grid,
|
||||
.price-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px 16px;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tag-input-shell,
|
||||
.description-panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.price-panel,
|
||||
.description-panel {
|
||||
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 {
|
||||
font-size: 18px;
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.section-header span {
|
||||
color: var(--xboard-text-muted);
|
||||
line-height: 1.47;
|
||||
}
|
||||
|
||||
.section-actions,
|
||||
.drawer-actions,
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.description-editor,
|
||||
.description-preview {
|
||||
min-height: 220px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.description-editor {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
color: var(--xboard-text-strong);
|
||||
font: inherit;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.description-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;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.drawer-grid,
|
||||
.price-grid,
|
||||
.section-header,
|
||||
.drawer-footer {
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.section-actions,
|
||||
.drawer-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
<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 { savePlan } from '@/api/admin'
|
||||
import type { AdminPlanListItem, AdminServerGroupItem } from '@/types/api'
|
||||
import {
|
||||
DEFAULT_PLAN_DESCRIPTION_TEMPLATE,
|
||||
PLAN_PRICE_PERIODS,
|
||||
RESET_TRAFFIC_METHOD_OPTIONS,
|
||||
createEmptyPlanForm,
|
||||
normalizePlanTag,
|
||||
renderPlanContent,
|
||||
sanitizePlanPriceInput,
|
||||
toPlanFormModel,
|
||||
toPlanSavePayload,
|
||||
type PlanFormModel,
|
||||
} from '@/utils/plans'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
mode: 'create' | 'edit'
|
||||
plan?: AdminPlanListItem | null
|
||||
groups: AdminServerGroupItem[]
|
||||
}>()
|
||||
|
||||
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<PlanFormModel>(createEmptyPlanForm())
|
||||
|
||||
const drawerTitle = computed(() => props.mode === 'create' ? '添加套餐' : '编辑套餐')
|
||||
const renderedContent = computed(() => renderPlanContent(form.content))
|
||||
|
||||
const rules = computed<FormRules<PlanFormModel>>(() => ({
|
||||
name: [{ required: true, message: '请输入套餐名称', trigger: 'blur' }],
|
||||
transferEnableGb: [
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (!Number.isFinite(Number(value)) || Number(value) < 1) {
|
||||
callback(new Error('请输入大于等于 1 的流量值'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
function closeDrawer() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
function syncForm() {
|
||||
Object.assign(form, toPlanFormModel(props.plan))
|
||||
tagInput.value = ''
|
||||
previewVisible.value = false
|
||||
}
|
||||
|
||||
function handleTagConfirm() {
|
||||
const nextTag = normalizePlanTag(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 applyTemplate() {
|
||||
if (!form.content.trim()) {
|
||||
form.content = DEFAULT_PLAN_DESCRIPTION_TEMPLATE
|
||||
return
|
||||
}
|
||||
|
||||
if (!form.content.includes(DEFAULT_PLAN_DESCRIPTION_TEMPLATE)) {
|
||||
form.content = `${form.content.trim()}\n\n${DEFAULT_PLAN_DESCRIPTION_TEMPLATE}`
|
||||
}
|
||||
}
|
||||
|
||||
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 savePlan(toPlanSavePayload(form))
|
||||
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, props.plan, props.mode],
|
||||
([visible]) => {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
|
||||
syncForm()
|
||||
nextTick(() => {
|
||||
formRef.value?.clearValidate()
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDrawer
|
||||
:model-value="props.visible"
|
||||
:title="drawerTitle"
|
||||
size="min(560px, 100vw)"
|
||||
destroy-on-close
|
||||
class="plan-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>根据现有 `plan/*` 接口维护套餐结构、价格与说明内容。</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="标签">
|
||||
<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>
|
||||
|
||||
<div class="drawer-grid">
|
||||
<ElFormItem label="服务器分组">
|
||||
<ElSelect v-model="form.groupId" clearable placeholder="请选择分组">
|
||||
<ElOption
|
||||
v-for="group in props.groups"
|
||||
:key="group.id"
|
||||
:label="group.name"
|
||||
:value="group.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="流量" prop="transferEnableGb">
|
||||
<ElInputNumber
|
||||
v-model="form.transferEnableGb"
|
||||
:min="1"
|
||||
:controls="false"
|
||||
class="full-width"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="速度限制">
|
||||
<ElInputNumber
|
||||
v-model="form.speedLimit"
|
||||
:min="0"
|
||||
:controls="false"
|
||||
class="full-width"
|
||||
placeholder="请输入速度限制"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="设备限制">
|
||||
<ElInputNumber
|
||||
v-model="form.deviceLimit"
|
||||
:min="0"
|
||||
:controls="false"
|
||||
class="full-width"
|
||||
placeholder="请输入设备限制"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="容量限制">
|
||||
<ElInputNumber
|
||||
v-model="form.capacityLimit"
|
||||
:min="0"
|
||||
:controls="false"
|
||||
class="full-width"
|
||||
placeholder="请输入容量限制"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="流量重置方式">
|
||||
<ElSelect v-model="form.resetTrafficMethod" placeholder="请选择重置方式">
|
||||
<ElOption
|
||||
v-for="option in RESET_TRAFFIC_METHOD_OPTIONS"
|
||||
:key="String(option.value)"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
|
||||
<section class="price-panel">
|
||||
<header class="section-header">
|
||||
<div>
|
||||
<h3>价格设置</h3>
|
||||
<span>留空表示该周期不开放购买。</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="price-grid">
|
||||
<ElFormItem
|
||||
v-for="period in PLAN_PRICE_PERIODS"
|
||||
:key="period.key"
|
||||
:label="period.label"
|
||||
>
|
||||
<ElInput
|
||||
:model-value="form.prices[period.key]"
|
||||
placeholder="请输入价格"
|
||||
@update:model-value="form.prices[period.key] = sanitizePlanPriceInput($event)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="description-panel">
|
||||
<header class="section-header">
|
||||
<div>
|
||||
<h3>套餐说明</h3>
|
||||
<span>支持 Markdown 与基础 HTML 换行。</span>
|
||||
</div>
|
||||
|
||||
<div class="section-actions">
|
||||
<ElButton @click="applyTemplate">使用模板</ElButton>
|
||||
<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>
|
||||
|
||||
<div v-if="previewVisible" class="description-preview markdown-body" v-html="renderedContent" />
|
||||
<textarea
|
||||
v-else
|
||||
ref="contentEditorRef"
|
||||
v-model="form.content"
|
||||
class="description-editor"
|
||||
placeholder="请输入套餐说明,支持 Markdown 或 <br> 换行"
|
||||
/>
|
||||
</section>
|
||||
</ElForm>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<ElCheckbox v-if="props.mode === 'edit'" v-model="form.forceUpdate">
|
||||
强制更新用户套餐
|
||||
</ElCheckbox>
|
||||
<span v-else />
|
||||
|
||||
<div class="drawer-actions">
|
||||
<ElButton @click="closeDrawer">取消</ElButton>
|
||||
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
|
||||
{{ props.mode === 'create' ? '提交' : '保存修改' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss" src="./PlanEditorDrawer.scss"></style>
|
||||
@@ -0,0 +1,188 @@
|
||||
.plans-page {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.plans-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 30px 32px;
|
||||
border-radius: 28px;
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.plans-copy {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-width: 620px;
|
||||
}
|
||||
|
||||
.plans-kicker {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
}
|
||||
|
||||
.plans-copy h1 {
|
||||
font-size: clamp(34px, 5vw, 52px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.28px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.plans-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 {
|
||||
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%);
|
||||
}
|
||||
|
||||
.plans-table :deep(th.el-table__cell) {
|
||||
color: var(--xboard-text-secondary);
|
||||
background: #fbfbfd;
|
||||
}
|
||||
|
||||
.plans-table :deep(.el-table__row td.el-table__cell) {
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.name-cell,
|
||||
.price-cell,
|
||||
.metric-cell,
|
||||
.sort-shell,
|
||||
.sort-list,
|
||||
.sort-meta {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.name-cell strong,
|
||||
.sort-meta strong {
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.name-cell span,
|
||||
.table-footer span,
|
||||
.price-empty,
|
||||
.sort-copy,
|
||||
.sort-meta span {
|
||||
color: var(--xboard-text-muted);
|
||||
}
|
||||
|
||||
.price-cell,
|
||||
.metric-cell {
|
||||
grid-template-columns: repeat(auto-fit, minmax(104px, max-content));
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.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) {
|
||||
.plans-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,373 @@
|
||||
<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 {
|
||||
deletePlan,
|
||||
getPlans,
|
||||
getServerGroups,
|
||||
sortPlans,
|
||||
updatePlan,
|
||||
} from '@/api/admin'
|
||||
import type { AdminPlanListItem, AdminServerGroupItem } from '@/types/api'
|
||||
import {
|
||||
countEnabledPlans,
|
||||
filterPlans,
|
||||
formatPlanTraffic,
|
||||
getPlanPriceBadges,
|
||||
movePlanOrder,
|
||||
} from '@/utils/plans'
|
||||
import PlanEditorDrawer from './PlanEditorDrawer.vue'
|
||||
|
||||
type DrawerMode = 'create' | 'edit'
|
||||
type PlanToggleField = 'show' | 'sell' | 'renew'
|
||||
|
||||
const loading = ref(false)
|
||||
const sortSubmitting = ref(false)
|
||||
const drawerVisible = ref(false)
|
||||
const drawerMode = ref<DrawerMode>('create')
|
||||
const activePlan = ref<AdminPlanListItem | null>(null)
|
||||
const sortDialogVisible = ref(false)
|
||||
|
||||
const keyword = ref('')
|
||||
const current = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const plans = ref<AdminPlanListItem[]>([])
|
||||
const groups = ref<AdminServerGroupItem[]>([])
|
||||
const sortDraft = ref<AdminPlanListItem[]>([])
|
||||
const toggleLoadingMap = ref<Record<string, boolean>>({})
|
||||
|
||||
const filteredPlans = computed(() => filterPlans(plans.value, keyword.value))
|
||||
const visiblePlans = computed(() => {
|
||||
const start = (current.value - 1) * pageSize.value
|
||||
return filteredPlans.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
|
||||
const heroStats = computed(() => [
|
||||
{ label: '套餐总数', value: String(plans.value.length) },
|
||||
{ label: '展示中', value: String(countEnabledPlans(plans.value, 'show')) },
|
||||
{ label: '支持新购', value: String(countEnabledPlans(plans.value, 'sell')) },
|
||||
{ label: '支持续费', value: String(countEnabledPlans(plans.value, 'renew')) },
|
||||
])
|
||||
|
||||
function getToggleKey(id: number, field: PlanToggleField): string {
|
||||
return `${id}:${field}`
|
||||
}
|
||||
|
||||
function isToggleLoading(id: number, field: PlanToggleField): boolean {
|
||||
return Boolean(toggleLoadingMap.value[getToggleKey(id, field)])
|
||||
}
|
||||
|
||||
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))
|
||||
groups.value = groupsResponse.data ?? []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDrawer() {
|
||||
drawerMode.value = 'create'
|
||||
activePlan.value = null
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDrawer(plan: AdminPlanListItem) {
|
||||
drawerMode.value = 'edit'
|
||||
activePlan.value = plan
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
async function handleToggle(plan: AdminPlanListItem, field: PlanToggleField, nextValue: boolean | string | number) {
|
||||
const key = getToggleKey(plan.id, field)
|
||||
toggleLoadingMap.value[key] = true
|
||||
try {
|
||||
await updatePlan(plan.id, { [field]: Boolean(nextValue) })
|
||||
plan[field] = Boolean(nextValue)
|
||||
ElMessage.success('套餐状态已更新')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '套餐状态更新失败')
|
||||
} finally {
|
||||
toggleLoadingMap.value[key] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(plan: AdminPlanListItem) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`删除套餐「${plan.name}」后无法恢复,确认继续吗?`, '删除套餐', {
|
||||
type: 'warning',
|
||||
})
|
||||
await deletePlan(plan.id)
|
||||
ElMessage.success('套餐已删除')
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') {
|
||||
return
|
||||
}
|
||||
ElMessage.error(error instanceof Error ? error.message : '套餐删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function openSortEditor() {
|
||||
sortDraft.value = plans.value.map((plan) => ({ ...plan }))
|
||||
sortDialogVisible.value = true
|
||||
}
|
||||
|
||||
function moveDraft(index: number, direction: -1 | 1) {
|
||||
sortDraft.value = movePlanOrder(sortDraft.value, index, direction)
|
||||
}
|
||||
|
||||
async function submitSort() {
|
||||
sortSubmitting.value = true
|
||||
try {
|
||||
await sortPlans(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(filteredPlans, (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="plans-page">
|
||||
<section class="plans-hero">
|
||||
<div class="plans-copy">
|
||||
<p class="plans-kicker">Subscriptions</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="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="visiblePlans"
|
||||
v-loading="loading"
|
||||
class="plans-table"
|
||||
row-key="id"
|
||||
empty-text="当前筛选条件下暂无套餐"
|
||||
>
|
||||
<ElTableColumn prop="id" label="ID" width="86" />
|
||||
<ElTableColumn label="显示" width="92">
|
||||
<template #default="{ row }">
|
||||
<ElSwitch
|
||||
:model-value="row.show"
|
||||
:loading="isToggleLoading(row.id, 'show')"
|
||||
@change="handleToggle(row, 'show', $event)"
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="新购" width="92">
|
||||
<template #default="{ row }">
|
||||
<ElSwitch
|
||||
:model-value="row.sell"
|
||||
:loading="isToggleLoading(row.id, 'sell')"
|
||||
@change="handleToggle(row, 'sell', $event)"
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="续费" width="92">
|
||||
<template #default="{ row }">
|
||||
<ElSwitch
|
||||
:model-value="row.renew"
|
||||
:loading="isToggleLoading(row.id, 'renew')"
|
||||
@change="handleToggle(row, 'renew', $event)"
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="名称" min-width="280">
|
||||
<template #default="{ row }">
|
||||
<div class="name-cell">
|
||||
<strong>{{ row.name }}</strong>
|
||||
<span>
|
||||
{{ formatPlanTraffic(row) }}
|
||||
<template v-if="row.tags?.length">
|
||||
· {{ row.tags.join(' / ') }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="统计" min-width="154">
|
||||
<template #default="{ row }">
|
||||
<div class="metric-cell">
|
||||
<ElTag effect="plain" round>
|
||||
总用户 {{ row.users_count ?? 0 }}
|
||||
</ElTag>
|
||||
<ElTag type="success" effect="plain" round>
|
||||
活跃 {{ row.active_users_count ?? 0 }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="权限组" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<ElTag effect="plain" round>
|
||||
{{ row.group?.name || '未分组' }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="价格" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<div class="price-cell">
|
||||
<ElTag
|
||||
v-for="badge in getPlanPriceBadges(row)"
|
||||
:key="`${row.id}-${badge.key}`"
|
||||
effect="plain"
|
||||
round
|
||||
>
|
||||
{{ badge.label }}
|
||||
</ElTag>
|
||||
<span v-if="!getPlanPriceBadges(row).length" class="price-empty">未设置价格</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>已选择 0 项,共 {{ filteredPlans.length }} 项</span>
|
||||
<ElPagination
|
||||
v-model:current-page="current"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="sizes, prev, pager, next"
|
||||
:total="filteredPlans.length"
|
||||
background
|
||||
/>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<PlanEditorDrawer
|
||||
v-model:visible="drawerVisible"
|
||||
:mode="drawerMode"
|
||||
:plan="activePlan"
|
||||
:groups="groups"
|
||||
@success="() => loadData()"
|
||||
/>
|
||||
|
||||
<ElDialog
|
||||
v-model="sortDialogVisible"
|
||||
width="min(640px, calc(100vw - 32px))"
|
||||
title="编辑排序"
|
||||
class="sort-dialog"
|
||||
>
|
||||
<div class="sort-shell">
|
||||
<p class="sort-copy">按照当前展示顺序调整套餐排序,保存后会同步到后台 `/plan/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>{{ formatPlanTraffic(item) }} · {{ item.group?.name || '未分组' }}</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="./PlansView.scss"></style>
|
||||
@@ -0,0 +1,677 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { CircleCheckFilled, Message, RefreshRight, Setting, WarningFilled } from '@element-plus/icons-vue'
|
||||
import { fetchAdminConfig, getPlans, saveAdminConfig, setTelegramWebhook, testAdminMail } from '@/api/admin'
|
||||
import type { AdminPlanListItem } from '@/types/api'
|
||||
import { formatDateTime } from '@/utils/dashboard'
|
||||
import {
|
||||
createSystemConfigFormState,
|
||||
getSystemConfigFieldOptions,
|
||||
normalizeSystemConfigMappings,
|
||||
serializeSystemConfigForm,
|
||||
systemConfigSections,
|
||||
type SystemConfigFieldSchema,
|
||||
type SystemConfigFieldValue,
|
||||
type SystemConfigSectionKey,
|
||||
} from '@/utils/systemConfig'
|
||||
|
||||
const loading = ref(true)
|
||||
const reloading = ref(false)
|
||||
const saving = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const activeSection = ref<SystemConfigSectionKey>('site')
|
||||
const auxiliaryAction = ref<'mail' | 'telegram' | null>(null)
|
||||
const plans = ref<AdminPlanListItem[]>([])
|
||||
const lastLoadedAt = ref<string | null>(null)
|
||||
|
||||
const form = reactive(createSystemConfigFormState())
|
||||
const sectionRefs = new Map<SystemConfigSectionKey, HTMLElement>()
|
||||
const originalSnapshot = ref(JSON.stringify(serializeSystemConfigForm(form)))
|
||||
|
||||
const resolvedSections = computed(() => systemConfigSections.map((section) => ({
|
||||
...section,
|
||||
fields: section.fields.map((field) => ({
|
||||
...field,
|
||||
options: getSystemConfigFieldOptions(field, plans.value),
|
||||
})),
|
||||
})))
|
||||
|
||||
const currentSnapshot = computed(() => JSON.stringify(serializeSystemConfigForm(form)))
|
||||
const isDirty = computed(() => currentSnapshot.value !== originalSnapshot.value)
|
||||
const saveStatusText = computed(() => {
|
||||
if (saving.value) return '配置保存中'
|
||||
if (isDirty.value) return '存在未保存改动'
|
||||
return '已与服务端同步'
|
||||
})
|
||||
|
||||
const summaryCards = computed(() => [
|
||||
{
|
||||
label: '站点名称',
|
||||
value: String(form.app_name || '未命名站点'),
|
||||
},
|
||||
{
|
||||
label: '后台路径',
|
||||
value: form.secure_path ? `/${form.secure_path}` : '未设置',
|
||||
},
|
||||
{
|
||||
label: '注册状态',
|
||||
value: Boolean(form.stop_register) ? '暂停注册' : '开放注册',
|
||||
},
|
||||
])
|
||||
|
||||
function applyFormState() {
|
||||
originalSnapshot.value = JSON.stringify(serializeSystemConfigForm(form))
|
||||
lastLoadedAt.value = new Date().toISOString()
|
||||
}
|
||||
|
||||
function assignFormState(nextState: Record<string, SystemConfigFieldValue>) {
|
||||
Object.keys(nextState).forEach((key) => {
|
||||
form[key] = nextState[key]
|
||||
})
|
||||
applyFormState()
|
||||
}
|
||||
|
||||
async function loadPage(mode: 'initial' | 'reload' = 'initial') {
|
||||
if (mode === 'initial') {
|
||||
loading.value = true
|
||||
} else {
|
||||
reloading.value = true
|
||||
}
|
||||
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const [configResult, plansResult] = await Promise.allSettled([
|
||||
fetchAdminConfig(),
|
||||
getPlans(),
|
||||
])
|
||||
|
||||
if (configResult.status === 'rejected') {
|
||||
throw configResult.reason
|
||||
}
|
||||
|
||||
const nextState = normalizeSystemConfigMappings(configResult.value.data)
|
||||
assignFormState(nextState)
|
||||
|
||||
if (plansResult.status === 'fulfilled') {
|
||||
plans.value = plansResult.value.data ?? []
|
||||
} else {
|
||||
plans.value = []
|
||||
ElMessage.warning('试用套餐列表加载失败,注册试用下拉选项将暂时不可用')
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '系统配置加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
reloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getFieldValue(key: string): SystemConfigFieldValue {
|
||||
return form[key]
|
||||
}
|
||||
|
||||
function updateField(key: string, value: SystemConfigFieldValue) {
|
||||
form[key] = value
|
||||
}
|
||||
|
||||
function resolveNumberValue(field: SystemConfigFieldSchema): number | undefined {
|
||||
const value = getFieldValue(field.key)
|
||||
if (typeof value === 'number') return value
|
||||
if (value === null || value === '') return undefined
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : undefined
|
||||
}
|
||||
|
||||
function resolveTextValue(field: SystemConfigFieldSchema): string {
|
||||
const value = getFieldValue(field.key)
|
||||
if (value === null || value === undefined) return ''
|
||||
if (Array.isArray(value)) return value.join(', ')
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function registerSection(key: SystemConfigSectionKey) {
|
||||
return (element: Element | ComponentPublicInstance | null) => {
|
||||
if (element instanceof HTMLElement) {
|
||||
sectionRefs.set(key, element)
|
||||
return
|
||||
}
|
||||
|
||||
if (element && '$el' in element && element.$el instanceof HTMLElement) {
|
||||
sectionRefs.set(key, element.$el)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function jumpToSection(key: SystemConfigSectionKey) {
|
||||
activeSection.value = key
|
||||
sectionRefs.get(key)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (saving.value) return
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const payload = serializeSystemConfigForm(form)
|
||||
await saveAdminConfig(payload)
|
||||
originalSnapshot.value = JSON.stringify(payload)
|
||||
ElMessage.success('系统配置已保存')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '系统配置保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSavedBeforeAuxiliaryAction(): boolean {
|
||||
if (isDirty.value) {
|
||||
ElMessage.warning('请先保存当前配置,再执行辅助操作')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function handleTestMail() {
|
||||
if (!ensureSavedBeforeAuxiliaryAction()) return
|
||||
|
||||
auxiliaryAction.value = 'mail'
|
||||
try {
|
||||
await testAdminMail()
|
||||
ElMessage.success('测试邮件已触发,请检查当前管理员邮箱')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '测试邮件发送失败')
|
||||
} finally {
|
||||
auxiliaryAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetWebhook() {
|
||||
if (!ensureSavedBeforeAuxiliaryAction()) return
|
||||
|
||||
auxiliaryAction.value = 'telegram'
|
||||
try {
|
||||
await setTelegramWebhook({
|
||||
telegram_bot_token: String(form.telegram_bot_token || ''),
|
||||
})
|
||||
ElMessage.success('Telegram Webhook 设置成功')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : 'Telegram Webhook 设置失败')
|
||||
} finally {
|
||||
auxiliaryAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadPage()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="config-page">
|
||||
<section class="config-hero">
|
||||
<div class="config-copy">
|
||||
<p class="config-kicker">System Settings</p>
|
||||
<h1>系统设置。</h1>
|
||||
<p>
|
||||
管理系统核心配置,包括站点、安全、订阅、邀请佣金、节点、邮件与通知相关设置。
|
||||
</p>
|
||||
|
||||
<div class="config-status">
|
||||
<ElIcon :class="{ danger: isDirty }">
|
||||
<WarningFilled v-if="isDirty" />
|
||||
<CircleCheckFilled v-else />
|
||||
</ElIcon>
|
||||
<span>{{ saveStatusText }}</span>
|
||||
<small v-if="lastLoadedAt">最近加载于 {{ formatDateTime(lastLoadedAt) }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-side">
|
||||
<div class="hero-actions">
|
||||
<ElButton :loading="reloading" @click="loadPage('reload')">
|
||||
<ElIcon><RefreshRight /></ElIcon>
|
||||
重新拉取
|
||||
</ElButton>
|
||||
<ElButton type="primary" :loading="saving" :disabled="!isDirty" @click="handleSave">
|
||||
保存配置
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<div class="hero-summary">
|
||||
<article v-for="item in summaryCards" :key="item.label">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="loading" class="loading-shell">
|
||||
<ElSkeleton :rows="4" animated />
|
||||
<ElSkeleton :rows="6" animated />
|
||||
</section>
|
||||
|
||||
<section v-else class="config-shell">
|
||||
<aside class="config-nav">
|
||||
<button
|
||||
v-for="section in resolvedSections"
|
||||
:key="section.key"
|
||||
type="button"
|
||||
class="nav-item"
|
||||
:class="{ active: section.key === activeSection }"
|
||||
@click="jumpToSection(section.key)"
|
||||
>
|
||||
<span>{{ section.navLabel }}</span>
|
||||
<small>{{ section.fields.length }} 项</small>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<div class="config-content">
|
||||
<ElAlert
|
||||
v-if="errorMessage"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="config-error"
|
||||
:title="errorMessage"
|
||||
>
|
||||
<template #default>
|
||||
<ElButton size="small" @click="loadPage('reload')">重新加载</ElButton>
|
||||
</template>
|
||||
</ElAlert>
|
||||
|
||||
<article
|
||||
v-for="section in resolvedSections"
|
||||
:key="section.key"
|
||||
:ref="registerSection(section.key)"
|
||||
class="config-section"
|
||||
>
|
||||
<header class="section-header">
|
||||
<div class="section-copy">
|
||||
<p>{{ section.navLabel }}</p>
|
||||
<h2>{{ section.title }}</h2>
|
||||
<span>{{ section.description }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="section.key === 'email' || section.key === 'telegram'" class="section-actions">
|
||||
<ElButton
|
||||
v-if="section.key === 'email'"
|
||||
:loading="auxiliaryAction === 'mail'"
|
||||
@click="handleTestMail"
|
||||
>
|
||||
<ElIcon><Message /></ElIcon>
|
||||
发送测试邮件
|
||||
</ElButton>
|
||||
|
||||
<ElButton
|
||||
v-if="section.key === 'telegram'"
|
||||
:loading="auxiliaryAction === 'telegram'"
|
||||
@click="handleSetWebhook"
|
||||
>
|
||||
<ElIcon><Setting /></ElIcon>
|
||||
设置 Webhook
|
||||
</ElButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ElForm label-position="top" class="config-form">
|
||||
<div class="config-grid">
|
||||
<div
|
||||
v-for="field in section.fields"
|
||||
:key="field.key"
|
||||
class="config-field"
|
||||
:class="{ 'is-full': field.fullWidth }"
|
||||
>
|
||||
<ElFormItem :label="field.label">
|
||||
<ElSwitch
|
||||
v-if="field.type === 'switch'"
|
||||
:model-value="Boolean(getFieldValue(field.key))"
|
||||
@update:model-value="updateField(field.key, $event)"
|
||||
/>
|
||||
|
||||
<ElInputNumber
|
||||
v-else-if="field.type === 'number'"
|
||||
:model-value="resolveNumberValue(field)"
|
||||
:min="field.min"
|
||||
:max="field.max"
|
||||
:step="field.step ?? 1"
|
||||
controls-position="right"
|
||||
class="field-number"
|
||||
@update:model-value="updateField(field.key, $event ?? (field.nullable ? null : field.defaultValue ?? 0))"
|
||||
/>
|
||||
|
||||
<ElSelect
|
||||
v-else-if="field.type === 'select'"
|
||||
:model-value="getFieldValue(field.key)"
|
||||
:multiple="field.multiple"
|
||||
:allow-create="field.allowCreate"
|
||||
:filterable="field.multiple || field.allowCreate"
|
||||
:clearable="!field.multiple"
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
class="field-select"
|
||||
@update:model-value="updateField(field.key, $event as SystemConfigFieldValue)"
|
||||
>
|
||||
<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 === 'textarea'"
|
||||
:model-value="resolveTextValue(field)"
|
||||
type="textarea"
|
||||
:rows="field.rows ?? 4"
|
||||
:placeholder="field.placeholder"
|
||||
:autosize="field.rows ? undefined : { minRows: 4, maxRows: 10 }"
|
||||
@update:model-value="updateField(field.key, $event)"
|
||||
/>
|
||||
|
||||
<ElInput
|
||||
v-else
|
||||
:model-value="resolveTextValue(field)"
|
||||
:type="field.type === 'password' ? 'password' : 'text'"
|
||||
:show-password="field.type === 'password'"
|
||||
:placeholder="field.placeholder"
|
||||
clearable
|
||||
@update:model-value="updateField(field.key, $event)"
|
||||
/>
|
||||
|
||||
<p v-if="field.helper" class="field-helper">
|
||||
{{ field.helper }}
|
||||
</p>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
</div>
|
||||
</ElForm>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.config-page {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.config-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 34px;
|
||||
border-radius: 28px;
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.config-copy {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
max-width: 620px;
|
||||
}
|
||||
|
||||
.config-kicker {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.config-copy h1 {
|
||||
margin: 0;
|
||||
color: #ffffff;
|
||||
font-size: clamp(34px, 5vw, 52px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.28px;
|
||||
}
|
||||
|
||||
.config-copy > p:last-of-type {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.config-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.config-status :deep(.el-icon) {
|
||||
color: #2997ff;
|
||||
}
|
||||
|
||||
.config-status :deep(.el-icon.danger) {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.config-status small {
|
||||
color: rgba(255, 255, 255, 0.52);
|
||||
}
|
||||
|
||||
.hero-side {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
min-width: 360px;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hero-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hero-summary article {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.hero-summary span {
|
||||
color: rgba(255, 255, 255, 0.64);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hero-summary strong {
|
||||
color: #ffffff;
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.loading-shell {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 28px;
|
||||
border-radius: 24px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--xboard-shadow);
|
||||
}
|
||||
|
||||
.config-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.config-nav,
|
||||
.config-content {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.config-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
padding: 18px;
|
||||
border-radius: 24px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--xboard-shadow);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 18px;
|
||||
background: transparent;
|
||||
color: var(--xboard-text-secondary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
|
||||
.nav-item span {
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-item small {
|
||||
color: var(--xboard-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
border-color: rgba(0, 113, 227, 0.14);
|
||||
background: rgba(0, 113, 227, 0.08);
|
||||
color: #0071e3;
|
||||
}
|
||||
|
||||
.config-error {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
display: grid;
|
||||
gap: 22px;
|
||||
padding: 28px;
|
||||
border-radius: 24px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--xboard-shadow);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.section-copy {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-copy p {
|
||||
margin: 0;
|
||||
color: var(--xboard-text-muted);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.section-copy h2 {
|
||||
margin: 0;
|
||||
color: var(--xboard-text-strong);
|
||||
font-size: 30px;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.28px;
|
||||
}
|
||||
|
||||
.section-copy span {
|
||||
color: var(--xboard-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.section-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px 16px;
|
||||
}
|
||||
|
||||
.config-field.is-full {
|
||||
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;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.config-hero,
|
||||
.config-shell,
|
||||
.section-header {
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hero-side {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-actions,
|
||||
.section-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.config-shell {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.config-nav {
|
||||
position: static;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(180px, 1fr);
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.config-grid,
|
||||
.hero-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.config-hero {
|
||||
padding: 28px 24px;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
padding: 22px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,323 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Compass, Connection, Document, Setting } from '@element-plus/icons-vue'
|
||||
|
||||
interface PlaceholderState {
|
||||
title: string
|
||||
description: string
|
||||
summary: Array<{ label: string; value: string }>
|
||||
endpoints: string[]
|
||||
nextSteps: string[]
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const placeholderMap: Record<string, PlaceholderState> = {
|
||||
SystemPlugins: {
|
||||
title: '插件管理',
|
||||
description: '本轮先稳定菜单入口与信息架构。下一阶段会接入插件扫描、启停、配置编辑与上传工作流。',
|
||||
summary: [
|
||||
{ label: '当前阶段', value: '结构化占位' },
|
||||
{ label: '下一阶段', value: '插件列表与启停' },
|
||||
{ label: '重点边界', value: '配置编辑与上传' },
|
||||
],
|
||||
endpoints: ['GET /plugin/getPlugins', 'POST /plugin/config', 'POST /plugin/upload'],
|
||||
nextSteps: ['展示已安装 / 可安装插件列表', '接入启用、禁用、安装、升级动作', '补齐插件配置表单与 README 说明面板'],
|
||||
},
|
||||
SystemThemes: {
|
||||
title: '主题配置',
|
||||
description: '主题管理本轮仅保留入口。后续会接入主题列表、切换、配置编辑与上传能力。',
|
||||
summary: [
|
||||
{ label: '当前阶段', value: '结构化占位' },
|
||||
{ label: '下一阶段', value: '主题列表与切换' },
|
||||
{ label: '重点边界', value: '主题配置保存' },
|
||||
],
|
||||
endpoints: ['GET /theme/getThemes', 'POST /theme/getThemeConfig', 'POST /theme/saveThemeConfig'],
|
||||
nextSteps: ['展示主题列表与当前启用主题', '接入主题配置动态表单', '补齐主题上传与删除的安全边界'],
|
||||
},
|
||||
SystemNotices: {
|
||||
title: '公告管理',
|
||||
description: '公告管理入口已预留。下一阶段会补齐公告列表、显隐切换、排序与编辑工作台。',
|
||||
summary: [
|
||||
{ label: '当前阶段', value: '结构化占位' },
|
||||
{ label: '下一阶段', value: '公告列表' },
|
||||
{ label: '重点边界', value: '排序与显隐' },
|
||||
],
|
||||
endpoints: ['GET /notice/fetch', 'POST /notice/save', 'POST /notice/sort'],
|
||||
nextSteps: ['接入公告列表和编辑抽屉', '补齐显隐切换与排序反馈', '明确弹窗公告与普通公告的字段边界'],
|
||||
},
|
||||
SystemPayments: {
|
||||
title: '支付配置',
|
||||
description: '支付配置本轮只保留入口。下一阶段会接入支付方式列表、配置表单、显隐与排序。',
|
||||
summary: [
|
||||
{ label: '当前阶段', value: '结构化占位' },
|
||||
{ label: '下一阶段', value: '支付方式列表' },
|
||||
{ label: '重点边界', value: '网关配置安全性' },
|
||||
],
|
||||
endpoints: ['GET /payment/fetch', 'POST /payment/save', 'POST /payment/show'],
|
||||
nextSteps: ['展示支付方式列表与状态', '接入网关配置表单', '补齐排序、通知地址与风险提示'],
|
||||
},
|
||||
SystemKnowledge: {
|
||||
title: '知识库管理',
|
||||
description: '知识库管理入口已预留。下一阶段会补齐分类筛选、文档列表、显隐和编辑工作流。',
|
||||
summary: [
|
||||
{ label: '当前阶段', value: '结构化占位' },
|
||||
{ label: '下一阶段', value: '知识库列表' },
|
||||
{ label: '重点边界', value: '分类与排序' },
|
||||
],
|
||||
endpoints: ['GET /knowledge/fetch', 'GET /knowledge/getCategory', 'POST /knowledge/save'],
|
||||
nextSteps: ['接入分类与文档列表', '补齐显隐、排序与删除动作', '明确 Markdown / 富文本编辑策略'],
|
||||
},
|
||||
}
|
||||
|
||||
const pageState = computed(() => {
|
||||
const fallbackTitle = String(route.meta.title || '系统管理')
|
||||
return placeholderMap[String(route.name)] ?? {
|
||||
title: fallbackTitle,
|
||||
description: '该模块已经预留导航入口,本轮先完成结构化占位,后续继续接入真实管理能力。',
|
||||
summary: [
|
||||
{ label: '当前阶段', value: '结构化占位' },
|
||||
{ label: '下一阶段', value: '真实管理页' },
|
||||
{ label: '重点边界', value: '接口与权限' },
|
||||
],
|
||||
endpoints: [],
|
||||
nextSteps: ['补齐列表与编辑能力', '补齐保存、排序与删除工作流'],
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="placeholder-page">
|
||||
<section class="placeholder-hero">
|
||||
<div class="placeholder-copy">
|
||||
<p class="placeholder-kicker">System Management</p>
|
||||
<h1>{{ pageState.title }}。</h1>
|
||||
<p>{{ pageState.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="placeholder-summary">
|
||||
<article v-for="item in pageState.summary" :key="item.label">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="placeholder-shell">
|
||||
<article class="info-card">
|
||||
<div class="card-header">
|
||||
<ElIcon><Compass /></ElIcon>
|
||||
<div>
|
||||
<h2>本轮已就绪</h2>
|
||||
<p>菜单入口、路由结构和页面骨架已稳定下来,后续可以在这个基础上继续扩展。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="info-list">
|
||||
<li>
|
||||
<ElIcon><Setting /></ElIcon>
|
||||
<span>已接入系统管理分组,保持 Apple 风格后台的信息架构一致性。</span>
|
||||
</li>
|
||||
<li>
|
||||
<ElIcon><Connection /></ElIcon>
|
||||
<span>当前页面作为结构化占位页存在,保证导航与后续模块边界先稳定。</span>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="info-card">
|
||||
<div class="card-header">
|
||||
<ElIcon><Document /></ElIcon>
|
||||
<div>
|
||||
<h2>下一阶段接入</h2>
|
||||
<p>后续优先接入真实列表、编辑表单和状态反馈闭环。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol class="next-list">
|
||||
<li v-for="item in pageState.nextSteps" :key="item">{{ item }}</li>
|
||||
</ol>
|
||||
</article>
|
||||
|
||||
<article v-if="pageState.endpoints.length" class="info-card">
|
||||
<div class="card-header">
|
||||
<ElIcon><Setting /></ElIcon>
|
||||
<div>
|
||||
<h2>已确认的后端接口</h2>
|
||||
<p>后续页面会优先对齐这些现有 Laravel 管理接口,不额外猜测后端契约。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="endpoint-list">
|
||||
<code v-for="item in pageState.endpoints" :key="item">{{ item }}</code>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.placeholder-page {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.placeholder-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 32px;
|
||||
border-radius: 28px;
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.placeholder-copy {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
max-width: 620px;
|
||||
}
|
||||
|
||||
.placeholder-kicker {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.placeholder-copy h1 {
|
||||
margin: 0;
|
||||
color: #ffffff;
|
||||
font-size: clamp(34px, 5vw, 52px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.28px;
|
||||
}
|
||||
|
||||
.placeholder-copy p:last-child {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.placeholder-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
min-width: 360px;
|
||||
}
|
||||
|
||||
.placeholder-summary article {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.placeholder-summary span {
|
||||
color: rgba(255, 255, 255, 0.64);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.placeholder-summary strong {
|
||||
color: #ffffff;
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.placeholder-shell {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 26px 28px;
|
||||
border-radius: 24px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--xboard-shadow);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card-header :deep(.el-icon) {
|
||||
margin-top: 4px;
|
||||
font-size: 18px;
|
||||
color: #0071e3;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
margin: 0;
|
||||
color: var(--xboard-text-strong);
|
||||
font-size: 28px;
|
||||
line-height: 1.12;
|
||||
letter-spacing: -0.28px;
|
||||
}
|
||||
|
||||
.card-header p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--xboard-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-list,
|
||||
.next-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.info-list li,
|
||||
.next-list li {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
color: var(--xboard-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-list li :deep(.el-icon) {
|
||||
margin-top: 3px;
|
||||
color: var(--xboard-text-muted);
|
||||
}
|
||||
|
||||
.next-list {
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.endpoint-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.endpoint-list code {
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
background: #f5f5f7;
|
||||
color: var(--xboard-text-secondary);
|
||||
font-family: var(--xboard-font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.placeholder-hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.placeholder-summary {
|
||||
min-width: 0;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user