412 lines
15 KiB
Vue
412 lines
15 KiB
Vue
<template>
|
|
<div>
|
|
<h2>站点管理</h2>
|
|
|
|
<!-- 审核中的提交 -->
|
|
<el-card style="margin-top: 20px" v-if="submissions.length > 0">
|
|
<template #header><span style="font-weight:bold">待审核</span></template>
|
|
<el-table :data="submissions" stripe size="small">
|
|
<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 label="站点状态" width="90">
|
|
<template #default="scope">
|
|
<el-tag size="small" :type="scope.row.siteStatus === '正常' ? 'success' : scope.row.siteStatus === '关停' ? 'danger' : 'warning'">
|
|
{{ scope.row.siteStatus }}
|
|
</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="备注" min-width="120">
|
|
<template #default="scope">
|
|
<span class="text-xs text-muted">{{ scope.row.notes || '-' }}</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="createdAt" label="提交时间" width="160" />
|
|
<el-table-column label="操作" width="210">
|
|
<template #default="scope">
|
|
<el-button size="small" type="success" @click="handleApprove(scope.row)">通过</el-button>
|
|
<el-button size="small" type="danger" @click="handleReject(scope.row)">拒绝</el-button>
|
|
<el-button size="small" @click="handleMatch(scope.row)">匹配</el-button>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
</el-card>
|
|
|
|
<!-- 已审核站点 -->
|
|
<el-card style="margin-top: 20px">
|
|
<template #header><span style="font-weight:bold">已审核站点</span></template>
|
|
<el-table :data="sites" v-loading="loading" stripe size="small">
|
|
<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="220">
|
|
<template #default="scope">
|
|
<el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
|
|
<el-button size="small" @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 :model="editForm" label-width="80px" @submit.prevent="handleSave">
|
|
<el-form-item label="站点ID">
|
|
<el-input v-model="editForm.siteId" disabled />
|
|
</el-form-item>
|
|
<el-form-item label="名称">
|
|
<el-input v-model="editForm.name" type="textarea" :rows="3" placeholder="站点名称(一行一个)" />
|
|
</el-form-item>
|
|
<el-form-item label="地址">
|
|
<el-input v-model="editForm.baseUrl" 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-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" 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>
|
|
|
|
<!-- 手动匹配对话框 -->
|
|
<el-dialog v-model="matchVisible" title="关联到已有站点" width="500px" :close-on-click-modal="false">
|
|
<el-input v-model="matchQuery" placeholder="搜索站点名称或URL..." clearable @input="handleSearch" style="margin-bottom:12px" />
|
|
<el-table :data="matchResults" stripe size="small" v-loading="matchSearching" max-height="300" @row-click="handleLinkSite" highlight-current-row>
|
|
<el-table-column prop="siteId" label="站点ID" width="120" />
|
|
<el-table-column label="名称" min-width="150">
|
|
<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="150">
|
|
<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>
|
|
<template #footer>
|
|
<el-button @click="matchVisible = false">取消</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import api from '../api'
|
|
|
|
const sites = ref([])
|
|
const submissions = ref([])
|
|
const loading = ref(false)
|
|
const editVisible = ref(false)
|
|
const saving = ref(false)
|
|
const editForm = ref({ id: null, siteId: '', name: '', baseUrl: '', siteType: '', description: '' })
|
|
const siteTypes = [
|
|
{ value: 'nexusphp', label: 'NexusPHP' },
|
|
{ value: 'mtorrent', label: 'MTorrent' },
|
|
{ value: 'mteam', label: 'M-Team API' },
|
|
{ value: 'pttime', label: 'PTTime' },
|
|
{ value: 'gazelle', label: 'Gazelle' },
|
|
{ value: 'unit3d', label: 'UNIT3D' },
|
|
{ value: 'jsonapi', label: 'JSON API' },
|
|
{ value: 'unknown', label: '未知' },
|
|
]
|
|
|
|
// 配置查看
|
|
const matchVisible = ref(false)
|
|
const matchQuery = ref('')
|
|
const matchResults = ref([])
|
|
const matchSearching = ref(false)
|
|
let matchTargetId = ref(null)
|
|
|
|
const handleMatch = (row) => {
|
|
matchTargetId.value = row.id
|
|
matchQuery.value = (row.names || [])[0] || ''
|
|
matchVisible.value = true
|
|
handleSearch()
|
|
}
|
|
|
|
const handleSearch = async () => {
|
|
if (!matchQuery.value.trim()) { matchResults.value = []; return }
|
|
matchSearching.value = true
|
|
try {
|
|
const res = await api.sites.search(matchQuery.value.trim())
|
|
matchResults.value = res.data || []
|
|
} catch { matchResults.value = [] }
|
|
finally { matchSearching.value = false }
|
|
}
|
|
|
|
const handleLinkSite = async (row) => {
|
|
try {
|
|
await api.sites.linkChange(matchTargetId.value, row.siteId)
|
|
ElMessage.success(`已关联到 ${row.siteId}`)
|
|
matchVisible.value = false
|
|
fetchAll()
|
|
} catch (e) {
|
|
ElMessage.error(e.response?.data?.message || '关联失败')
|
|
}
|
|
}
|
|
|
|
const historyVisible = ref(false)
|
|
const historyLoading = ref(false)
|
|
const historyList = ref([])
|
|
const historySiteId = ref('')
|
|
|
|
const tagForStatus = (s) => {
|
|
if (s === '正常') return 'success'
|
|
if (s === '关停') return 'danger'
|
|
return 'warning'
|
|
}
|
|
|
|
const handleViewHistory = async (row) => {
|
|
historySiteId.value = row.siteId
|
|
historyList.value = []
|
|
historyVisible.value = true
|
|
historyLoading.value = true
|
|
try {
|
|
const res = await api.sites.changesBySite(row.siteId)
|
|
historyList.value = res.data || []
|
|
} catch (e) {
|
|
ElMessage.error('获取变更记录失败')
|
|
} finally {
|
|
historyLoading.value = false
|
|
}
|
|
}
|
|
|
|
const configVisible = ref(false)
|
|
const configLoading = ref(false)
|
|
const configError = ref('')
|
|
const configContent = ref('')
|
|
const configSiteId = ref('')
|
|
|
|
const highlightedJson = computed(() => {
|
|
try {
|
|
const obj = JSON.parse(configContent.value)
|
|
return syntaxHighlight(JSON.stringify(obj, null, 2))
|
|
} catch {
|
|
return escapeHtml(configContent.value)
|
|
}
|
|
})
|
|
|
|
const handleViewConfig = async (row) => {
|
|
configVisible.value = true
|
|
configSiteId.value = row.siteId
|
|
configLoading.value = true
|
|
configError.value = ''
|
|
configContent.value = ''
|
|
try {
|
|
const res = await api.configs.bySite(row.siteId)
|
|
const configs = res.data || []
|
|
if (configs.length === 0) {
|
|
configContent.value = ''
|
|
} else {
|
|
const latest = configs[0] // already sorted by created_at desc
|
|
const resp = await api.configs.content(latest.siteId, latest.version)
|
|
const content = resp.data
|
|
configContent.value = typeof content === 'string' ? content : JSON.stringify(content, null, 2)
|
|
}
|
|
} catch (e) {
|
|
configError.value = e.response?.data?.message || '获取配置失败'
|
|
} finally {
|
|
configLoading.value = false
|
|
}
|
|
}
|
|
|
|
// syntax highlighting (reused from Configs.vue)
|
|
const syntaxHighlight = (json) => {
|
|
return 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) => {
|
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
}
|
|
|
|
const fetchAll = async () => {
|
|
loading.value = true
|
|
try {
|
|
const [siteRes, subRes] = await Promise.all([
|
|
api.sites.adminList(),
|
|
api.sites.pendingChanges(),
|
|
])
|
|
sites.value = siteRes.data || []
|
|
submissions.value = subRes.data || []
|
|
} catch (e) {
|
|
ElMessage.error('获取数据失败')
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
const handleApprove = async (row) => {
|
|
try {
|
|
await api.sites.approveChange(row.id)
|
|
ElMessage.success('已通过')
|
|
fetchAll()
|
|
} catch (e) {
|
|
ElMessage.error(e.response?.data?.message || '操作失败')
|
|
}
|
|
}
|
|
|
|
const handleReject = async (row) => {
|
|
try {
|
|
const name = (row.names || [])[0] || row.siteId
|
|
await ElMessageBox.confirm(`确认拒绝 "${name}" 的提交?`, '警告', { type: 'warning' })
|
|
await api.sites.rejectChange(row.id)
|
|
ElMessage.success('已拒绝')
|
|
fetchAll()
|
|
} catch (e) {
|
|
if (e !== 'cancel') ElMessage.error(e.response?.data?.message || '操作失败')
|
|
}
|
|
}
|
|
|
|
const handleEdit = (row) => {
|
|
editForm.value = {
|
|
id: row.id,
|
|
siteId: row.siteId,
|
|
name: (row.names || []).join('\n'),
|
|
baseUrl: (row.urls || []).join('\n'),
|
|
siteType: row.siteType || '',
|
|
description: row.description || '',
|
|
}
|
|
editVisible.value = true
|
|
}
|
|
|
|
const handleSave = async () => {
|
|
if (!editForm.value.name.trim() || !editForm.value.baseUrl.trim()) {
|
|
ElMessage.warning('名称和地址不能为空')
|
|
return
|
|
}
|
|
saving.value = true
|
|
try {
|
|
await api.sites.update(editForm.value.siteId, {
|
|
name: editForm.value.name.trim().split('\n').filter(Boolean)[0] || '',
|
|
baseUrl: editForm.value.baseUrl.trim().split('\n').filter(Boolean)[0] || '',
|
|
siteType: editForm.value.siteType.trim() || 'unknown',
|
|
description: editForm.value.description.trim() || null,
|
|
})
|
|
ElMessage.success('保存成功')
|
|
editVisible.value = false
|
|
fetchAll()
|
|
} catch (e) {
|
|
ElMessage.error(e.response?.data?.message || '保存失败')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(fetchAll)
|
|
</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>
|