5eecbecd7f
- Portal: wrap .panel-head in matching card (border, radius, shadow) with cyan->purple left accent bar; h2 28px -> 20px; description right-aligned with vertical divider; top edge now aligns with side-menu & links-panel - Login: switch from absolute-positioned icons to el-input prefix slot; layout brand row (logo + title + uppercase subtitle); add form-title section with gradient bar; consistent cyan focus; gradient button; dashed top divider for back-link; card width 420 -> 460 - Add .gitignore (node_modules, dist, __pycache__, .env, etc.)
333 lines
12 KiB
Vue
333 lines
12 KiB
Vue
<script setup>
|
||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||
import { portalApi } from '../api'
|
||
import '../styles/portal.css'
|
||
|
||
const loading = ref(true)
|
||
const settings = ref({})
|
||
const categories = ref([])
|
||
const links = ref([])
|
||
|
||
const activeCatId = ref(null)
|
||
const selections = ref({}) // { rowId: optionId }
|
||
const resources = ref([])
|
||
const resLoading = ref(false)
|
||
const copiedKey = ref('')
|
||
|
||
// Search
|
||
const searchMode = ref(false)
|
||
const searchKeyword = ref('')
|
||
const searchResults = ref([])
|
||
const searchLoading = ref(false)
|
||
const searchDebounce = ref(null)
|
||
|
||
async function doSearch() {
|
||
const kw = searchKeyword.value.trim()
|
||
if (!kw || kw.length < 2) {
|
||
searchResults.value = []
|
||
return
|
||
}
|
||
searchLoading.value = true
|
||
try {
|
||
const { data } = await portalApi.search(kw)
|
||
searchResults.value = data.results || []
|
||
} finally {
|
||
searchLoading.value = false
|
||
}
|
||
}
|
||
|
||
function onSearchInput() {
|
||
clearTimeout(searchDebounce.value)
|
||
searchDebounce.value = setTimeout(doSearch, 300)
|
||
}
|
||
|
||
function openSearch() {
|
||
searchMode.value = true
|
||
searchKeyword.value = ''
|
||
searchResults.value = []
|
||
}
|
||
|
||
function closeSearch() {
|
||
searchMode.value = false
|
||
searchKeyword.value = ''
|
||
searchResults.value = []
|
||
}
|
||
|
||
const activeCat = computed(() => categories.value.find(c => c.id === activeCatId.value) || null)
|
||
|
||
const stats = computed(() => ({
|
||
categories: categories.value.length,
|
||
links: links.value.length
|
||
}))
|
||
|
||
async function loadBootstrap() {
|
||
loading.value = true
|
||
try {
|
||
const { data } = await portalApi.bootstrap()
|
||
settings.value = data.settings || {}
|
||
categories.value = data.categories || []
|
||
links.value = data.links || []
|
||
document.title = settings.value.site_name || 'OPS 资源中心'
|
||
if (categories.value.length && !activeCatId.value) {
|
||
selectCategory(categories.value[0].id)
|
||
}
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function selectCategory(id) {
|
||
activeCatId.value = id
|
||
selections.value = {}
|
||
loadResources()
|
||
}
|
||
|
||
function toggleOption(rowId, optId) {
|
||
if (selections.value[rowId] === optId) {
|
||
delete selections.value[rowId]
|
||
} else {
|
||
selections.value[rowId] = optId
|
||
}
|
||
selections.value = { ...selections.value }
|
||
loadResources()
|
||
}
|
||
|
||
async function loadResources() {
|
||
if (!activeCatId.value) return
|
||
resLoading.value = true
|
||
try {
|
||
const ids = Object.values(selections.value)
|
||
const { data } = await portalApi.resources(activeCatId.value, ids)
|
||
resources.value = data.resources || []
|
||
} catch {
|
||
resources.value = []
|
||
} finally {
|
||
resLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function copyText(text, key) {
|
||
try {
|
||
await navigator.clipboard.writeText(text)
|
||
} catch {
|
||
const ta = document.createElement('textarea')
|
||
ta.value = text
|
||
document.body.appendChild(ta)
|
||
ta.select()
|
||
document.execCommand('copy')
|
||
document.body.removeChild(ta)
|
||
}
|
||
copiedKey.value = key
|
||
setTimeout(() => { if (copiedKey.value === key) copiedKey.value = '' }, 1600)
|
||
}
|
||
|
||
const typeLabel = { command: 'CMD', link: 'LINK', text: 'DOC', file: 'FILE' }
|
||
|
||
onMounted(() => {
|
||
loadBootstrap()
|
||
// Keyboard shortcut for search
|
||
const handleKeydown = (e) => {
|
||
if (e.key === '/' && !searchMode.value && !['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) {
|
||
e.preventDefault()
|
||
openSearch()
|
||
setTimeout(() => document.querySelector('.search-input')?.focus(), 50)
|
||
}
|
||
if (e.key === 'Escape' && searchMode.value) {
|
||
closeSearch()
|
||
}
|
||
}
|
||
window.addEventListener('keydown', handleKeydown)
|
||
onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="portal">
|
||
<!-- announcement ticker -->
|
||
<div class="ticker" v-if="settings.announcement">
|
||
<div class="ticker-inner">
|
||
<span v-for="i in 2" :key="i"><b>公告</b>{{ settings.announcement }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- hero -->
|
||
<header class="hero">
|
||
<div class="hero-grid-bg"></div>
|
||
<div class="hero-inner">
|
||
<div class="hero-left">
|
||
<div class="hero-tag">{{ settings.hero_tag || 'INTERNAL RESOURCES' }}</div>
|
||
<h1 v-html="(settings.hero_title || '内部资源 快速上手').replace(/(快速上手|GET STARTED)/, '<em>$1</em>')"></h1>
|
||
<p class="hero-sub">{{ settings.hero_subtitle }}</p>
|
||
|
||
<!-- Search button in hero -->
|
||
<div class="hero-search-btn" @click="openSearch">
|
||
<span class="hs-icon">🔍</span>
|
||
搜索内部资源
|
||
<span class="hs-hint">按 / 快速搜索</span>
|
||
</div>
|
||
|
||
<div class="terminal" v-if="settings.hero_command">
|
||
<div class="terminal-bar">
|
||
<i></i><i></i><i></i>
|
||
<span>{{ settings.hero_command_label || 'RUN THE COMMAND TO INSTALL' }}</span>
|
||
</div>
|
||
<div class="terminal-body">
|
||
<code>{{ settings.hero_command }}</code>
|
||
<button class="copy-btn" :class="{ copied: copiedKey === 'hero' }"
|
||
@click="copyText(settings.hero_command, 'hero')">
|
||
{{ copiedKey === 'hero' ? '✓ 已复制' : '复制' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="hero-stats">
|
||
<div class="hero-stat"><b>{{ stats.categories }}</b><span>资源分类</span></div>
|
||
<div class="hero-stat"><b>{{ stats.links }}</b><span>快捷入口</span></div>
|
||
<div class="hero-stat"><b>24/7</b><span>内部可用</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="hero-art">
|
||
<svg viewBox="0 0 460 380" preserveAspectRatio="xMidYMid meet">
|
||
<path class="trace" d="M20,60 H160 V150 H300 V60 H440" />
|
||
<path class="trace" d="M20,190 H110 V290 H250 V190 H440" />
|
||
<path class="trace" d="M60,20 V120 H220 V330 H400 V240" />
|
||
<path class="trace-flow" d="M20,60 H160 V150 H300 V60 H440" />
|
||
<path class="trace-flow" d="M20,190 H110 V290 H250 V190 H440" style="animation-delay:-2s" />
|
||
<path class="trace-flow" d="M60,20 V120 H220 V330 H400 V240" style="animation-delay:-3.4s" />
|
||
<g v-for="(p, i) in [[160,60],[300,60],[110,190],[250,190],[220,120],[400,240],[160,150],[250,290]]" :key="i">
|
||
<rect class="node" :x="p[0]-7" :y="p[1]-7" width="14" height="14" rx="2" />
|
||
<circle class="node-core" :cx="p[0]" :cy="p[1]" r="3" :style="{ animationDelay: (-i * .35) + 's' }" />
|
||
</g>
|
||
</svg>
|
||
<div class="cube c1"></div>
|
||
<div class="cube c2"></div>
|
||
<div class="cube c3"></div>
|
||
<div class="cube c4"></div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- Search overlay -->
|
||
<div class="search-overlay" v-if="searchMode" @click.self="closeSearch">
|
||
<div class="search-modal">
|
||
<div class="search-input-wrap">
|
||
<span class="search-icon">🔍</span>
|
||
<input type="text" v-model="searchKeyword" placeholder="搜索资源标题、内容..."
|
||
class="search-input" @input="onSearchInput" ref="searchInput">
|
||
<button class="search-close" @click="closeSearch">✕</button>
|
||
</div>
|
||
<div class="search-results" v-loading="searchLoading">
|
||
<div v-if="searchKeyword.length && searchKeyword.length < 2" class="search-hint">
|
||
请输入至少 2 个字符
|
||
</div>
|
||
<div v-else-if="!searchLoading && !searchResults.length && searchKeyword.length >= 2" class="search-empty">
|
||
未找到相关资源
|
||
</div>
|
||
<div v-for="res in searchResults" :key="res.id" class="search-result-item"
|
||
@click="selectCategory(res.category_id); closeSearch()">
|
||
<span class="res-badge-sm" :class="res.type">{{ typeLabel[res.type] || res.type }}</span>
|
||
<span class="sr-title">{{ res.title }}</span>
|
||
<span class="sr-desc" v-if="res.description">{{ res.description }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="search-footer">
|
||
<kbd>ESC</kbd> 关闭 <kbd>↑</kbd><kbd>↓</kbd> 导航
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- content -->
|
||
<div class="loading-wrap" v-if="loading">正在加载资源…</div>
|
||
|
||
<main class="content-wrap" v-else>
|
||
<!-- left menu -->
|
||
<nav class="side-menu">
|
||
<div class="side-menu-title">CATEGORIES</div>
|
||
<div v-for="cat in categories" :key="cat.id"
|
||
class="menu-item" :class="{ active: cat.id === activeCatId }"
|
||
@click="selectCategory(cat.id)">
|
||
<span class="mi-icon">{{ cat.icon }}</span>
|
||
<span>{{ cat.name }}</span>
|
||
<span class="mi-count">{{ cat.resource_count }}</span>
|
||
</div>
|
||
</nav>
|
||
|
||
<!-- center -->
|
||
<section class="center-panel">
|
||
<div class="panel-head" v-if="activeCat">
|
||
<h2>{{ activeCat.icon }} {{ activeCat.name }}</h2>
|
||
<p>{{ activeCat.description }}</p>
|
||
</div>
|
||
|
||
<!-- option tiles -->
|
||
<div v-for="row in (activeCat?.rows || [])" :key="row.id" class="option-row">
|
||
<div class="option-row-title">{{ row.title }}</div>
|
||
<div class="tiles">
|
||
<div v-for="opt in row.options" :key="opt.id"
|
||
class="tile" :class="{ selected: selections[row.id] === opt.id }"
|
||
@click="toggleOption(row.id, opt.id)">
|
||
<span class="tile-icon" v-if="opt.icon">{{ opt.icon }}</span>
|
||
{{ opt.label }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- resources -->
|
||
<div class="res-list" v-if="resources.length">
|
||
<article v-for="(res, idx) in resources" :key="res.id" class="res-card"
|
||
:style="{ animationDelay: (idx * 60) + 'ms' }">
|
||
<div class="res-head">
|
||
<span class="res-badge" :class="res.type">{{ typeLabel[res.type] || res.type }}</span>
|
||
<span class="res-title">{{ res.title }}</span>
|
||
<span class="res-desc" v-if="res.description">{{ res.description }}</span>
|
||
</div>
|
||
|
||
<div v-if="res.type === 'command'" class="cmd-block">
|
||
<code>{{ res.content }}</code>
|
||
<button class="cmd-copy" :class="{ copied: copiedKey === 'res' + res.id }"
|
||
@click="copyText(res.content, 'res' + res.id)">
|
||
{{ copiedKey === 'res' + res.id ? '✓ 已复制' : '复制' }}
|
||
</button>
|
||
</div>
|
||
|
||
<template v-else-if="res.type === 'link'">
|
||
<div class="res-body" v-if="res.content">{{ res.content }}</div>
|
||
<a class="res-jump" :href="res.url" target="_blank" rel="noopener">
|
||
{{ res.url }} 跳转 →
|
||
</a>
|
||
</template>
|
||
|
||
<template v-else-if="res.type === 'file'">
|
||
<div class="res-body" v-if="res.content">{{ res.content }}</div>
|
||
<a class="res-jump" :href="res.url" target="_blank" rel="noopener">下载文件 ↓</a>
|
||
</template>
|
||
|
||
<div v-else class="res-body">{{ res.content }}</div>
|
||
</article>
|
||
</div>
|
||
|
||
<div class="empty-hint" v-else-if="!resLoading">
|
||
<span class="eh-icon">📭</span>
|
||
该分类下暂无资源,请在管理后台添加
|
||
</div>
|
||
</section>
|
||
|
||
<!-- right links -->
|
||
<aside class="links-panel" v-if="links.length">
|
||
<h3>其他链接</h3>
|
||
<a v-for="l in links" :key="l.id" class="link-item" :href="l.url" target="_blank" rel="noopener">
|
||
{{ l.title }}
|
||
</a>
|
||
</aside>
|
||
</main>
|
||
|
||
<footer class="portal-footer">
|
||
{{ settings.footer_text || 'OPS Resource Portal' }}
|
||
<router-link to="/admin">管理后台</router-link>
|
||
</footer>
|
||
|
||
<router-link to="/admin" class="admin-fab" title="管理后台">⚙</router-link>
|
||
</div>
|
||
</template>
|