perf: 性能优化与错误修复
- 修复 N+1 查询: users/devices/agents 批量 GROUP BY 替代循环查询 - 添加分页: cards/finance/agents/devices API - Redis 初始化根据安装配置 redis.enabled 决定是否连接 - 修复 DefaultVal 解析错误: 使用 sql.NullString 处理 NULL 值 - Dashboard 优化: 替换 ECharts 世界地图为 Chart.js 环形饼图 - 适配前端 cards 页面新 API 响应格式 - 添加数据库索引优化查询性能 - 实现可配置的数据清理定时任务
This commit is contained in:
@@ -33,6 +33,7 @@ export interface User {
|
||||
export interface Team {
|
||||
name: string
|
||||
logo: NavIcon | string
|
||||
plan?: string
|
||||
}
|
||||
|
||||
export interface SidebarData {
|
||||
|
||||
@@ -115,7 +115,7 @@ async function loadSystemSettings() {
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api.get('/dev/system-settings')
|
||||
const data = await api.get<any>('/dev/system-settings')
|
||||
if (data) {
|
||||
const settings = {
|
||||
site_name: data.site_name || '',
|
||||
|
||||
@@ -779,7 +779,7 @@ function toggleWeekday(index: number) {
|
||||
|
||||
<div v-if="form.verify_method === 'email'" class="space-y-2 pt-4 border-t">
|
||||
<UiLabel>邮箱配置</UiLabel>
|
||||
<UiSelect v-model="form.email_config_id" @update:model-value="(val: number | null) => form.email_config_id = val">
|
||||
<UiSelect v-model="form.email_config_id" @update:model-value="(val: any) => form.email_config_id = (val ? Number(val) : null)">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="请选择邮箱配置" />
|
||||
</UiSelectTrigger>
|
||||
@@ -801,7 +801,7 @@ function toggleWeekday(index: number) {
|
||||
|
||||
<div v-if="form.verify_method === 'sms'" class="space-y-2 pt-4 border-t">
|
||||
<UiLabel>短信配置</UiLabel>
|
||||
<UiSelect v-model="form.sms_config_id" @update:model-value="(val: number | null) => form.sms_config_id = val">
|
||||
<UiSelect v-model="form.sms_config_id" @update:model-value="(val: any) => form.sms_config_id = (val ? Number(val) : null)">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="请选择短信配置" />
|
||||
</UiSelectTrigger>
|
||||
@@ -875,7 +875,7 @@ function toggleWeekday(index: number) {
|
||||
|
||||
<div v-if="form.password_reset_method === 'email'" class="space-y-2 pt-4 border-t">
|
||||
<UiLabel>邮箱配置</UiLabel>
|
||||
<UiSelect v-model="form.password_reset_email_id" @update:model-value="(val: number | null) => form.password_reset_email_id = val">
|
||||
<UiSelect v-model="form.password_reset_email_id" @update:model-value="(val: any) => form.password_reset_email_id = (val ? Number(val) : null)">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="请选择邮箱配置" />
|
||||
</UiSelectTrigger>
|
||||
@@ -897,7 +897,7 @@ function toggleWeekday(index: number) {
|
||||
|
||||
<div v-if="form.password_reset_method === 'sms'" class="space-y-2 pt-4 border-t">
|
||||
<UiLabel>短信配置</UiLabel>
|
||||
<UiSelect v-model="form.password_reset_sms_id" @update:model-value="(val: number | null) => form.password_reset_sms_id = val">
|
||||
<UiSelect v-model="form.password_reset_sms_id" @update:model-value="(val: any) => form.password_reset_sms_id = (val ? Number(val) : null)">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="请选择短信配置" />
|
||||
</UiSelectTrigger>
|
||||
|
||||
@@ -149,8 +149,16 @@ async function fetchCardTypes() {
|
||||
async function fetchCards() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<Card[]>('/dev/cards')
|
||||
cards.value = Array.isArray(data) ? data : []
|
||||
const data = await api.get<any>('/dev/cards')
|
||||
if (data && typeof data === 'object' && data.cards) {
|
||||
cards.value = data.cards
|
||||
}
|
||||
else if (Array.isArray(data)) {
|
||||
cards.value = data
|
||||
}
|
||||
else {
|
||||
cards.value = []
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取卡密列表失败:', error)
|
||||
|
||||
@@ -39,7 +39,7 @@ const selectedEncryption = computed(() => {
|
||||
async function fetchEmailConfig() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get(`/dev/email-configs/${route.params.id}`)
|
||||
const data = await api.get<any>(`/dev/email-configs/${route.params.id}`)
|
||||
if (data) {
|
||||
formData.value = {
|
||||
name: data.name,
|
||||
|
||||
@@ -48,9 +48,9 @@ const provinces = ref<Province[]>([])
|
||||
const onlineTrendData = ref<number[]>([])
|
||||
const recentTickets = ref<Ticket[]>([])
|
||||
|
||||
const mapChart = ref<HTMLElement | null>(null)
|
||||
const distributionChart = ref<HTMLCanvasElement | null>(null)
|
||||
const activityChart = ref<HTMLCanvasElement | null>(null)
|
||||
let chartInstance: any = null
|
||||
let distributionChartInstance: any = null
|
||||
let activityChartInstance: any = null
|
||||
|
||||
const overseasUsers = computed(() => {
|
||||
@@ -231,30 +231,77 @@ function fetchCurrentUser() {
|
||||
}
|
||||
}
|
||||
|
||||
async function initMapChart() {
|
||||
if (!mapChart.value)
|
||||
async function initDistributionChart() {
|
||||
if (!distributionChart.value)
|
||||
return
|
||||
|
||||
try {
|
||||
const [echarts, worldResponse] = await Promise.all([
|
||||
import('echarts'),
|
||||
fetch('/world.json'),
|
||||
])
|
||||
const { default: Chart } = await import('chart.js/auto')
|
||||
|
||||
if (!worldResponse.ok) {
|
||||
throw new Error('Failed to load world map data')
|
||||
const ctx = distributionChart.value.getContext('2d')
|
||||
if (!ctx)
|
||||
return
|
||||
|
||||
if (distributionChartInstance) {
|
||||
distributionChartInstance.destroy()
|
||||
}
|
||||
|
||||
const worldData = await worldResponse.json()
|
||||
const data = provinces.value.filter((d: any) => d.count > 0)
|
||||
const labels = data.map((d: any) => d.name)
|
||||
const values = data.map((d: any) => d.count)
|
||||
const dark = isDark.value
|
||||
|
||||
echarts.registerMap('world', worldData)
|
||||
const colors = [
|
||||
'#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6',
|
||||
'#06b6d4', '#ec4899', '#14b8a6', '#f97316', '#6366f1',
|
||||
]
|
||||
|
||||
chartInstance = echarts.init(mapChart.value)
|
||||
|
||||
updateMapOption()
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
chartInstance?.resize()
|
||||
distributionChartInstance = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
data: values,
|
||||
backgroundColor: colors.slice(0, values.length),
|
||||
borderColor: dark ? '#1e293b' : '#ffffff',
|
||||
borderWidth: 2,
|
||||
hoverOffset: 8,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '55%',
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
labels: {
|
||||
color: dark ? '#cbd5e1' : '#475569',
|
||||
padding: 16,
|
||||
usePointStyle: true,
|
||||
pointStyleWidth: 12,
|
||||
font: {
|
||||
size: 13,
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.95)',
|
||||
titleColor: '#f8fafc',
|
||||
bodyColor: '#cbd5e1',
|
||||
borderColor: '#334155',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
|
||||
const percentage = total > 0 ? ((context.parsed / total) * 100).toFixed(1) : '0'
|
||||
return ` ${context.label}: ${context.parsed} (${percentage}%)`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
@@ -262,352 +309,6 @@ async function initMapChart() {
|
||||
}
|
||||
}
|
||||
|
||||
const worldNameMap: Record<string, string> = {
|
||||
'Afghanistan': '阿富汗',
|
||||
'Albania': '阿尔巴尼亚',
|
||||
'Algeria': '阿尔及利亚',
|
||||
'American Samoa': '美属萨摩亚',
|
||||
'Andorra': '安道尔',
|
||||
'Angola': '安哥拉',
|
||||
'Anguilla': '安圭拉',
|
||||
'Antarctica': '南极洲',
|
||||
'Antigua and Barbuda': '安提瓜和巴布达',
|
||||
'Argentina': '阿根廷',
|
||||
'Armenia': '亚美尼亚',
|
||||
'Aruba': '阿鲁巴',
|
||||
'Australia': '澳大利亚',
|
||||
'Austria': '奥地利',
|
||||
'Azerbaijan': '阿塞拜疆',
|
||||
'Bahamas': '巴哈马',
|
||||
'Bahrain': '巴林',
|
||||
'Bangladesh': '孟加拉国',
|
||||
'Barbados': '巴巴多斯',
|
||||
'Belarus': '白俄罗斯',
|
||||
'Belgium': '比利时',
|
||||
'Belize': '伯利兹',
|
||||
'Benin': '贝宁',
|
||||
'Bermuda': '百慕大',
|
||||
'Bhutan': '不丹',
|
||||
'Bolivia': '玻利维亚',
|
||||
'Bosnia and Herzegovina': '波黑',
|
||||
'Botswana': '博茨瓦纳',
|
||||
'Brazil': '巴西',
|
||||
'British Indian Ocean Ter.': '英属印度洋领地',
|
||||
'Brunei': '文莱',
|
||||
'Bulgaria': '保加利亚',
|
||||
'Burkina Faso': '布基纳法索',
|
||||
'Burundi': '布隆迪',
|
||||
'Cambodia': '柬埔寨',
|
||||
'Cameroon': '喀麦隆',
|
||||
'Canada': '加拿大',
|
||||
'Cape Verde': '佛得角',
|
||||
'Cayman Is.': '开曼群岛',
|
||||
'Central African Rep.': '中非',
|
||||
'Chad': '乍得',
|
||||
'Chile': '智利',
|
||||
'China': '中国',
|
||||
'Colombia': '哥伦比亚',
|
||||
'Comoros': '科摩罗',
|
||||
'Congo': '刚果',
|
||||
'Dem. Rep. Congo': '刚果民主共和国',
|
||||
'Cook Is.': '库克群岛',
|
||||
'Costa Rica': '哥斯达黎加',
|
||||
'Croatia': '克罗地亚',
|
||||
'Cuba': '古巴',
|
||||
'Cyprus': '塞浦路斯',
|
||||
'Czech Rep.': '捷克',
|
||||
'Côte d\'Ivoire': '科特迪瓦',
|
||||
'Denmark': '丹麦',
|
||||
'Djibouti': '吉布提',
|
||||
'Dominica': '多米尼克',
|
||||
'Dominican Rep.': '多米尼加',
|
||||
'Ecuador': '厄瓜多尔',
|
||||
'Egypt': '埃及',
|
||||
'El Salvador': '萨尔瓦多',
|
||||
'Equatorial Guinea': '赤道几内亚',
|
||||
'Eritrea': '厄立特里亚',
|
||||
'Estonia': '爱沙尼亚',
|
||||
'Ethiopia': '埃塞俄比亚',
|
||||
'Falkland Is.': '福克兰群岛',
|
||||
'Faeroe Is.': '法罗群岛',
|
||||
'Fiji': '斐济',
|
||||
'Finland': '芬兰',
|
||||
'France': '法国',
|
||||
'French Guiana': '法属圭亚那',
|
||||
'French Polynesia': '法属波利尼西亚',
|
||||
'French Southern Ter.': '法属南方领地',
|
||||
'Gabon': '加蓬',
|
||||
'Gambia': '冈比亚',
|
||||
'Gaza': '加沙',
|
||||
'Georgia': '格鲁吉亚',
|
||||
'Germany': '德国',
|
||||
'Ghana': '加纳',
|
||||
'Gibraltar': '直布罗陀',
|
||||
'Greece': '希腊',
|
||||
'Greenland': '格陵兰',
|
||||
'Grenada': '格林纳达',
|
||||
'Guadeloupe': '瓜德罗普',
|
||||
'Guam': '关岛',
|
||||
'Guatemala': '危地马拉',
|
||||
'Guinea': '几内亚',
|
||||
'Guinea-Bissau': '几内亚比绍',
|
||||
'Guyana': '圭亚那',
|
||||
'Haiti': '海地',
|
||||
'Heard I. and McDonald Is.': '赫德岛和麦克唐纳群岛',
|
||||
'Honduras': '洪都拉斯',
|
||||
'Hong Kong': '香港',
|
||||
'Hungary': '匈牙利',
|
||||
'Iceland': '冰岛',
|
||||
'India': '印度',
|
||||
'Indonesia': '印度尼西亚',
|
||||
'Iran': '伊朗',
|
||||
'Iraq': '伊拉克',
|
||||
'Ireland': '爱尔兰',
|
||||
'Isle of Man': '马恩岛',
|
||||
'Israel': '以色列',
|
||||
'Italy': '意大利',
|
||||
'Jamaica': '牙买加',
|
||||
'Japan': '日本',
|
||||
'Jordan': '约旦',
|
||||
'Kazakhstan': '哈萨克斯坦',
|
||||
'Kenya': '肯尼亚',
|
||||
'Kiribati': '基里巴斯',
|
||||
'Korea': '韩国',
|
||||
'Dem. Rep. Korea': '朝鲜',
|
||||
'Kuwait': '科威特',
|
||||
'Kyrgyzstan': '吉尔吉斯斯坦',
|
||||
'Lao PDR': '老挝',
|
||||
'Latvia': '拉脱维亚',
|
||||
'Lebanon': '黎巴嫩',
|
||||
'Lesotho': '莱索托',
|
||||
'Liberia': '利比里亚',
|
||||
'Libya': '利比亚',
|
||||
'Liechtenstein': '列支敦士登',
|
||||
'Lithuania': '立陶宛',
|
||||
'Luxembourg': '卢森堡',
|
||||
'Macao': '澳门',
|
||||
'Macedonia': '马其顿',
|
||||
'Madagascar': '马达加斯加',
|
||||
'Malawi': '马拉维',
|
||||
'Malaysia': '马来西亚',
|
||||
'Maldives': '马尔代夫',
|
||||
'Mali': '马里',
|
||||
'Malta': '马耳他',
|
||||
'Marshall Is.': '马绍尔群岛',
|
||||
'Martinique': '马提尼克',
|
||||
'Mauritania': '毛里塔尼亚',
|
||||
'Mauritius': '毛里求斯',
|
||||
'Mexico': '墨西哥',
|
||||
'Micronesia': '密克罗尼西亚',
|
||||
'Moldova': '摩尔多瓦',
|
||||
'Monaco': '摩纳哥',
|
||||
'Mongolia': '蒙古',
|
||||
'Montenegro': '黑山',
|
||||
'Montserrat': '蒙特塞拉特',
|
||||
'Morocco': '摩洛哥',
|
||||
'Mozambique': '莫桑比克',
|
||||
'Myanmar': '缅甸',
|
||||
'Namibia': '纳米比亚',
|
||||
'Nauru': '瑙鲁',
|
||||
'Nepal': '尼泊尔',
|
||||
'Netherlands': '荷兰',
|
||||
'New Caledonia': '新喀里多尼亚',
|
||||
'New Zealand': '新西兰',
|
||||
'Nicaragua': '尼加拉瓜',
|
||||
'Niger': '尼日尔',
|
||||
'Nigeria': '尼日利亚',
|
||||
'Niue': '纽埃',
|
||||
'Norfolk Island': '诺福克岛',
|
||||
'Northern Mariana Is.': '北马里亚纳群岛',
|
||||
'Norway': '挪威',
|
||||
'Oman': '阿曼',
|
||||
'Pakistan': '巴基斯坦',
|
||||
'Palau': '帕劳',
|
||||
'Palestine': '巴勒斯坦',
|
||||
'Panama': '巴拿马',
|
||||
'Papua New Guinea': '巴布亚新几内亚',
|
||||
'Paraguay': '巴拉圭',
|
||||
'Peru': '秘鲁',
|
||||
'Philippines': '菲律宾',
|
||||
'Pitcairn Is.': '皮特凯恩群岛',
|
||||
'Poland': '波兰',
|
||||
'Portugal': '葡萄牙',
|
||||
'Puerto Rico': '波多黎各',
|
||||
'Qatar': '卡塔尔',
|
||||
'Réunion': '留尼汪',
|
||||
'Romania': '罗马尼亚',
|
||||
'Russia': '俄罗斯',
|
||||
'Rwanda': '卢旺达',
|
||||
'Saint Helena': '圣赫勒拿',
|
||||
'Saint Kitts and Nevis': '圣基茨和尼维斯',
|
||||
'Saint Lucia': '圣卢西亚',
|
||||
'Saint Pierre and Miquelon': '圣皮埃尔和密克隆',
|
||||
'Saint Vincent and the Grenadines': '圣文森特和格林纳丁斯',
|
||||
'Samoa': '萨摩亚',
|
||||
'San Marino': '圣马力诺',
|
||||
'Sao Tome and Principe': '圣多美和普林西比',
|
||||
'Saudi Arabia': '沙特阿拉伯',
|
||||
'Senegal': '塞内加尔',
|
||||
'Serbia': '塞尔维亚',
|
||||
'Seychelles': '塞舌尔',
|
||||
'Sierra Leone': '塞拉利昂',
|
||||
'Singapore': '新加坡',
|
||||
'Slovakia': '斯洛伐克',
|
||||
'Slovenia': '斯洛文尼亚',
|
||||
'Solomon Is.': '所罗门群岛',
|
||||
'Somalia': '索马里',
|
||||
'South Africa': '南非',
|
||||
'South Georgia and the South Sandwich Is.': '南乔治亚和南桑威奇群岛',
|
||||
'S. Sudan': '南苏丹',
|
||||
'Spain': '西班牙',
|
||||
'Sri Lanka': '斯里兰卡',
|
||||
'Sudan': '苏丹',
|
||||
'Suriname': '苏里南',
|
||||
'Swaziland': '斯威士兰',
|
||||
'Sweden': '瑞典',
|
||||
'Switzerland': '瑞士',
|
||||
'Syria': '叙利亚',
|
||||
'Taiwan': '台湾',
|
||||
'Tajikistan': '塔吉克斯坦',
|
||||
'Tanzania': '坦桑尼亚',
|
||||
'Thailand': '泰国',
|
||||
'Timor-Leste': '东帝汶',
|
||||
'Togo': '多哥',
|
||||
'Tokelau': '托克劳',
|
||||
'Tonga': '汤加',
|
||||
'Trinidad and Tobago': '特立尼达和多巴哥',
|
||||
'Tunisia': '突尼斯',
|
||||
'Turkey': '土耳其',
|
||||
'Turkmenistan': '土库曼斯坦',
|
||||
'Turks and Caicos Is.': '特克斯和凯科斯群岛',
|
||||
'Tuvalu': '图瓦卢',
|
||||
'Uganda': '乌干达',
|
||||
'Ukraine': '乌克兰',
|
||||
'United Arab Emirates': '阿联酋',
|
||||
'United Kingdom': '英国',
|
||||
'United States': '美国',
|
||||
'United States Minor Outlying Is.': '美属小离岛',
|
||||
'Uruguay': '乌拉圭',
|
||||
'Uzbekistan': '乌兹别克斯坦',
|
||||
'Vanuatu': '瓦努阿图',
|
||||
'Vatican City': '梵蒂冈',
|
||||
'Venezuela': '委内瑞拉',
|
||||
'Vietnam': '越南',
|
||||
'Virgin Is.': '维尔京群岛',
|
||||
'W. Sahara': '西撒哈拉',
|
||||
'Yemen': '也门',
|
||||
'Zambia': '赞比亚',
|
||||
'Zimbabwe': '津巴布韦',
|
||||
}
|
||||
|
||||
function updateMapOption() {
|
||||
if (!chartInstance)
|
||||
return
|
||||
|
||||
const data = provinces.value.filter((d: any) => d.count > 0)
|
||||
const maxCount = Math.max(...data.map((d: any) => d.count || 0), 1)
|
||||
|
||||
const dark = isDark.value
|
||||
|
||||
const seriesData = data.map((d: any) => {
|
||||
const ratio = d.count / maxCount
|
||||
let r: number, g: number, b: number
|
||||
if (dark) {
|
||||
r = Math.round(30 + (59 - 30) * ratio)
|
||||
g = Math.round(58 + (130 - 58) * ratio)
|
||||
b = Math.round(138 + (246 - 138) * ratio)
|
||||
}
|
||||
else {
|
||||
r = Math.round(147 + (37 - 147) * ratio)
|
||||
g = Math.round(197 + (99 - 197) * ratio)
|
||||
b = Math.round(253 + (235 - 253) * ratio)
|
||||
}
|
||||
return {
|
||||
name: d.name,
|
||||
value: d.count,
|
||||
count: d.count,
|
||||
online: d.online || 0,
|
||||
offline: d.offline || 0,
|
||||
itemStyle: {
|
||||
areaColor: `rgb(${r}, ${g}, ${b})`,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
backgroundColor: dark ? 'rgba(15, 23, 42, 0.95)' : 'rgba(255, 255, 255, 0.95)',
|
||||
borderColor: dark ? '#334155' : '#e2e8f0',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
textStyle: {
|
||||
color: dark ? '#f8fafc' : '#0f172a',
|
||||
},
|
||||
formatter: (params: any) => {
|
||||
if (params.data && params.data.count > 0) {
|
||||
return `<div style="padding: 4px;">
|
||||
<div style="font-weight: 600; margin-bottom: 6px; font-size: 13px;">${params.name}</div>
|
||||
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 3px;">
|
||||
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#22c55e;"></span>
|
||||
<span>${t('admin.online')}: ${params.data.online}</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 3px;">
|
||||
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#ef4444;"></span>
|
||||
<span>${t('admin.offline')}: ${params.data.offline}</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 6px;">
|
||||
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#3b82f6;"></span>
|
||||
<span>${t('admin.total')}: ${params.data.count}</span>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
return `<div style="padding: 4px;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px; font-size: 13px;">${params.name}</div>
|
||||
<div style="color: ${dark ? '#94a3b8' : '#64748b'};">${t('admin.noUserData')}</div>
|
||||
</div>`
|
||||
},
|
||||
},
|
||||
geo: {
|
||||
map: 'world',
|
||||
roam: true,
|
||||
zoom: 1.2,
|
||||
nameMap: worldNameMap,
|
||||
label: {
|
||||
show: false,
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
color: dark ? '#f8fafc' : '#0f172a',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
},
|
||||
itemStyle: {
|
||||
areaColor: dark ? '#334155' : '#f1f5f9',
|
||||
borderColor: dark ? '#475569' : '#94a3b8',
|
||||
borderWidth: 1,
|
||||
},
|
||||
},
|
||||
itemStyle: {
|
||||
areaColor: dark ? '#1e293b' : '#e2e8f0',
|
||||
borderColor: dark ? '#334155' : '#cbd5e1',
|
||||
borderWidth: 0.5,
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: t('admin.userDistribution'),
|
||||
type: 'map',
|
||||
geoIndex: 0,
|
||||
data: seriesData,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
chartInstance.setOption(option)
|
||||
}
|
||||
|
||||
function initActivityChart() {
|
||||
if (!activityChart.value)
|
||||
return
|
||||
@@ -719,20 +420,20 @@ watch(loading, async (newVal) => {
|
||||
if (!newVal) {
|
||||
await nextTick()
|
||||
setTimeout(() => {
|
||||
initMapChart()
|
||||
initDistributionChart()
|
||||
initActivityChart()
|
||||
}, 100)
|
||||
}
|
||||
})
|
||||
|
||||
watch(isDark, () => {
|
||||
updateMapOption()
|
||||
initDistributionChart()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose()
|
||||
chartInstance = null
|
||||
if (distributionChartInstance) {
|
||||
distributionChartInstance.destroy()
|
||||
distributionChartInstance = null
|
||||
}
|
||||
if (activityChartInstance) {
|
||||
activityChartInstance.destroy()
|
||||
@@ -852,7 +553,7 @@ onUnmounted(() => {
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="relative h-[350px] rounded-lg overflow-hidden bg-card">
|
||||
<div ref="mapChart" class="w-full h-full" />
|
||||
<canvas ref="distributionChart" />
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
@@ -42,7 +42,7 @@ const selectedType = computed(() => {
|
||||
async function fetchPaymentChannel() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get(`/dev/payment-channels/${route.params.id}`)
|
||||
const data = await api.get<any>(`/dev/payment-channels/${route.params.id}`)
|
||||
if (data) {
|
||||
formData.value = {
|
||||
name: data.name,
|
||||
|
||||
@@ -60,7 +60,7 @@ const configPlaceholder = computed(() => {
|
||||
async function fetchSmsConfig() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get(`/dev/sms-configs/${route.params.id}`)
|
||||
const data = await api.get<any>(`/dev/sms-configs/${route.params.id}`)
|
||||
if (data) {
|
||||
formData.value = {
|
||||
name: data.name,
|
||||
|
||||
@@ -67,7 +67,7 @@ const endpointPlaceholder = computed(() => {
|
||||
async function fetchStorageConfig() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get(`/dev/storage-configs/${route.params.id}`)
|
||||
const data = await api.get<any>(`/dev/storage-configs/${route.params.id}`)
|
||||
if (data) {
|
||||
formData.value = {
|
||||
name: data.name || '',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Bell, Database, Loader2, Settings, Shield, ToggleLeft, Upload } from 'lucide-vue-next'
|
||||
import { Bell, Database, Loader2, Settings, Shield, ToggleLeft, Trash2, Upload } from 'lucide-vue-next'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
@@ -51,10 +51,23 @@ const notificationForm = ref({
|
||||
notify_on_ticket: true,
|
||||
})
|
||||
|
||||
const cleanupForm = ref({
|
||||
enable_auto_cleanup: false,
|
||||
cleanup_interval_hours: 24,
|
||||
captcha_retention_days: 1,
|
||||
verify_code_retention_days: 7,
|
||||
api_usage_retention_days: 30,
|
||||
webhook_log_retention_days: 30,
|
||||
device_session_retention_days: 7,
|
||||
})
|
||||
|
||||
const cleanupLoading = ref(false)
|
||||
const manualCleanupLoading = ref(false)
|
||||
|
||||
async function loadSettings() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get('/dev/system-settings')
|
||||
const data = await api.get<any>('/dev/system-settings')
|
||||
if (data) {
|
||||
basicForm.value = {
|
||||
site_name: data.site_name || '',
|
||||
@@ -102,6 +115,24 @@ async function loadSettings() {
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
try {
|
||||
const cleanupData = await api.get<any>('/dev/system-settings/cleanup')
|
||||
if (cleanupData) {
|
||||
cleanupForm.value = {
|
||||
enable_auto_cleanup: cleanupData.enable_auto_cleanup ?? false,
|
||||
cleanup_interval_hours: cleanupData.cleanup_interval_hours || 24,
|
||||
captcha_retention_days: cleanupData.captcha_retention_days || 1,
|
||||
verify_code_retention_days: cleanupData.verify_code_retention_days || 7,
|
||||
api_usage_retention_days: cleanupData.api_usage_retention_days || 30,
|
||||
webhook_log_retention_days: cleanupData.webhook_log_retention_days || 30,
|
||||
device_session_retention_days: cleanupData.device_session_retention_days || 7,
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
@@ -136,6 +167,34 @@ function updateGlobalSettings() {
|
||||
window.dispatchEvent(new CustomEvent('system-settings-changed', { detail: settings }))
|
||||
}
|
||||
|
||||
async function saveCleanupSettings() {
|
||||
cleanupLoading.value = true
|
||||
try {
|
||||
await api.put('/dev/system-settings/cleanup', cleanupForm.value)
|
||||
toast.success('清理设置保存成功')
|
||||
}
|
||||
catch (error) {
|
||||
toast.error('保存清理设置失败')
|
||||
}
|
||||
finally {
|
||||
cleanupLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runManualCleanup() {
|
||||
manualCleanupLoading.value = true
|
||||
try {
|
||||
await api.post('/dev/system-settings/cleanup/run')
|
||||
toast.success('手动清理完成')
|
||||
}
|
||||
catch (error) {
|
||||
toast.error('手动清理失败')
|
||||
}
|
||||
finally {
|
||||
manualCleanupLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function triggerLogoUpload() {
|
||||
logoInputRef.value?.click()
|
||||
}
|
||||
@@ -152,7 +211,7 @@ async function handleLogoUpload(event: Event) {
|
||||
formData.append('file', file)
|
||||
formData.append('type', 'logo')
|
||||
|
||||
const data = await api.postFormData('/dev/system-settings/upload', formData)
|
||||
const data = await api.postFormData<any>('/dev/system-settings/upload', formData)
|
||||
basicForm.value.site_logo = data.url
|
||||
toast.success('Logo上传成功')
|
||||
}
|
||||
@@ -181,7 +240,7 @@ async function handleFaviconUpload(event: Event) {
|
||||
formData.append('file', file)
|
||||
formData.append('type', 'favicon')
|
||||
|
||||
const data = await api.postFormData('/dev/system-settings/upload', formData)
|
||||
const data = await api.postFormData<any>('/dev/system-settings/upload', formData)
|
||||
basicForm.value.site_favicon = data.url
|
||||
toast.success('图标上传成功')
|
||||
}
|
||||
@@ -198,6 +257,7 @@ const tabs = [
|
||||
{ id: 'basic', label: '基本设置', icon: Settings },
|
||||
{ id: 'security', label: '安全设置', icon: Shield },
|
||||
{ id: 'backup', label: '备份设置', icon: Database },
|
||||
{ id: 'cleanup', label: '数据清理', icon: Trash2 },
|
||||
{ id: 'feature', label: '功能设置', icon: ToggleLeft },
|
||||
{ id: 'notification', label: '通知设置', icon: Bell },
|
||||
]
|
||||
@@ -603,6 +663,126 @@ onMounted(() => {
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard v-show="activeTab === 'cleanup'">
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>数据清理</UiCardTitle>
|
||||
<UiCardDescription>配置过期数据自动清理规则,释放数据库空间</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel>启用自动清理</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
定时自动清理过期数据
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="cleanupForm.enable_auto_cleanup" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="cleanup_interval_hours">清理间隔(小时)</UiLabel>
|
||||
<UiInput
|
||||
id="cleanup_interval_hours"
|
||||
v-model.number="cleanupForm.cleanup_interval_hours"
|
||||
type="number"
|
||||
min="1"
|
||||
max="168"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
每隔多少小时执行一次清理
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="captcha_retention_days">验证码保留天数</UiLabel>
|
||||
<UiInput
|
||||
id="captcha_retention_days"
|
||||
v-model.number="cleanupForm.captcha_retention_days"
|
||||
type="number"
|
||||
min="1"
|
||||
max="30"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
图形验证码过期后保留天数
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="verify_code_retention_days">邮箱/短信验证码保留天数</UiLabel>
|
||||
<UiInput
|
||||
id="verify_code_retention_days"
|
||||
v-model.number="cleanupForm.verify_code_retention_days"
|
||||
type="number"
|
||||
min="1"
|
||||
max="90"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
已使用的邮箱/短信验证码保留天数
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="api_usage_retention_days">API调用日志保留天数</UiLabel>
|
||||
<UiInput
|
||||
id="api_usage_retention_days"
|
||||
v-model.number="cleanupForm.api_usage_retention_days"
|
||||
type="number"
|
||||
min="7"
|
||||
max="365"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
API调用统计记录保留天数
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="webhook_log_retention_days">Webhook日志保留天数</UiLabel>
|
||||
<UiInput
|
||||
id="webhook_log_retention_days"
|
||||
v-model.number="cleanupForm.webhook_log_retention_days"
|
||||
type="number"
|
||||
min="7"
|
||||
max="365"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Webhook发送日志保留天数
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="device_session_retention_days">设备会话保留天数</UiLabel>
|
||||
<UiInput
|
||||
id="device_session_retention_days"
|
||||
v-model.number="cleanupForm.device_session_retention_days"
|
||||
type="number"
|
||||
min="1"
|
||||
max="90"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
过期的设备会话记录保留天数
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 border-t pt-4">
|
||||
<UiButton :disabled="cleanupLoading" @click="saveCleanupSettings">
|
||||
<Loader2 v-if="cleanupLoading" class="mr-2 h-4 w-4 animate-spin" />
|
||||
保存清理设置
|
||||
</UiButton>
|
||||
<UiButton variant="outline" :disabled="manualCleanupLoading" @click="runManualCleanup">
|
||||
<Loader2 v-if="manualCleanupLoading" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Trash2 v-else class="mr-2 h-4 w-4" />
|
||||
立即执行清理
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<UiButton :disabled="saving" @click="saveSettings">
|
||||
<Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
declare module 'vue3-flag-icons/styles'
|
||||
Reference in New Issue
Block a user