refactor: 站点列表改为pt-sites.json编辑+可视化双栏布局

This commit is contained in:
mediabot-pt
2026-07-05 20:15:38 +08:00
parent c4f10cafaa
commit c2e01db572

View File

@@ -1,262 +1,113 @@
<template>
<div>
<div style="display:flex;justify-content:space-between;align-items:center">
<h2>站点列表</h2>
<div>
<el-badge v-if="pendingCount > 0" :value="pendingCount" type="warning">
<div style="display:flex;flex-direction:column;height:calc(100vh - 80px)">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<div style="display:flex;align-items:center;gap:12px">
<h2 style="margin:0">站点列表</h2>
<el-badge v-if="pendingCount" :value="pendingCount" type="warning">
<el-button size="small" @click="$router.push('/sites/review')">变更审核</el-button>
</el-badge>
</div>
<div style="display:flex;gap:8px">
<el-button size="small" @click="refresh" :loading="loading">刷新</el-button>
<el-button size="small" @click="addSite">+ 新建</el-button>
<el-button size="small" type="primary" @click="saveAll" :loading="saving">保存</el-button>
</div>
</div>
<el-card style="margin-top: 20px">
<el-table :data="sites" v-loading="loading" stripe size="small" empty-text="暂无站点">
<el-table-column prop="siteId" label="站点ID" width="120" />
<el-table-column label="名称" width="180">
<template #default="scope">
<div v-for="(n, i) in scope.row.names" :key="i" class="text-xs">{{ n }}</div>
</template>
</el-table-column>
<el-table-column label="地址" min-width="200">
<template #default="scope">
<div v-for="(u, i) in scope.row.urls" :key="i" class="text-xs">{{ u }}</div>
</template>
</el-table-column>
<el-table-column prop="siteType" label="类型" width="100" />
<el-table-column prop="status" label="状态" width="100">
<template #default="scope">
<el-tag size="small" :type="scope.row.status === 'active' ? 'success' : 'info'">
{{ scope.row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="适配版本" width="110">
<template #default="scope">
<span v-if="scope.row.latestVersion" class="text-xs">{{ scope.row.latestVersion }}</span>
<el-tag v-else size="small" type="info">未适配</el-tag>
</template>
</el-table-column>
<el-table-column label="适配状态" width="100">
<template #default="scope">
<el-tag v-if="scope.row.configStatus" size="small" :type="configStatusTag(scope.row.configStatus)">
{{ configStatusLabel(scope.row.configStatus) }}
</el-tag>
<span v-else class="text-xs" style="color:#909399">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="240">
<template #default="scope">
<el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
<el-button size="small" type="primary" @click="handleViewConfig(scope.row)">查看配置</el-button>
<el-button size="small" @click="handleViewHistory(scope.row)">变更</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 编辑对话框 -->
<el-dialog v-model="editVisible" title="编辑站点" width="500px" :close-on-click-modal="false">
<el-form label-width="80px">
<el-form-item label="站点ID">
<el-input v-model="editForm.siteId" disabled />
</el-form-item>
<el-form-item label="名称">
<el-input v-model="editForm.namesText" type="textarea" :rows="3" placeholder="站点名称(一行一个)" />
</el-form-item>
<el-form-item label="地址">
<el-input v-model="editForm.urlsText" type="textarea" :rows="3" placeholder="站点地址(一行一个)" />
</el-form-item>
<el-form-item label="类型">
<el-select v-model="editForm.siteType" placeholder="选择类型" style="width:100%">
<el-option v-for="t in siteTypes" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="editForm.status" style="width:100%">
<el-option label="活跃" value="active" />
<el-option label="弃用" value="deprecated" />
</el-select>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="editForm.description" type="textarea" :rows="3" placeholder="站点描述" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
</template>
</el-dialog>
<!-- 查看配置对话框 -->
<el-dialog v-model="configVisible" :title="'配置:' + configSiteId + (configVersion ? ' v' + configVersion : '')" width="700px" :close-on-click-modal="false">
<div v-if="configLoading" style="text-align:center;padding:40px">加载中...</div>
<div v-else-if="configError" style="color:#f56c6c;text-align:center;padding:20px">{{ configError }}</div>
<div v-else-if="!configContent" style="text-align:center;padding:40px;color:#909399">暂无配置</div>
<pre v-else class="config-json" v-html="highlightedJson"></pre>
<template #footer>
<el-button @click="configVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 变更记录对话框 -->
<el-dialog v-model="historyVisible" :title="'变更记录:' + historySiteId" width="800px" :close-on-click-modal="false">
<el-table :data="historyList" stripe size="small" v-loading="historyLoading" empty-text="暂无记录">
<el-table-column label="名称" width="160">
<template #default="scope">
<div v-for="(n, i) in scope.row.names" :key="i" class="text-xs">{{ n }}</div>
</template>
</el-table-column>
<el-table-column label="地址" min-width="180">
<template #default="scope">
<div v-for="(u, i) in scope.row.urls" :key="i" class="text-xs">{{ u }}</div>
</template>
</el-table-column>
<el-table-column label="站点状态" width="90">
<template #default="scope">
<el-tag size="small" :type="tagForStatus(scope.row.siteStatus)">{{ scope.row.siteStatus }}</el-tag>
</template>
</el-table-column>
<el-table-column label="审核" width="80">
<template #default="scope">
<el-tag size="small" :type="scope.row.status === 'approved' ? 'success' : scope.row.status === 'pending' ? 'warning' : 'danger'">
{{ scope.row.status === 'approved' ? '已通过' : scope.row.status === 'pending' ? '待审核' : '已拒绝' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" min-width="100">
<template #default="scope">
<span class="text-xs">{{ scope.row.notes || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="时间" width="160" />
</el-table>
<template #footer>
<el-button @click="historyVisible = false">关闭</el-button>
</template>
</el-dialog>
<div style="display:flex;gap:12px;flex:1;min-height:0">
<!-- 左侧:JSON 编辑器 -->
<div style="flex:0 0 460px;display:flex;flex-direction:column;min-height:0">
<div style="font-size:13px;font-weight:500;margin-bottom:4px">pt-sites.json</div>
<div style="flex:1;min-height:0;border-radius:4px;overflow:hidden">
<CodeEditor v-model="editorJson" language="json" />
</div>
</div>
<!-- 右侧:可视化表格 -->
<div style="flex:1;overflow:auto;min-height:0">
<el-table :data="parsed" stripe size="small" v-loading="loading" border empty-text="暂无站点">
<el-table-column prop="siteId" label="站点ID" width="110" />
<el-table-column label="名称" min-width="130">
<template #default="s">{{ (s.row.names||[]).join(' / ') || '-' }}</template>
</el-table-column>
<el-table-column label="地址" min-width="180" show-overflow-tooltip>
<template #default="s">{{ (s.row.urls||[]).join(' / ') }}</template>
</el-table-column>
<el-table-column prop="siteType" label="类型" width="100" />
<el-table-column label="状态" width="80">
<template #default="s">
<el-tag size="small" :type="s.row.status==='active'?'success':'info'">{{ s.row.status||'active' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="适配版本" width="100">
<template #default="s">
<el-tag v-if="s.row.latestVersion" size="small" type="primary">v{{ s.row.latestVersion }}</el-tag>
<span v-else style="color:#909399">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="s">
<el-button size="small" type="danger" text @click="removeSite(s.$index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</template>
<script setup>
import api from '../api'
const sites = ref([]), pendingCount = ref(0)
const loading = ref(false)
const editVisible = ref(false), saving = ref(false)
const editForm = reactive({ id: null, siteId: '', namesText: '', urlsText: '', siteType: '', status: '', description: '' })
const siteTypes = [
{ value: 'nexusphp', label: 'NexusPHP' }, { value: 'mtorrent', label: 'MTorrent' },
{ value: 'gazelle', label: 'Gazelle' }, { value: 'unit3d', label: 'UNIT3D' },
{ value: 'unknown', label: '未知' },
]
const loading = ref(false), saving = ref(false), pendingCount = ref(0)
const editorJson = ref('[]')
const configVisible = ref(false), configLoading = ref(false), configError = ref(''), configContent = ref(''), configSiteId = ref(''), configVersion = ref('')
const historyVisible = ref(false), historyLoading = ref(false), historyList = ref([]), historySiteId = ref('')
const highlightedJson = computed(() => {
try { return syntaxHighlight(JSON.stringify(JSON.parse(configContent.value), null, 2)) }
catch { return escapeHtml(configContent.value) }
const parsed = computed(() => {
try { return JSON.parse(editorJson.value) } catch { return [] }
})
const fetchAll = async () => {
const refresh = async () => {
loading.value = true
try {
const r = await api.sites.adminList(); sites.value = r.data || []
try { pendingCount.value = ((await api.sites.pendingChanges()).data || []).length }
catch { pendingCount.value = 0 }
}
catch { ElMessage.error('获取站点列表失败') }
const r = await api.raw.get('/admin/sites')
const sites = Array.isArray(r?.data) ? r.data : r || []
editorJson.value = JSON.stringify(sites, null, 2)
try { pendingCount.value = ((await api.sites.pendingChanges()).data || []).length } catch { pendingCount.value = 0 }
} catch { ElMessage.error('加载失败') }
finally { loading.value = false }
}
const handleEdit = (row) => {
editForm.id = row.id
editForm.siteId = row.siteId
editForm.namesText = (row.names || []).join('\n')
editForm.urlsText = (row.urls || []).join('\n')
editForm.siteType = row.siteType || ''
editForm.status = row.status || 'active'
editForm.description = row.description || ''
editVisible.value = true
const addSite = () => {
const arr = parsed.value
arr.push({ siteId: '', names: [''], urls: [''], siteType: 'nexusphp', status: 'active' })
editorJson.value = JSON.stringify(arr, null, 2)
}
const handleSave = async () => {
const names = editForm.namesText.split('\n').map(s => s.trim()).filter(Boolean)
const urls = editForm.urlsText.split('\n').map(s => s.trim()).filter(Boolean)
if (names.length === 0 || urls.length === 0) { ElMessage.warning('名称和地址不能为空'); return }
const removeSite = (idx) => {
const arr = parsed.value
if (arr.length <= 1) { ElMessage.warning('至少保留一个站点'); return }
arr.splice(idx, 1)
editorJson.value = JSON.stringify(arr, null, 2)
}
const saveAll = async () => {
saving.value = true
try {
await api.sites.update(editForm.siteId, {
names, urls,
siteType: editForm.siteType.trim()||'unknown',
status: editForm.status||'active',
description: editForm.description.trim()||null,
})
ElMessage.success('保存成功'); editVisible.value = false; fetchAll()
} catch (e) { ElMessage.error(e.response?.data?.message||'保存失败') }
let ok = 0, fail = 0
for (const site of parsed.value) {
if (!site.siteId) { fail++; continue }
try {
await api.raw.put('/admin/sites/'+site.siteId, {
names: site.names, urls: site.urls, siteType: site.siteType, status: site.status,
})
ok++
} catch { fail++ }
}
ElMessage.success('保存: '+ok+' 成功, '+fail+' 失败')
refresh()
} catch { ElMessage.error('保存失败') }
finally { saving.value = false }
}
const handleViewConfig = async (row) => {
configVisible.value = true; configSiteId.value = row.siteId; configVersion.value = ''; configLoading.value = true; configError.value = ''; configContent.value = ''
try {
const r = await api.configs.bySite(row.siteId)
const configs = r.data || []
if (configs.length) {
configVersion.value = configs[0].version
const resp = await api.configs.content(configs[0].siteId, configs[0].version)
configContent.value = typeof resp.data==='string' ? resp.data : JSON.stringify(resp.data,null,2)
}
} catch (e) { configError.value = e.response?.data?.message||'获取配置失败' }
finally { configLoading.value = false }
}
const handleViewHistory = async (row) => {
historySiteId.value = row.siteId; historyList.value = []; historyVisible.value = true; historyLoading.value = true
try { const r = await api.sites.changesBySite(row.siteId); historyList.value = r.data || [] }
catch { ElMessage.error('获取变更记录失败') }
finally { historyLoading.value = false }
}
const syntaxHighlight = (json) => escapeHtml(json).replace(/("(\\u[a-fA-F0-9]{4}|\\[^u]|[^"\\])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, (match) => {
let cls = 'number'
if (/^"/.test(match)) cls = /:$/.test(match) ? 'key' : 'string'
else if (/true|false/.test(match)) cls = 'boolean'
else if (/null/.test(match)) cls = 'null'
return `<span class="${cls}">${match}</span>`
})
const escapeHtml = (str) => str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
const configStatusLabel = (s) => {
const map = { approved: '已适配', pending: '待审核', rejected: '已拒绝', deprecated: '已弃用' }
return map[s] || s
}
const configStatusTag = (s) => {
const map = { approved: 'success', pending: 'warning', rejected: 'danger', deprecated: 'info' }
return map[s] || 'info'
}
onMounted(fetchAll)
onMounted(refresh)
</script>
<style scoped>
.config-json {
background: #1e1e1e;
color: #d4d4d4;
padding: 12px;
border-radius: 4px;
font-size: 12px;
line-height: 1.5;
margin: 0;
white-space: pre-wrap;
word-break: break-all;
max-height: 500px;
overflow: auto;
}
.config-json :deep(.key) { color: #9cdcfe; }
.config-json :deep(.string) { color: #ce9178; }
.config-json :deep(.number) { color: #b5cea8; }
.config-json :deep(.boolean) { color: #569cd6; }
.config-json :deep(.null) { color: #569cd6; }
</style>