Files
ops-manage/frontend/src/views/Portal.vue
T
Hermes 5eecbecd7f feat: align portal center panel with side cards + redesign login page
- 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.)
2026-07-22 11:12:19 +08:00

333 lines
12 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.
<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> 关闭 &nbsp;&nbsp; <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 }} &nbsp;跳转
</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>