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.)
This commit is contained in:
Hermes
2026-07-22 11:12:19 +08:00
commit 5eecbecd7f
32 changed files with 7371 additions and 0 deletions
+332
View File
@@ -0,0 +1,332 @@
<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>