Files
par/web/src/views/SitePages.vue

369 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>