Initial commit

This commit is contained in:
liumangmang
2026-05-12 17:51:53 +08:00
commit b564ca4797
55 changed files with 6407 additions and 0 deletions
+208
View File
@@ -0,0 +1,208 @@
<template>
<div class="viewer-wrap">
<!-- Toolbar -->
<div class="viewer-bar">
<el-button size="small" text @click="router.back()">
<el-icon><ArrowLeft /></el-icon> 返回
</el-button>
<div class="viewer-title">
<el-icon><component :is="pageIcon" /></el-icon>
<span>{{ page?.name || '...' }}</span>
<el-tag v-if="page?.use_proxy" size="small" type="warning" style="margin-left:4px">代理</el-tag>
</div>
<div class="viewer-url">{{ page?.url }}</div>
<div class="viewer-actions">
<el-tooltip content="在新标签页打开">
<el-button size="small" text @click="openExternal">
<el-icon><TopRight /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="刷新">
<el-button size="small" text @click="reload">
<el-icon><Refresh /></el-icon>
</el-button>
</el-tooltip>
</div>
</div>
<!-- Loading overlay -->
<div v-if="iframeLoading" class="iframe-loading">
<el-icon class="spin" :size="32"><Loading /></el-icon>
<p>正在加载页面</p>
</div>
<!-- Error state (removed to let browser natively show iframe state) -->
<iframe
v-show="!iframeLoading"
ref="iframeRef"
:src="iframeSrc"
class="page-iframe"
@load="onLoad"
@error="onError"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, markRaw } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
ArrowLeft, TopRight, Refresh, Loading, WarningFilled,
Link as LinkIcon, Monitor, SetUp, Reading, Cpu, DataLine,
Grid, Connection, Ticket, Wallet, Key, Tools, Star, House,
} from '@element-plus/icons-vue'
import { customPagesApi, type CustomPageData } from '@/api'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const iconMap: Record<string, any> = {
Link: markRaw(LinkIcon), Monitor: markRaw(Monitor), SetUp: markRaw(SetUp),
Reading: markRaw(Reading), Cpu: markRaw(Cpu), DataLine: markRaw(DataLine),
Grid: markRaw(Grid), Connection: markRaw(Connection), Ticket: markRaw(Ticket),
Wallet: markRaw(Wallet), Key: markRaw(Key), Tools: markRaw(Tools),
Star: markRaw(Star), House: markRaw(House),
}
const page = ref<CustomPageData | null>(null)
const iframeRef = ref<HTMLIFrameElement>()
const iframeLoading = ref(true)
const loadError = ref(false)
const pageIcon = computed(() => iconMap[page.value?.icon || 'Link'] || LinkIcon)
// Build the iframe src: use backend proxy if use_proxy=true
// Pass JWT token as query param since iframe can't set Authorization header
const iframeSrc = computed(() => {
if (!page.value) return ''
if (page.value.use_proxy) {
const encoded = encodeURIComponent(page.value.url)
const token = encodeURIComponent(auth.token || '')
return `/api/custom-pages/frame-proxy?url=${encoded}&token=${token}`
}
return page.value.url
})
async function loadPage(id: number) {
iframeLoading.value = true
loadError.value = false
try {
const res = await customPagesApi.list()
page.value = res.data.find(p => p.id === id) || null
if (!page.value) {
ElMessage.error('页面不存在')
router.push('/custom-pages')
}
} catch {
ElMessage.error('加载失败')
}
}
function onLoad() {
iframeLoading.value = false
}
function onError() {
iframeLoading.value = false
}
function openExternal() {
if (page.value?.url) window.open(page.value.url, '_blank', 'noopener')
}
function reload() {
if (!iframeRef.value || !page.value) return
iframeLoading.value = true
loadError.value = false
iframeRef.value.src = iframeSrc.value
}
watch(() => route.params.id, (id) => {
if (id) loadPage(Number(id))
}, { immediate: false })
onMounted(() => {
loadPage(Number(route.params.id))
})
</script>
<style scoped>
.viewer-wrap {
/* Expand to full viewport minus topbar, ignoring parent padding */
position: fixed;
inset: 0;
top: var(--topbar-height);
left: var(--sidebar-width);
display: flex;
flex-direction: column;
background: var(--bg-base);
z-index: 10;
}
.viewer-bar {
height: 44px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 10px;
padding: 0 12px;
flex-shrink: 0;
}
.viewer-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
}
.viewer-url {
flex: 1;
font-size: 12px;
color: var(--text-muted);
background: var(--bg-elevated);
border-radius: 6px;
padding: 4px 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.viewer-actions { display: flex; gap: 4px; }
.page-iframe {
flex: 1;
width: 100%;
border: none;
background: #fff;
}
.iframe-loading, .iframe-error {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-muted);
}
.spin {
animation: spin 1s linear infinite;
color: var(--color-primary);
}
@keyframes spin { to { transform: rotate(360deg); } }
.error-icon { color: #f59e0b; }
.error-title { font-size: 16px; font-weight: 600; color: var(--text-primary); }
.error-sub { font-size: 13px; color: var(--text-muted); margin-bottom: 8px; }
</style>