369 lines
19 KiB
Vue
369 lines
19 KiB
Vue
<template>
|
||
<!-- 列表模式 -->
|
||
<div v-if="!editSiteId">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||
<h2 style="margin:0">页面配置</h2>
|
||
<el-button size="small" @click="fetchAll" :icon="Refresh">刷新</el-button>
|
||
</div>
|
||
|
||
<!-- 默认模板 -->
|
||
<el-card style="margin-bottom:12px">
|
||
<template #header><span style="font-weight:500">站点类型默认模板</span></template>
|
||
<div style="display:flex;gap:12px;flex-wrap:wrap">
|
||
<div v-for="dt in defaultTypes" :key="dt" style="border:1px solid #ebeef5;border-radius:4px;padding:8px 12px;min-width:140px">
|
||
<div style="display:flex;align-items:center;gap:8px;justify-content:space-between">
|
||
<span><el-tag size="small">{{ dt }}</el-tag></span>
|
||
<el-button size="small" @click="editPage({siteId:'default:'+dt})">编辑</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- 站点列表 -->
|
||
<el-table :data="merged" stripe v-loading="loading" border empty-text="暂无站点" style="font-size:13px" :row-style="{height:'44px'}">
|
||
<el-table-column prop="siteId" label="站点" width="140" />
|
||
<el-table-column label="名称" min-width="130">
|
||
<template #default="s">{{ (s.row.names||[]).join('/') || '-' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="入口数" width="80" align="center">
|
||
<template #default="s">{{ s.row.entryCount || 0 }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="版本" width="80" align="center">
|
||
<template #default="s">{{ s.row.version || '-' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="更新时间" width="150">
|
||
<template #default="s">{{ fmt(s.row.updatedAt) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="100">
|
||
<template #default="s"><el-button size="small" @click="editPage(s.row)">编辑</el-button></template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
|
||
<!-- 编辑模式:全屏双栏 -->
|
||
<div v-else 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:10px">
|
||
<h2 style="margin:0">编辑: {{ editSiteId }}</h2>
|
||
<!-- 快照状态(仅对非默认模板显示) -->
|
||
<el-tag v-if="!isDefaultEditor && snapshotStatus" size="small" :type="snapshotStatus.hasPendingPublish ? 'warning' : snapshotStatus.draft.exists ? 'info' : 'success'">
|
||
{{ snapshotStatus.hasPendingPublish ? '预览版 v' + snapshotStatus.draft.version : snapshotStatus.draft.exists ? '预览版 v' + snapshotStatus.draft.version : snapshotStatus.published.exists ? '正式版 v' + snapshotStatus.published.version : '未发布' }}
|
||
</el-tag>
|
||
<el-tag v-if="hasUnsavedChanges" size="small" type="danger" effect="dark">未保存</el-tag>
|
||
</div>
|
||
<div style="display:flex;gap:8px">
|
||
<el-button v-if="!isDefaultEditor && snapshotStatus" size="small" type="primary" @click="savePreview" :loading="savingPreview">保存预览版</el-button>
|
||
<el-button v-if="hasUnsavedChanges" size="small" @click="resetEditor" type="warning">重置</el-button>
|
||
|
||
|
||
<el-button v-if="!isDefaultEditor && snapshotStatus?.draft.exists" size="small" type="success" @click="publishSnapshot" :loading="publishing">发布正式版</el-button>
|
||
<el-button v-if="!isDefaultEditor && snapshotStatus?.draft.exists" size="small" type="danger" @click="discardDraft" :loading="discardingDraft">丢弃预览版</el-button>
|
||
<el-button size="small" type="danger" @click="deletePages" :loading="deleting">删除</el-button>
|
||
<el-button size="small" @click="editSiteId=''">返回列表</el-button>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:12px;flex:1;min-height:0">
|
||
<div style="flex:0 0 400px;display:flex;flex-direction:column;min-height:0">
|
||
<div style="font-size:13px;font-weight:500;margin-bottom:4px">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">
|
||
<!-- 认证方式 -->
|
||
<div style="margin-bottom:12px;padding:10px 12px;border:1px solid #e6a23c;border-radius:4px;background:#fdf6ec">
|
||
<div style="font-weight:500;margin-bottom:6px;font-size:13px">认证方式</div>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||
<el-select v-model="authType" size="small" @change="syncAuth" style="width:110px">
|
||
<el-option label="无" value="NONE"/>
|
||
<el-option label="Cookie" value="COOKIE"/>
|
||
<el-option label="X-API-Key" value="API_KEY"/>
|
||
<el-option label="自定义" value="HEADER"/>
|
||
</el-select>
|
||
<span v-if="authType==='API_KEY'" style="color:#909399;font-size:12px">API Key 由外部传入,无需配置值</span>
|
||
<span v-if="authType==='HEADER'" style="color:#909399;font-size:12px">Header 由外部传入,无需配置值</span>
|
||
<span v-if="authType==='COOKIE'" style="color:#909399;font-size:12px">mediabo 管理 cookie 生命周期</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 页面入口 -->
|
||
<div style="font-size:13px;font-weight:500;margin-bottom:8px;display:flex;justify-content:space-between;align-items:center">
|
||
<span>页面入口</span>
|
||
<el-dropdown v-if="availableKeys.length" @command="addEntryByKey">
|
||
<el-button size="small" type="primary">+ 添加</el-button>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item v-for="k in availableKeys" :key="k.key" :command="k">
|
||
{{ k.key }} - {{ k.label }}
|
||
</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
<el-button size="small" @click="addComposeEntry">+ 复合</el-button>
|
||
</div>
|
||
<div v-if="!parsed.length" style="text-align:center;padding:40px;color:#909399">暂无,点击添加</div>
|
||
<div v-for="(entry, idx) in parsed" :key="idx" style="margin-bottom:12px;padding:12px;border:1px solid #ebeef5;border-radius:4px;background:#fafafa">
|
||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px">
|
||
<!-- compose 主 key 可编辑下拉框 -->
|
||
<el-select v-if="entry.compose" v-model="entry.key" size="small" @change="syncToJson" style="width:150px">
|
||
<el-option v-for="pk in allPageKeys" :key="pk.key" :label="`${pk.key} - ${pk.label}`" :value="pk.key" />
|
||
</el-select>
|
||
<el-tag v-else size="small" type="primary">{{ entry.key }} - {{ keyLabel(entry.key) }}</el-tag>
|
||
<el-select v-if="!entry.compose" v-model="entry.method" size="small" @change="syncToJson" style="width:80px">
|
||
<el-option label="GET" value="GET"/><el-option label="POST" value="POST"/>
|
||
</el-select>
|
||
<div style="flex:1"></div>
|
||
<el-button size="small" type="danger" text @click="remove(idx)">删除</el-button>
|
||
</div>
|
||
<!-- compose 子页面编辑区 -->
|
||
<template v-if="entry.compose">
|
||
<div style="font-size:12px;color:#606266;font-weight:500;margin-bottom:4px">子页面({uid} 自动替换为用户UID):</div>
|
||
<div v-for="(sk, si) in entry.compose" :key="si" style="display:flex;gap:6px;align-items:center;margin-bottom:4px">
|
||
<el-select v-model="entry.compose[si]" size="small" @change="syncToJson" style="width:160px">
|
||
<el-option v-for="pk in allPageKeys" :key="pk.key" :label="`${pk.key} - ${pk.label}`" :value="pk.key" />
|
||
</el-select>
|
||
<el-button size="small" type="danger" text @click="removeComposeSub(entry, si)" :icon="Delete" circle />
|
||
</div>
|
||
<el-button size="small" text type="primary" @click="addComposeSub(entry)" style="align-self:flex-start">
|
||
<el-icon style="margin-right:2px"><Plus /></el-icon>添加子页面
|
||
</el-button>
|
||
</template>
|
||
<!-- 非 compose:URL / 验证 / headers -->
|
||
<template v-if="!entry.compose">
|
||
<div style="display:flex;gap:8px;align-items:flex-start;margin-bottom:6px;max-width:500px">
|
||
<el-input v-model="entry.url" size="small" placeholder="URL路径,如 /torrents.php" @input="syncToJson" style="flex:1" />
|
||
<el-button size="small" @click="verifyEntry(entry, idx)" :loading="verifying[idx]" :type="verifyResults[idx]?.success===true ? 'success' : verifyResults[idx]?.success===false ? 'danger' : ''">
|
||
{{ verifyResults[idx]?.success===true ? '✓' : verifyResults[idx]?.success===false ? '✗' : '验证' }}
|
||
</el-button>
|
||
</div>
|
||
<div v-if="verifyResults[idx]?.message" style="margin-bottom:6px;font-size:12px;padding:4px 8px;border-radius:4px" :style="{background:verifyResults[idx]?.success? '#f0f9eb':'#fef0f0',color:verifyResults[idx]?.success? '#67c23a':'#f56c6c'}">
|
||
{{ verifyResults[idx].message }}
|
||
<span v-if="verifyResults[idx].responseTimeMs != null" style="margin-left:8px;opacity:0.7">({{ verifyResults[idx].responseTimeMs }}ms)</span>
|
||
</div>
|
||
<el-input v-model="entry.headers" size="small" type="textarea" :rows="2" placeholder="自定义Header,每行一个 key: value" @input="syncToJson" style="margin-bottom:6px;max-width:500px" />
|
||
<el-input v-if="entry.method==='POST'" v-model="entry.body" size="small" type="textarea" :rows="2" placeholder="POST Body(可选)" @input="syncToJson" />
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import api from '../api'; import { Refresh, Plus, Delete } from '@element-plus/icons-vue'
|
||
|
||
const loading = ref(false), saving = ref(false), deleting = ref(false), savingPreview = ref(false), publishing = ref(false), discardingDraft = ref(false)
|
||
const sites = ref([]), pagesMap = ref({}), sitesList = ref([])
|
||
const editSiteId = ref(''), editorJson = ref('{}'), originalJson = ref('')
|
||
const snapshotStatus = ref(null)
|
||
const authType = ref('NONE')
|
||
const defaultUrls = {
|
||
index: '/index.php',
|
||
signIn: '/attendance.php',
|
||
search: '/torrents.php?search={keyword}',
|
||
download: '/download.php?id={id}',
|
||
rss: '/torrentrss.php?rows=10&search={keyword}&search_mode=1&linktype=dl&passkey={passkey}',
|
||
detail: '/details.php?id={id}',
|
||
}
|
||
const defaultTypes = ['nexusphp','mtorrent','gazelle','unit3d']
|
||
const isDefaultEditor = computed(() => editSiteId.value.startsWith('default:'))
|
||
const pageKeys = ref([])
|
||
const verifying = ref({})
|
||
const verifyResults = ref({})
|
||
|
||
const hasUnsavedChanges = computed(() => {
|
||
if (!editSiteId.value) return false
|
||
try { return JSON.stringify(JSON.parse(editorJson.value)) !== JSON.stringify(JSON.parse(originalJson.value || '{}')) }
|
||
catch { return false }
|
||
})
|
||
|
||
const loadPageKeys = async () => {
|
||
try { const r = await api.raw.get('/admin/page-keys'); pageKeys.value = r?.data || r || [] }
|
||
catch {}
|
||
}
|
||
|
||
const keyLabel = (key) => pageKeys.value.find(k => k.key === key)?.label || key
|
||
const availableKeys = computed(() => {
|
||
const used = new Set(parsed.value.map(e => e.key).filter(Boolean))
|
||
return pageKeys.value.filter(k => !used.has(k.key))
|
||
})
|
||
const allPageKeys = computed(() => pageKeys.value)
|
||
|
||
const onKeyChange = (row) => {
|
||
const pk = pageKeys.value.find(k => k.key === row.key)
|
||
if (pk?.defaultMethod) row.method = pk.defaultMethod
|
||
syncToJson()
|
||
}
|
||
const fmt = (t) => t ? (t.substring(0,19)||'').replace('T',' ') : '-'
|
||
const parsed = computed(() => { try { return JSON.parse(editorJson.value).entries || [] } catch { return [] } })
|
||
const syncAuth = () => {
|
||
const obj = (() => { try { return JSON.parse(editorJson.value) } catch { return {} } })()
|
||
obj.authType = authType.value
|
||
obj.entries = parsed.value
|
||
editorJson.value = JSON.stringify(obj, null, 2)
|
||
}
|
||
const syncToJson = () => {
|
||
const obj = { authType: authType.value, entries: parsed.value }
|
||
editorJson.value = JSON.stringify(obj, null, 2)
|
||
}
|
||
const resolveUrl = (url) => {
|
||
const base = sitesList.value.find(s=>s.siteId===editSiteId.value)?.urls?.[0] || ''
|
||
if (!url || !base) return url || ''
|
||
return url.startsWith('/') ? base.replace(/\/$/, '') + url : url
|
||
}
|
||
|
||
const verifyEntry = async (entry, idx) => {
|
||
const resolved = resolveUrl(entry.url)
|
||
if (!resolved || resolved === entry.url) {
|
||
ElMessage.warning('请先配置站点地址')
|
||
return
|
||
}
|
||
verifying.value = { ...verifying.value, [idx]: true }
|
||
verifyResults.value = { ...verifyResults.value, [idx]: { message: '验证中...' } }
|
||
try {
|
||
const siteId = editSiteId.value.startsWith('default:') ? '' : editSiteId.value
|
||
if (!siteId) {
|
||
verifyResults.value = { ...verifyResults.value, [idx]: { success: false, message: '默认模板无关联站点,请在实际站点中验证' } }
|
||
return
|
||
}
|
||
const r = await api.adapter.verifyPage(siteId, resolved, entry.key)
|
||
const data = r?.data || r
|
||
verifyResults.value = { ...verifyResults.value, [idx]: {
|
||
success: data?.success, message: data?.message || (data?.success ? '解析成功' : '解析失败'),
|
||
matched: data?.matched, responseTimeMs: data?.responseTimeMs,
|
||
pageName: data?.pageName, rowCount: data?.rowCount, fieldCount: data?.fieldCount
|
||
}}
|
||
} catch (e) {
|
||
verifyResults.value = { ...verifyResults.value, [idx]: { success: false, message: '请求异常: ' + (e.response?.data?.message || e.message) } }
|
||
} finally {
|
||
verifying.value = { ...verifying.value, [idx]: false }
|
||
}
|
||
}
|
||
const merged = computed(() => (Array.isArray(sitesList.value)?sitesList.value:[]).map(s => ({
|
||
siteId: s.siteId, names: s.names,
|
||
version: pagesMap.value[s.siteId]?.version || null, entryCount: pagesMap.value[s.siteId]?.entryCount || 0,
|
||
updatedAt: pagesMap.value[s.siteId]?.updatedAt || null, entriesJson: pagesMap.value[s.siteId]?.entriesJson || null,
|
||
})))
|
||
|
||
const fetchAll = async () => {
|
||
loading.value = true
|
||
try {
|
||
const [sr, pr] = await Promise.allSettled([api.raw.get('/admin/sites'), api.raw.get('/admin/site-pages')])
|
||
sitesList.value = sr.status==='fulfilled' ? (sr.value?.data||sr.value||[]) : []
|
||
const pages = pr.status==='fulfilled' ? (pr.value?.data||pr.value||[]) : []
|
||
const map = {}
|
||
for (const p of pages) {
|
||
const parsed = (() => { try { return JSON.parse(p.entriesJson||'{}') } catch {return {}} })()
|
||
const entries = Array.isArray(parsed) ? parsed : (parsed.entries || [])
|
||
if (!map[p.siteId] || p.version > map[p.siteId].version)
|
||
map[p.siteId] = { version: p.version, entryCount: entries.length, updatedAt: p.updatedAt, entriesJson: p.entriesJson }
|
||
}
|
||
pagesMap.value = map
|
||
} finally { loading.value = false }
|
||
}
|
||
|
||
const editPage = async (row) => {
|
||
editSiteId.value = row.siteId
|
||
const url = row.siteId.startsWith('default:')
|
||
? '/admin/default-pages/' + row.siteId.replace('default:','')
|
||
: '/admin/site-pages/' + row.siteId
|
||
try {
|
||
const r = await api.raw.get(url)
|
||
const data = r?.data || r
|
||
authType.value = data?.authType || 'NONE'
|
||
const existing = data?.entries || []
|
||
// 无配置则默认初始化所有页面类型(带默认URL)
|
||
const entries = existing.length ? existing
|
||
: pageKeys.value.map(k => ({ key: k.key, method: k.defaultMethod || 'GET', url: defaultUrls[k.key] || '' }))
|
||
editorJson.value = JSON.stringify({ authType: authType.value, entries }, null, 2)
|
||
} catch { editorJson.value = JSON.stringify({ authType:'NONE', entries:[] }, null, 2) }
|
||
originalJson.value = editorJson.value
|
||
if (!row.siteId.startsWith('default:')) fetchSnapshotStatus()
|
||
}
|
||
|
||
const fetchSnapshotStatus = async () => {
|
||
try { snapshotStatus.value = (await api.raw.get('/admin/sites/' + editSiteId.value + '/snapshot-status')).data || null }
|
||
catch { snapshotStatus.value = null }
|
||
}
|
||
|
||
const savePreview = async () => {
|
||
savingPreview.value = true
|
||
try {
|
||
// 先自动保存页面配置
|
||
const url = editSiteId.value.startsWith('default:')
|
||
? '/admin/default-pages/' + editSiteId.value.replace('default:','')
|
||
: '/sites/' + editSiteId.value + '/pages'
|
||
await api.raw.put(url, { entries: parsed.value, authType: authType.value })
|
||
originalJson.value = editorJson.value
|
||
// 再拍快照
|
||
const r = await api.raw.post('/admin/sites/' + editSiteId.value + '/save-preview')
|
||
if (r.code !== 200) { ElMessage.error(r.message || '保存预览版失败'); return }
|
||
ElMessage.success('预览版已保存'); fetchSnapshotStatus()
|
||
} catch { ElMessage.error('保存预览版失败') }
|
||
finally { savingPreview.value = false }
|
||
}
|
||
|
||
const publishSnapshot = async () => {
|
||
publishing.value = true
|
||
try { await api.raw.post('/admin/sites/' + editSiteId.value + '/publish'); ElMessage.success('已发布为正式版'); fetchSnapshotStatus() }
|
||
catch { ElMessage.error('发布失败') }
|
||
finally { publishing.value = false }
|
||
}
|
||
|
||
const resetEditor = () => {
|
||
editorJson.value = originalJson.value
|
||
ElMessage.success('已重置到上次保存的状态')
|
||
}
|
||
|
||
const discardDraft = async () => {
|
||
try { await ElMessageBox.confirm('确认丢弃预览版?将回退到正式版。', '丢弃预览版', {confirmButtonText:'确认丢弃',type:'warning'}) } catch { return }
|
||
discardingDraft.value = true
|
||
try {
|
||
await api.raw.post('/admin/sites/' + editSiteId.value + '/discard-draft')
|
||
ElMessage.success('预览版已丢弃,当前为正式版')
|
||
fetchSnapshotStatus()
|
||
} catch { ElMessage.error('丢弃预览版失败') }
|
||
finally { discardingDraft.value = false }
|
||
}
|
||
|
||
const remove = (idx) => { parsed.value.splice(idx,1); syncToJson() }
|
||
const addEntryByKey = (k) => {
|
||
parsed.value.push({ key: k.key, method: k.defaultMethod || 'GET', url: defaultUrls[k.key] || '' })
|
||
syncToJson()
|
||
}
|
||
const addComposeEntry = () => {
|
||
parsed.value.push({ key: 'index', compose: ['userdetail', 'peerStats'] })
|
||
syncToJson()
|
||
}
|
||
const addComposeSub = (entry) => {
|
||
if (!entry.compose) entry.compose = []
|
||
entry.compose.push('userdetail')
|
||
syncToJson()
|
||
}
|
||
const removeComposeSub = (entry, si) => {
|
||
entry.compose.splice(si, 1)
|
||
syncToJson()
|
||
}
|
||
const deletePages = async () => {
|
||
try { await ElMessageBox.confirm('删除后站点将使用默认模板','确认',{type:'warning',confirmButtonText:'确认删除'}) } catch { return }
|
||
deleting.value = true
|
||
try { await api.raw.delete('/admin/site-pages/'+editSiteId.value); ElMessage.success('已删除'); editSiteId.value=''; fetchAll() }
|
||
catch { ElMessage.error('删除失败') }
|
||
finally { deleting.value = false }
|
||
}
|
||
|
||
const savePages = async () => {
|
||
saving.value = true
|
||
try {
|
||
const url = editSiteId.value.startsWith('default:')
|
||
? '/admin/default-pages/' + editSiteId.value.replace('default:','')
|
||
: '/sites/' + editSiteId.value + '/pages'
|
||
await api.raw.put(url, { entries: parsed.value, authType: authType.value })
|
||
originalJson.value = editorJson.value
|
||
ElMessage.success('已保存'); editSiteId.value=''; fetchAll()
|
||
} catch { ElMessage.error('保存失败') }
|
||
finally { saving.value = false }
|
||
}
|
||
onMounted(() => { fetchAll(); loadPageKeys() })
|
||
</script>
|