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
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OPS 资源中心</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1870
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "ops-portal-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.9",
"element-plus": "^2.9.3",
"@element-plus/icons-vue": "^2.3.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.7"
}
}
+3
View File
@@ -0,0 +1,3 @@
<template>
<router-view />
</template>
+105
View File
@@ -0,0 +1,105 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { getToken, removeToken } from '../utils/auth'
import router from '../router'
const api = axios.create({
baseURL: '/api',
timeout: 30000
})
api.interceptors.request.use((config) => {
const token = getToken()
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
api.interceptors.response.use(
(res) => res,
(err) => {
const status = err.response?.status
const msg = err.response?.data?.error || err.message || '请求失败'
if (status === 401 && router.currentRoute.value.path.startsWith('/admin')) {
removeToken()
router.push({ name: 'admin-login' })
}
ElMessage.error(msg)
return Promise.reject(err)
}
)
// ---------- Portal (public) ----------
export const portalApi = {
bootstrap: () => api.get('/portal/bootstrap'),
resources: (categoryId, optionIds = [], keyword = '') =>
api.get(`/portal/categories/${categoryId}/resources`, {
params: {
...(optionIds.length ? { option_ids: optionIds.join(',') } : {}),
...(keyword ? { keyword } : {})
}
}),
search: (keyword) => api.get('/portal/search', { params: { keyword } })
}
// ---------- Auth ----------
export const authApi = {
login: (username, password) => api.post('/auth/login', { username, password }),
me: () => api.get('/auth/me'),
changePassword: (old_password, new_password) => api.post('/auth/password', { old_password, new_password })
}
// ---------- Admin ----------
export const adminApi = {
stats: () => api.get('/admin/stats'),
// categories
listCategories: () => api.get('/admin/categories'),
createCategory: (data) => api.post('/admin/categories', data),
updateCategory: (id, data) => api.put(`/admin/categories/${id}`, data),
deleteCategory: (id) => api.delete(`/admin/categories/${id}`),
moveCategory: (id, direction) => api.post(`/admin/categories/${id}/move`, { direction }),
// option rows
createRow: (categoryId, data) => api.post(`/admin/categories/${categoryId}/rows`, data),
updateRow: (id, data) => api.put(`/admin/rows/${id}`, data),
deleteRow: (id) => api.delete(`/admin/rows/${id}`),
// options
createOption: (rowId, data) => api.post(`/admin/rows/${rowId}/options`, data),
updateOption: (id, data) => api.put(`/admin/options/${id}`, data),
deleteOption: (id) => api.delete(`/admin/options/${id}`),
// resources
listResources: (params) => api.get('/admin/resources', { params }),
createResource: (data) => api.post('/admin/resources', data),
updateResource: (id, data) => api.put(`/admin/resources/${id}`, data),
deleteResource: (id) => api.delete(`/admin/resources/${id}`),
// side links
listLinks: () => api.get('/admin/links'),
createLink: (data) => api.post('/admin/links', data),
updateLink: (id, data) => api.put(`/admin/links/${id}`, data),
deleteLink: (id) => api.delete(`/admin/links/${id}`),
// settings
getSettings: () => api.get('/admin/settings'),
updateSettings: (data) => api.put('/admin/settings', data),
// users
listUsers: () => api.get('/admin/users'),
createUser: (data) => api.post('/admin/users', data),
resetUserPassword: (id, password) => api.put(`/admin/users/${id}/password`, { password }),
deleteUser: (id) => api.delete(`/admin/users/${id}`),
// upload
uploadFile: (file, onProgress) => {
const formData = new FormData()
formData.append('file', file)
return api.post('/admin/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: onProgress
})
}
}
export default api
+16
View File
@@ -0,0 +1,16 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(ElementPlus, { locale: zhCn })
app.use(router)
app.mount('#app')
+42
View File
@@ -0,0 +1,42 @@
import { createRouter, createWebHistory } from 'vue-router'
import { getToken } from '../utils/auth'
const routes = [
{
path: '/',
name: 'portal',
component: () => import('../views/Portal.vue')
},
{
path: '/admin/login',
name: 'admin-login',
component: () => import('../views/admin/Login.vue')
},
{
path: '/admin',
component: () => import('../views/admin/Layout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', redirect: '/admin/dashboard' },
{ path: 'dashboard', name: 'admin-dashboard', component: () => import('../views/admin/Dashboard.vue') },
{ path: 'categories', name: 'admin-categories', component: () => import('../views/admin/Categories.vue') },
{ path: 'resources', name: 'admin-resources', component: () => import('../views/admin/Resources.vue') },
{ path: 'links', name: 'admin-links', component: () => import('../views/admin/Links.vue') },
{ path: 'settings', name: 'admin-settings', component: () => import('../views/admin/Settings.vue') },
{ path: 'users', name: 'admin-users', component: () => import('../views/admin/Users.vue') }
]
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach((to) => {
if (to.meta.requiresAuth && !getToken()) {
return { name: 'admin-login', query: { redirect: to.fullPath } }
}
})
export default router
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
const STORAGE_KEY = 'ops_portal_auth_token'
export function getToken() {
return localStorage.getItem(STORAGE_KEY)
}
export function setToken(token) {
localStorage.setItem(STORAGE_KEY, token)
}
export function removeToken() {
localStorage.removeItem(STORAGE_KEY)
}
+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>
+233
View File
@@ -0,0 +1,233 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { adminApi } from '../../api'
const categories = ref([])
const loading = ref(true)
const catDialog = ref(false)
const catForm = ref({ id: null, name: '', icon: '📁', description: '', sort_order: 0 })
const rowDialog = ref(false)
const rowForm = ref({ id: null, category_id: null, title: '', sort_order: 0 })
const optDialog = ref(false)
const optForm = ref({ id: null, row_id: null, label: '', icon: '', sort_order: 0 })
async function load() {
loading.value = true
try {
const { data } = await adminApi.listCategories()
categories.value = data
} finally { loading.value = false }
}
// ---------- category ----------
function openCat(cat) {
catForm.value = cat
? { id: cat.id, name: cat.name, icon: cat.icon, description: cat.description, sort_order: cat.sort_order }
: { id: null, name: '', icon: '📁', description: '', sort_order: categories.value.length }
catDialog.value = true
}
async function saveCat() {
if (!catForm.value.name.trim()) return ElMessage.warning('请输入分类名称')
const payload = { ...catForm.value }
if (payload.id) await adminApi.updateCategory(payload.id, payload)
else await adminApi.createCategory(payload)
ElMessage.success('已保存')
catDialog.value = false
load()
}
async function delCat(cat) {
await ElMessageBox.confirm(`删除分类「${cat.name}」将同时删除其下所有筛选行、选项和资源,确定?`, '警告', { type: 'warning' })
await adminApi.deleteCategory(cat.id)
ElMessage.success('已删除')
load()
}
async function moveCat(cat, dir) {
await adminApi.moveCategory(cat.id, dir)
load()
}
async function toggleCat(cat) {
await adminApi.updateCategory(cat.id, { is_active: !cat.is_active })
ElMessage.success(cat.is_active ? '已隐藏' : '已显示')
load()
}
// ---------- option row ----------
function openRow(cat, row) {
rowForm.value = row
? { id: row.id, category_id: cat.id, title: row.title, sort_order: row.sort_order }
: { id: null, category_id: cat.id, title: '', sort_order: (cat.rows || []).length }
rowDialog.value = true
}
async function saveRow() {
if (!rowForm.value.title.trim()) return ElMessage.warning('请输入筛选行名称')
const payload = { ...rowForm.value }
const cid = payload.category_id
delete payload.category_id
if (payload.id) await adminApi.updateRow(payload.id, payload)
else await adminApi.createRow(cid, payload)
ElMessage.success('已保存')
rowDialog.value = false
load()
}
async function delRow(row) {
await ElMessageBox.confirm(`删除筛选行「${row.title}」及其所有选项?`, '警告', { type: 'warning' })
await adminApi.deleteRow(row.id)
ElMessage.success('已删除')
load()
}
// ---------- option ----------
function openOpt(row, opt) {
optForm.value = opt
? { id: opt.id, row_id: row.id, label: opt.label, icon: opt.icon, sort_order: opt.sort_order }
: { id: null, row_id: row.id, label: '', icon: '', sort_order: (row.options || []).length }
optDialog.value = true
}
async function saveOpt() {
if (!optForm.value.label.trim()) return ElMessage.warning('请输入选项名称')
const payload = { ...optForm.value }
const rid = payload.row_id
delete payload.row_id
if (payload.id) await adminApi.updateOption(payload.id, payload)
else await adminApi.createOption(rid, payload)
ElMessage.success('已保存')
optDialog.value = false
load()
}
async function delOpt(opt) {
await ElMessageBox.confirm(`删除选项「${opt.label}」?`, '警告', { type: 'warning' })
await adminApi.deleteOption(opt.id)
ElMessage.success('已删除')
load()
}
onMounted(load)
</script>
<template>
<div v-loading="loading">
<el-card>
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center">
<b>分类列表门户左侧菜单</b>
<el-button type="primary" icon="Plus" @click="openCat(null)">新增分类</el-button>
</div>
</template>
<el-table :data="categories" row-key="id">
<el-table-column type="expand">
<template #default="{ row }">
<div class="expand-body">
<div class="expand-head">
<span>筛选行用户点击的瓦片分组运行环境操作系统</span>
<el-button size="small" type="primary" plain icon="Plus" @click="openRow(row, null)">添加筛选行</el-button>
</div>
<div v-if="!row.rows?.length" class="expand-empty">暂无筛选行 该分类下的资源将直接全部展示</div>
<div v-for="r in row.rows" :key="r.id" class="row-block">
<div class="row-line">
<el-tag type="info" effect="plain">{{ r.title }}</el-tag>
<div class="row-opts">
<el-tag v-for="o in r.options" :key="o.id" closable class="opt-tag"
@close="delOpt(o)" @click="openOpt(r, o)" style="cursor:pointer">
{{ o.icon ? o.icon + ' ' : '' }}{{ o.label }}
</el-tag>
<el-tag class="opt-add" @click="openOpt(r, null)">+ 选项</el-tag>
</div>
<div class="row-actions">
<el-button size="small" text icon="Edit" @click="openRow(row, r)" />
<el-button size="small" text type="danger" icon="Delete" @click="delRow(r)" />
</div>
</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="icon" label="图标" width="70" align="center">
<template #default="{ row }"><span style="font-size:18px">{{ row.icon }}</span></template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
<el-table-column prop="resource_count" label="资源数" width="80" align="center" />
<el-table-column label="筛选行" width="80" align="center">
<template #default="{ row }">{{ row.rows?.length || 0 }}</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-switch :model-value="row.is_active" @change="toggleCat(row)" size="small" />
</template>
</el-table-column>
<el-table-column label="排序" width="110" align="center">
<template #default="{ row }">
<el-button size="small" text icon="Top" @click="moveCat(row, 'up')" />
<el-button size="small" text icon="Bottom" @click="moveCat(row, 'down')" />
</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center">
<template #default="{ row }">
<el-button size="small" text icon="Edit" @click="openCat(row)" />
<el-button size="small" text type="danger" icon="Delete" @click="delCat(row)" />
</template>
</el-table-column>
</el-table>
</el-card>
<!-- category dialog -->
<el-dialog v-model="catDialog" :title="catForm.id ? '编辑分类' : '新增分类'" width="440px">
<el-form label-width="80px">
<el-form-item label="名称"><el-input v-model="catForm.name" placeholder="如:pypi源" /></el-form-item>
<el-form-item label="图标"><el-input v-model="catForm.icon" placeholder="emoji,如 🐍" maxlength="4" /></el-form-item>
<el-form-item label="描述"><el-input v-model="catForm.description" placeholder="显示在分类标题旁" /></el-form-item>
<el-form-item label="排序"><el-input-number v-model="catForm.sort_order" :min="0" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="catDialog = false">取消</el-button>
<el-button type="primary" @click="saveCat">保存</el-button>
</template>
</el-dialog>
<!-- row dialog -->
<el-dialog v-model="rowDialog" :title="rowForm.id ? '编辑筛选行' : '添加筛选行'" width="400px">
<el-form label-width="80px">
<el-form-item label="名称"><el-input v-model="rowForm.title" placeholder="如:运行环境 / 操作系统" /></el-form-item>
<el-form-item label="排序"><el-input-number v-model="rowForm.sort_order" :min="0" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="rowDialog = false">取消</el-button>
<el-button type="primary" @click="saveRow">保存</el-button>
</template>
</el-dialog>
<!-- option dialog -->
<el-dialog v-model="optDialog" :title="optForm.id ? '编辑选项' : '添加选项'" width="400px">
<el-form label-width="80px">
<el-form-item label="名称"><el-input v-model="optForm.label" placeholder="如:物理机 / ubuntu" /></el-form-item>
<el-form-item label="图标"><el-input v-model="optForm.icon" placeholder="可选 emoji,如 🖥️" maxlength="4" /></el-form-item>
<el-form-item label="排序"><el-input-number v-model="optForm.sort_order" :min="0" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="optDialog = false">取消</el-button>
<el-button type="primary" @click="saveOpt">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.expand-body { padding: 8px 24px 16px 48px; background: #fafbfd; }
.expand-head {
display: flex; justify-content: space-between; align-items: center;
font-size: 13px; color: #8b9aab; margin-bottom: 10px;
}
.expand-empty { font-size: 13px; color: #b3bfcc; padding: 8px 0; }
.row-block { padding: 8px 0; border-bottom: 1px dashed #e8edf3; }
.row-block:last-child { border-bottom: none; }
.row-line { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.row-opts { display: flex; gap: 8px; flex-wrap: wrap; flex: 1; }
.opt-tag:hover { border-color: #409eff; }
.opt-add { cursor: pointer; border-style: dashed; }
.row-actions { display: flex; }
</style>
+373
View File
@@ -0,0 +1,373 @@
<script setup>
import { ref, onMounted } from 'vue'
import { adminApi } from '../../api'
const stats = ref({})
const loading = ref(true)
const cards = [
{ key: 'categories', label: '资源分类', icon: '🗂️', color: '#0ea5e9', gradient: 'linear-gradient(135deg, #0ea5e9 0%, #06b6d4 100%)' },
{ key: 'resources', label: '已发布资源', icon: '📦', color: '#8b5cf6', gradient: 'linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%)' },
{ key: 'active_resources', label: '启用中', icon: '✅', color: '#10b981', gradient: 'linear-gradient(135deg, #10b981 0%, #34d399 100%)' },
{ key: 'links', label: '侧栏链接', icon: '🔗', color: '#f97316', gradient: 'linear-gradient(135deg, #f97316 0%, #fb923c 100%)' }
]
onMounted(async () => {
try {
const { data } = await adminApi.stats()
stats.value = data
} finally { loading.value = false }
})
</script>
<template>
<div v-loading="loading" class="dashboard">
<!-- Stats Cards -->
<div class="stats-grid">
<el-card v-for="c in cards" :key="c.key" shadow="never">
<div class="stat-bg" :style="{ background: c.gradient }"></div>
<div class="stat-icon-wrap" :style="{ background: c.color + '20', color: c.color }">
<span class="stat-icon">{{ c.icon }}</span>
</div>
<div class="stat-content">
<div class="stat-value">{{ stats[c.key] ?? '—' }}</div>
<div class="stat-label">{{ c.label }}</div>
</div>
<div class="stat-trend">
<el-icon><TrendCharts /></el-icon>
</div>
</el-card>
</div>
<!-- Quick Actions -->
<div class="section-card">
<div class="section-header">
<h2>快捷操作</h2>
<span class="section-desc">快速访问常用功能</span>
</div>
<div class="action-grid">
<router-link to="/admin/categories" class="action-btn">
<div class="action-icon" style="background: linear-gradient(135deg, #0ea5e9, #06b6d4)">
<el-icon><FolderAdd /></el-icon>
</div>
<span>新增分类</span>
</router-link>
<router-link to="/admin/resources" class="action-btn">
<div class="action-icon" style="background: linear-gradient(135deg, #8b5cf6, #a78bfa)">
<el-icon><DocumentAdd /></el-icon>
</div>
<span>发布资源</span>
</router-link>
<router-link to="/admin/links" class="action-btn">
<div class="action-icon" style="background: linear-gradient(135deg, #f97316, #fb923c)">
<el-icon><Link /></el-icon>
</div>
<span>管理链接</span>
</router-link>
<router-link to="/admin/settings" class="action-btn">
<div class="action-icon" style="background: linear-gradient(135deg, #10b981, #34d399)">
<el-icon><Setting /></el-icon>
</div>
<span>站点设置</span>
</router-link>
<router-link to="/" class="action-btn">
<div class="action-icon" style="background: linear-gradient(135deg, #6366f1, #818cf8)">
<el-icon><View /></el-icon>
</div>
<span>预览门户</span>
</router-link>
<router-link to="/admin/users" class="action-btn">
<div class="action-icon" style="background: linear-gradient(135deg, #ec4899, #f472b6)">
<el-icon><User /></el-icon>
</div>
<span>用户管理</span>
</router-link>
</div>
</div>
<!-- Workflow Steps -->
<div class="section-card">
<div class="section-header">
<h2>使用指南</h2>
<span class="section-desc">资源发布完整流程</span>
</div>
<div class="workflow-steps">
<div class="step-item">
<div class="step-number" style="background: linear-gradient(135deg, #0ea5e9, #06b6d4)">1</div>
<div class="step-content">
<h3>创建分类</h3>
<p>建立资源分类目录配置筛选选项</p>
</div>
</div>
<div class="step-line"></div>
<div class="step-item">
<div class="step-number" style="background: linear-gradient(135deg, #8b5cf6, #a78bfa)">2</div>
<div class="step-content">
<h3>配置筛选</h3>
<p>如运行环境操作系统版本标签</p>
</div>
</div>
<div class="step-line"></div>
<div class="step-item">
<div class="step-number" style="background: linear-gradient(135deg, #10b981, #34d399)">3</div>
<div class="step-content">
<h3>发布资源</h3>
<p>支持命令链接文档文件等类型</p>
</div>
</div>
<div class="step-line"></div>
<div class="step-item">
<div class="step-number" style="background: linear-gradient(135deg, #f97316, #fb923c)">4</div>
<div class="step-content">
<h3>门户展示</h3>
<p>用户按需筛选查看一键复制使用</p>
</div>
</div>
</div>
</div>
</div>
</template>
<style>
.dashboard {
max-width: 100%;
padding-top: 0;
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stats-grid .el-card {
position: relative;
background: #ffffff;
border-radius: 12px;
padding: 20px;
border: 1px solid #e2e8f0;
display: flex;
align-items: center;
gap: 14px;
overflow: hidden;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
transition: all 0.3s ease;
}
.stats-grid .el-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12);
border-color: rgba(139, 92, 246, 0.2);
}
.stats-grid .el-card :deep(.el-card__body) {
padding: 0;
display: flex;
align-items: center;
gap: 16px;
width: 100%;
}
.stat-bg {
position: absolute;
top: 0;
right: 0;
width: 120px;
height: 120px;
opacity: 0.06;
border-radius: 50%;
transform: translate(30%, -30%);
pointer-events: none;
}
.stat-icon-wrap {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-icon {
font-size: 24px;
}
.stat-content {
flex: 1;
}
.stat-value {
font-size: 32px;
font-weight: 700;
color: #0f172a;
line-height: 1.1;
letter-spacing: -0.02em;
}
.stat-label {
font-size: 13px;
color: #64748b;
margin-top: 6px;
font-weight: 500;
}
.stat-trend {
color: #94a3b8;
font-size: 20px;
opacity: 0.5;
}
/* Section Card */
.section-card {
background: #ffffff;
border-radius: 12px;
border: 1px solid #e2e8f0;
padding: 24px;
margin-bottom: 20px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
}
.section-header {
margin-bottom: 20px;
display: flex;
align-items: baseline;
gap: 12px;
}
.section-header h2 {
font-size: 18px;
font-weight: 600;
color: #0f172a;
margin: 0;
}
.section-desc {
font-size: 13px;
color: #94a3b8;
}
/* Action Grid */
.action-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 16px;
}
.action-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 20px 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
text-decoration: none;
color: #475569;
font-size: 13.5px;
font-weight: 500;
transition: all 0.25s ease;
}
.action-btn:hover {
background: #ffffff;
border-color: #cbd5e1;
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
color: #0f172a;
}
.action-icon {
width: 42px;
height: 42px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 19px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
/* Workflow Steps */
.workflow-steps {
display: flex;
align-items: flex-start;
gap: 0;
}
.step-item {
display: flex;
align-items: flex-start;
gap: 16px;
flex: 1;
}
.step-number {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 18px;
font-weight: 700;
flex-shrink: 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.step-content h3 {
font-size: 15px;
font-weight: 600;
color: #0f172a;
margin: 0 0 6px;
}
.step-content p {
font-size: 13px;
color: #64748b;
margin: 0;
line-height: 1.6;
}
.step-line {
width: 60px;
height: 2px;
background: linear-gradient(90deg, #e2e8f0, #e2e8f0);
margin: 20px 16px 0;
flex-shrink: 0;
}
/* Responsive */
@media (max-width: 1200px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.action-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: 1fr;
}
.action-grid {
grid-template-columns: repeat(2, 1fr);
}
.workflow-steps {
flex-direction: column;
gap: 20px;
}
.step-line {
width: 2px;
height: 30px;
margin: 0 0 0 21px;
}
}
</style>
+391
View File
@@ -0,0 +1,391 @@
<script setup>
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { removeToken } from '../../utils/auth'
import { authApi } from '../../api'
import { ref } from 'vue'
const router = useRouter()
const pwdDialog = ref(false)
const pwdForm = ref({ old_password: '', new_password: '' })
const pwdLoading = ref(false)
function logout() {
removeToken()
router.push('/admin/login')
}
async function changePwd() {
if (!pwdForm.value.old_password || !pwdForm.value.new_password) {
ElMessage.warning('请填写完整')
return
}
pwdLoading.value = true
try {
await authApi.changePassword(pwdForm.value.old_password, pwdForm.value.new_password)
ElMessage.success('密码已修改,请重新登录')
pwdDialog.value = false
logout()
} catch { /* toasted */ } finally { pwdLoading.value = false }
}
const menus = [
{ path: '/admin/dashboard', icon: 'Odometer', label: '仪表盘' },
{ path: '/admin/categories', icon: 'Menu', label: '分类管理' },
{ path: '/admin/resources', icon: 'Files', label: '资源管理' },
{ path: '/admin/links', icon: 'Link', label: '侧栏链接' },
{ path: '/admin/settings', icon: 'Setting', label: '站点设置' },
{ path: '/admin/users', icon: 'User', label: '用户管理' }
]
</script>
<template>
<el-container class="admin-layout">
<el-aside width="260px" class="aside">
<div class="brand">
<div class="brand-logo-wrap">
<span class="brand-logo"></span>
<div class="brand-glow"></div>
</div>
<div class="brand-text">
<b>OPS 资源中心</b>
<small>ADMIN CONSOLE</small>
</div>
</div>
<el-menu router :default-active="$route.path" class="aside-menu"
background-color="transparent" text-color="#94a3b8" active-text-color="#2dd9f0">
<el-menu-item v-for="m in menus" :key="m.path" :index="m.path" class="menu-item-wrapper">
<el-icon class="menu-icon"><component :is="m.icon" /></el-icon>
<span class="menu-label">{{ m.label }}</span>
</el-menu-item>
</el-menu>
<div class="aside-foot">
<router-link to="/" class="portal-link">
<el-icon><View /></el-icon>
<span>查看门户</span>
</router-link>
</div>
</el-aside>
<el-container class="main-container">
<el-header class="header">
<div class="header-left">
<h1 class="page-title">{{ menus.find(m => m.path === $route.path)?.label || '管理后台' }}</h1>
</div>
<div class="header-actions">
<el-button class="header-btn" @click="pwdDialog = true">
<el-icon><Lock /></el-icon>
修改密码
</el-button>
<el-button class="header-btn logout-btn" @click="logout">
<el-icon><SwitchButton /></el-icon>
退出登录
</el-button>
</div>
</el-header>
<el-main class="main">
<router-view />
</el-main>
</el-container>
</el-container>
<el-dialog v-model="pwdDialog" title="修改密码" width="440px" class="modern-dialog">
<template #header>
<div class="dialog-header">
<el-icon class="dialog-icon"><Lock /></el-icon>
<span>修改密码</span>
</div>
</template>
<el-form label-width="90px" class="pwd-form">
<el-form-item label="原密码">
<el-input v-model="pwdForm.old_password" type="password" show-password placeholder="请输入原密码" />
</el-form-item>
<el-form-item label="新密码">
<el-input v-model="pwdForm.new_password" type="password" show-password placeholder="请输入新密码" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="pwdDialog = false" class="btn-secondary">取消</el-button>
<el-button type="primary" :loading="pwdLoading" @click="changePwd" class="btn-primary">确定</el-button>
</template>
</el-dialog>
</template>
<style>
.admin-layout {
min-height: 100vh;
background: #f8fafc;
}
.aside {
background: linear-gradient(180deg, #0a1424 0%, #0e1c33 50%, #0f172a 100%);
display: flex;
flex-direction: column;
border-right: 1px solid rgba(45, 217, 240, 0.12);
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15);
}
.brand {
display: flex;
align-items: center;
gap: 16px;
padding: 24px 24px;
border-bottom: 1px solid rgba(45, 217, 240, 0.1);
position: relative;
margin-top: 0;
padding-top: 24px;
}
.brand-logo-wrap {
position: relative;
}
.brand-logo {
width: 50px;
height: 50px;
border-radius: 14px;
background: linear-gradient(135deg, rgba(45, 217, 240, 0.15) 0%, rgba(45, 217, 240, 0.05) 100%);
border: 2px solid rgba(45, 217, 240, 0.4);
color: #2dd9f0;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
position: relative;
z-index: 1;
}
.brand-glow {
position: absolute;
inset: -8px;
background: radial-gradient(circle, rgba(45, 217, 240, 0.3) 0%, transparent 70%);
filter: blur(12px);
z-index: 0;
}
.brand-text b {
color: #f1f5f9;
font-size: 17px;
display: block;
font-weight: 700;
letter-spacing: -0.02em;
}
.brand-text small {
color: #64748b;
font-size: 11px;
letter-spacing: 0.2em;
margin-top: 4px;
display: block;
}
.aside :deep(.el-menu) {
border-right: none;
flex: 1;
padding: 16px 12px;
background: transparent;
}
.aside :deep(.el-menu-item) {
margin-bottom: 4px;
border-radius: 10px;
transition: all 0.2s ease;
height: 50px;
line-height: 50px;
color: #94a3b8;
}
.aside :deep(.el-menu-item:hover) {
background: rgba(255, 255, 255, 0.05);
color: #e2e8f0;
}
.aside :deep(.el-menu-item.is-active) {
background: linear-gradient(90deg, rgba(45, 217, 240, 0.15), transparent 70%) !important;
color: #2dd9f0 !important;
box-shadow: inset 0 0 0 1px rgba(45, 217, 240, 0.2);
}
.aside :deep(.el-menu-item .el-icon) {
color: #64748b;
transition: color 0.2s ease;
}
.aside :deep(.el-menu-item.is-active .el-icon) {
color: #2dd9f0;
}
.aside :deep(.el-menu-item:hover .el-icon) {
color: #e2e8f0;
}
.aside-foot {
padding: 20px 24px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.portal-link {
display: flex;
align-items: center;
gap: 10px;
color: #94a3b8;
font-size: 14px;
text-decoration: none;
padding: 12px 16px;
border-radius: 10px;
transition: all 0.2s ease;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.portal-link:hover {
color: #2dd9f0;
background: rgba(45, 217, 240, 0.1);
border-color: rgba(45, 217, 240, 0.2);
transform: translateX(4px);
}
.main-container {
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e2e8f0;
background: #ffffff;
padding: 0 28px;
height: 68px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.page-title {
font-size: 20px;
font-weight: 700;
color: #0f172a;
margin: 0;
letter-spacing: -0.02em;
}
.header-actions {
display: flex;
gap: 8px;
}
.header-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 18px;
border-radius: 10px;
border: 1px solid #e2e8f0;
background: #ffffff;
color: #475569;
font-size: 14px;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.header-btn:hover {
border-color: #cbd5e1;
background: #f8fafc;
color: #334155;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.logout-btn:hover {
border-color: rgba(239, 68, 68, 0.3);
color: #ef4444;
background: rgba(239, 68, 68, 0.05);
}
.main {
background: #f8fafc;
padding: 24px 28px;
flex: 1;
}
/* Dialog styles */
.modern-dialog :deep(.el-dialog) {
border-radius: 16px;
overflow: hidden;
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.3);
}
.modern-dialog :deep(.el-dialog__header) {
padding: 24px 28px 20px;
background: linear-gradient(135deg, #f8fafc 0%, #ffffff 100%);
border-bottom: 1px solid #e2e8f0;
}
.dialog-header {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 600;
color: #0f172a;
}
.dialog-icon {
color: #8b5cf6;
font-size: 22px;
}
.modern-dialog :deep(.el-dialog__body) {
padding: 24px 28px;
}
.pwd-form {
margin-top: 8px;
}
.modern-dialog :deep(.el-dialog__footer) {
padding: 16px 28px 24px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
.btn-secondary {
padding: 10px 24px;
border-radius: 10px;
border: 1px solid #e2e8f0;
background: #ffffff;
color: #475569;
font-weight: 500;
transition: all 0.2s ease;
}
.btn-secondary:hover {
border-color: #cbd5e1;
background: #f8fafc;
color: #334155;
}
.btn-primary {
padding: 10px 28px;
border-radius: 10px;
font-weight: 500;
background: linear-gradient(135deg, #2dd9f0 0%, #0ea5e9 100%);
border: none;
box-shadow: 0 4px 14px rgba(45, 217, 240, 0.35);
transition: all 0.2s ease;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(45, 217, 240, 0.45);
}
</style>
+107
View File
@@ -0,0 +1,107 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { adminApi } from '../../api'
const links = ref([])
const loading = ref(true)
const dialog = ref(false)
const form = ref(emptyForm())
function emptyForm() {
return { id: null, title: '', url: '', group_name: '其他链接', sort_order: 0, is_active: true }
}
async function load() {
loading.value = true
try {
const { data } = await adminApi.listLinks()
links.value = data
} finally { loading.value = false }
}
function openForm(l) {
form.value = l ? { ...l } : emptyForm()
if (!l) form.value.sort_order = links.value.length
dialog.value = true
}
async function save() {
if (!form.value.title.trim()) return ElMessage.warning('请输入链接标题')
if (!form.value.url.trim()) return ElMessage.warning('请输入链接地址')
const payload = { ...form.value }
if (payload.id) await adminApi.updateLink(payload.id, payload)
else await adminApi.createLink(payload)
ElMessage.success('已保存')
dialog.value = false
load()
}
async function del(l) {
await ElMessageBox.confirm(`删除链接「${l.title}」?`, '警告', { type: 'warning' })
await adminApi.deleteLink(l.id)
ElMessage.success('已删除')
load()
}
async function toggleActive(l) {
await adminApi.updateLink(l.id, { is_active: !l.is_active })
ElMessage.success(l.is_active ? '已隐藏' : '已显示')
load()
}
onMounted(load)
</script>
<template>
<div v-loading="loading">
<el-card>
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center">
<b>侧栏链接门户右侧其他链接面板</b>
<el-button type="primary" icon="Plus" @click="openForm(null)">新增链接</el-button>
</div>
</template>
<el-table :data="links">
<el-table-column prop="title" label="标题" min-width="240" />
<el-table-column label="地址" min-width="280" show-overflow-tooltip>
<template #default="{ row }">
<a :href="row.url" target="_blank" class="url-link">{{ row.url }}</a>
</template>
</el-table-column>
<el-table-column prop="group_name" label="分组" width="120" />
<el-table-column prop="sort_order" label="排序" width="80" align="center" />
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-switch :model-value="row.is_active" @change="toggleActive(row)" size="small" />
</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center">
<template #default="{ row }">
<el-button size="small" text icon="Edit" @click="openForm(row)" />
<el-button size="small" text type="danger" icon="Delete" @click="del(row)" />
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dialog" :title="form.id ? '编辑链接' : '新增链接'" width="480px">
<el-form label-width="80px">
<el-form-item label="标题"><el-input v-model="form.title" placeholder="如:Docker镜像仓库" /></el-form-item>
<el-form-item label="地址"><el-input v-model="form.url" placeholder="http://..." /></el-form-item>
<el-form-item label="分组"><el-input v-model="form.group_name" placeholder="其他链接" /></el-form-item>
<el-form-item label="排序"><el-input-number v-model="form.sort_order" :min="0" /></el-form-item>
<el-form-item label="显示"><el-switch v-model="form.is_active" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="dialog = false">取消</el-button>
<el-button type="primary" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.url-link { color: #3b5bdb; text-decoration: none; font-size: 13px; }
.url-link:hover { text-decoration: underline; }
</style>
+504
View File
@@ -0,0 +1,504 @@
<script setup>
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock } from '@element-plus/icons-vue'
import { authApi } from '../../api'
import { setToken } from '../../utils/auth'
const router = useRouter()
const route = useRoute()
const form = ref({ username: '', password: '' })
const loading = ref(false)
async function submit() {
if (!form.value.username || !form.value.password) {
ElMessage.warning('请输入用户名和密码')
return
}
loading.value = true
try {
const { data } = await authApi.login(form.value.username, form.value.password)
setToken(data.token)
ElMessage.success('登录成功')
router.push(route.query.redirect || '/admin/dashboard')
} catch {
/* interceptor already toasted */
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login-page">
<!-- Background -->
<div class="login-bg">
<div class="bg-gradient bg-gradient-1"></div>
<div class="bg-gradient bg-gradient-2"></div>
<div class="bg-gradient bg-gradient-3"></div>
<div class="bg-grid"></div>
<div class="bg-float">
<div class="float-shape shape-1"></div>
<div class="float-shape shape-2"></div>
<div class="float-shape shape-3"></div>
</div>
</div>
<!-- Login Card -->
<div class="login-card">
<div class="card-glow"></div>
<!-- Brand header -->
<div class="brand">
<div class="brand-logo">
<span class="logo-glyph"></span>
</div>
<div class="brand-text">
<h1>OPS 资源中心</h1>
<p>内部资源管理后台 · Admin Console</p>
</div>
</div>
<!-- Form section -->
<el-form @submit.prevent="submit" class="login-form">
<div class="form-title">
<span class="form-title-bar"></span>
<span>账号登录</span>
</div>
<div class="form-fields">
<el-input
v-model="form.username"
placeholder="请输入用户名"
size="large"
class="modern-input"
autocomplete="username"
@keyup.enter="submit"
>
<template #prefix>
<el-icon class="i"><User /></el-icon>
</template>
</el-input>
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码"
show-password
size="large"
class="modern-input"
autocomplete="current-password"
@keyup.enter="submit"
>
<template #prefix>
<el-icon class="i"><Lock /></el-icon>
</template>
</el-input>
</div>
<el-button type="primary" :loading="loading" class="login-btn" @click="submit">
<span v-if="!loading"> </span>
<span v-else>登录中</span>
</el-button>
<div class="form-foot">
<router-link to="/" class="back-link">
<el-icon><ArrowLeft /></el-icon>
返回门户首页
</router-link>
</div>
</el-form>
</div>
</div>
</template>
<style>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
padding: 20px;
overflow: hidden;
}
/* Background */
.login-bg {
position: absolute;
inset: 0;
background: linear-gradient(160deg, #0a1424 0%, #0e1c33 40%, #0f172a 100%);
}
.bg-gradient {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.4;
}
.bg-gradient-1 {
width: 600px;
height: 600px;
top: -200px;
right: -100px;
background: radial-gradient(circle, #2dd9f0, transparent 70%);
animation: float-1 20s ease-in-out infinite;
}
.bg-gradient-2 {
width: 500px;
height: 500px;
bottom: -150px;
left: -100px;
background: radial-gradient(circle, #8b5cf6, transparent 70%);
animation: float-2 25s ease-in-out infinite;
}
.bg-gradient-3 {
width: 400px;
height: 400px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: radial-gradient(circle, #f97316, transparent 70%);
animation: float-3 30s ease-in-out infinite;
opacity: 0.25;
}
@keyframes float-1 {
0%, 100% { transform: translate(0, 0); }
33% { transform: translate(-50px, 50px); }
66% { transform: translate(30px, -30px); }
}
@keyframes float-2 {
0%, 100% { transform: translate(0, 0); }
33% { transform: translate(50px, -30px); }
66% { transform: translate(-30px, 40px); }
}
@keyframes float-3 {
0%, 100% { transform: translate(-50%, -50%) scale(1); }
50% { transform: translate(-50%, -50%) scale(1.2); }
}
.bg-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(45, 217, 240, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(45, 217, 240, 0.04) 1px, transparent 1px);
background-size: 50px 50px;
mask-image: radial-gradient(ellipse at center, #000 40%, transparent 70%);
}
.bg-float {
position: absolute;
inset: 0;
overflow: hidden;
}
.float-shape {
position: absolute;
border: 2px solid rgba(255, 255, 255, 0.06);
border-radius: 20px;
background: rgba(255, 255, 255, 0.02);
backdrop-filter: blur(4px);
}
.shape-1 {
width: 100px;
height: 100px;
top: 15%;
left: 10%;
transform: rotate(45deg);
animation: shape-float 15s ease-in-out infinite;
}
.shape-2 {
width: 70px;
height: 70px;
top: 60%;
right: 15%;
transform: rotate(-20deg);
animation: shape-float 18s ease-in-out infinite reverse;
border-radius: 50%;
}
.shape-3 {
width: 120px;
height: 60px;
bottom: 20%;
left: 20%;
transform: rotate(15deg);
animation: shape-float 20s ease-in-out infinite;
}
@keyframes shape-float {
0%, 100% { transform: translateY(0) rotate(var(--rotation, 0deg)); }
50% { transform: translateY(-30px) rotate(var(--rotation, 0deg)); }
}
/* Login Card */
.login-card {
position: relative;
width: 100%;
max-width: 460px;
background: rgba(255, 255, 255, 0.97);
backdrop-filter: blur(20px);
border-radius: 24px;
padding: 44px 48px 36px;
box-shadow:
0 30px 60px -15px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
overflow: hidden;
}
.card-glow {
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle at 30% 20%, rgba(45, 217, 240, 0.08), transparent 50%);
pointer-events: none;
}
/* ---------- Brand header ---------- */
.brand {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 32px;
position: relative;
}
.brand-logo {
position: relative;
width: 56px;
height: 56px;
border-radius: 16px;
background: linear-gradient(135deg, #0a1424 0%, #1e293b 100%);
border: 2px solid rgba(45, 217, 240, 0.4);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.25),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.brand-logo::after {
content: '';
position: absolute;
inset: -6px;
border-radius: 20px;
background: radial-gradient(circle, rgba(45, 217, 240, 0.25) 0%, transparent 70%);
filter: blur(8px);
z-index: -1;
animation: logo-pulse 3s ease-in-out infinite;
}
.logo-glyph {
font-size: 26px;
color: #2dd9f0;
line-height: 1;
}
@keyframes logo-pulse {
0%, 100% { opacity: 0.5; transform: scale(1); }
50% { opacity: 0.9; transform: scale(1.08); }
}
.brand-text h1 {
font-size: 22px;
font-weight: 700;
color: #0f172a;
margin: 0 0 4px;
letter-spacing: -0.01em;
line-height: 1.2;
}
.brand-text p {
font-size: 12px;
color: #94a3b8;
margin: 0;
letter-spacing: 0.06em;
text-transform: uppercase;
}
/* ---------- Form section ---------- */
.login-form {
position: relative;
z-index: 1;
}
.form-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
font-weight: 600;
color: #475569;
letter-spacing: 0.04em;
margin-bottom: 14px;
}
.form-title-bar {
width: 3px;
height: 14px;
border-radius: 2px;
background: linear-gradient(180deg, #2dd9f0 0%, #8b5cf6 100%);
}
.form-fields {
display: flex;
flex-direction: column;
gap: 14px;
margin-bottom: 22px;
}
.modern-input :deep(.el-input__wrapper) {
padding: 4px 12px 4px 4px;
border-radius: 12px;
box-shadow: 0 0 0 1px #e2e8f0 inset;
background: #f8fafc;
transition: all 0.25s ease;
}
.modern-input :deep(.el-input__wrapper:hover) {
box-shadow: 0 0 0 1px #cbd5e1 inset;
background: #ffffff;
}
.modern-input :deep(.el-input__wrapper.is-focus) {
box-shadow:
0 0 0 2px rgba(45, 217, 240, 0.18),
0 0 0 1px #2dd9f0 inset;
background: #ffffff;
}
.modern-input :deep(.el-input__inner) {
font-size: 14.5px;
color: #1e293b;
height: 44px;
}
.modern-input :deep(.el-input__inner::placeholder) {
color: #94a3b8;
}
.modern-input :deep(.el-input__prefix) {
display: flex;
align-items: center;
padding: 0 4px 0 14px;
margin-right: 8px;
color: #94a3b8;
transition: color 0.2s ease;
}
.modern-input :deep(.el-input__wrapper.is-focus) .el-input__prefix {
color: #2dd9f0;
}
.modern-input :deep(.el-input__prefix .i) {
font-size: 17px;
}
.modern-input :deep(.el-input__suffix) {
padding-right: 6px;
color: #94a3b8;
}
.modern-input :deep(.el-input__suffix .el-input__icon) {
font-size: 16px;
}
.modern-input :deep(.el-input__clear) {
font-size: 15px;
}
/* ---------- Login Button ---------- */
.login-btn {
width: 100%;
height: 50px;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.2em;
color: #fff;
background: linear-gradient(135deg, #2dd9f0 0%, #6366f1 55%, #8b5cf6 100%);
background-size: 200% 200%;
border: none;
box-shadow:
0 8px 24px rgba(45, 217, 240, 0.28),
0 2px 8px rgba(99, 102, 241, 0.18);
transition: all 0.3s ease;
animation: gradient-shift 5s ease infinite;
}
@keyframes gradient-shift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.login-btn:hover {
transform: translateY(-2px);
box-shadow:
0 14px 34px rgba(45, 217, 240, 0.4),
0 4px 12px rgba(99, 102, 241, 0.28);
}
.login-btn:active {
transform: translateY(0);
}
/* ---------- Back Link ---------- */
.form-foot {
display: flex;
justify-content: center;
margin-top: 22px;
padding-top: 18px;
border-top: 1px dashed #e2e8f0;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13.5px;
color: #64748b;
text-decoration: none;
transition: all 0.2s ease;
}
.back-link:hover {
color: #6366f1;
transform: translateX(-3px);
}
/* ---------- Responsive ---------- */
@media (max-width: 480px) {
.login-card {
padding: 36px 28px 30px;
border-radius: 20px;
}
.brand-logo {
width: 48px;
height: 48px;
}
.logo-glyph {
font-size: 22px;
}
.brand-text h1 {
font-size: 19px;
}
.brand-text p {
font-size: 11px;
}
}
</style>
+635
View File
@@ -0,0 +1,635 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { adminApi } from '../../api'
const resources = ref([])
const categories = ref([])
const loading = ref(true)
const filter = ref({ category_id: '', type: '' })
const dialog = ref(false)
const form = ref(emptyForm())
const uploading = ref(false)
const uploadProgress = ref(0)
function emptyForm() {
return {
id: null, category_id: null, title: '', type: 'command',
content: '', url: '', description: '', sort_order: 0,
is_active: true, option_ids: []
}
}
async function handleFileUpload(ev) {
const file = ev.target.files[0]
if (!file) return
uploading.value = true
uploadProgress.value = 0
try {
const res = await adminApi.uploadFile(file, (e) => {
uploadProgress.value = Math.round((e.loaded * 100) / e.total)
})
form.value.url = res.data.url
if (!form.value.title.trim()) {
form.value.title = res.data.filename.replace(/\.[^/.]+$/, '')
}
ElMessage.success('上传成功')
} finally {
uploading.value = false
uploadProgress.value = 0
}
}
const typeMap = { command: '命令', link: '链接', text: '文档', file: '文件' }
const typeColors = {
command: { bg: '#e0f2fe', text: '#0369a1', border: '#7dd3fc' },
link: { bg: '#e0e7ff', text: '#4338ca', border: '#a5b4fc' },
text: { bg: '#dcfce7', text: '#166534', border: '#86efac' },
file: { bg: '#ffedd5', text: '#9a3412', border: '#fdba74' }
}
const formCategory = computed(() => categories.value.find(c => c.id === form.value.category_id))
const needUrl = computed(() => ['link', 'file'].includes(form.value.type))
async function load() {
loading.value = true
try {
const [res, cats] = await Promise.all([
adminApi.listResources({
category_id: filter.value.category_id || undefined,
type: filter.value.type || undefined
}),
adminApi.listCategories()
])
resources.value = res.data
categories.value = cats.data
} finally { loading.value = false }
}
function catName(id) {
return categories.value.find(c => c.id === id)?.name || '—'
}
function openForm(res) {
if (res) {
form.value = {
id: res.id, category_id: res.category_id, title: res.title, type: res.type,
content: res.content, url: res.url, description: res.description,
sort_order: res.sort_order, is_active: res.is_active, option_ids: [...res.option_ids]
}
} else {
form.value = emptyForm()
if (categories.value.length) form.value.category_id = categories.value[0].id
}
dialog.value = true
}
function onCatChange() {
form.value.option_ids = []
}
function toggleOpt(optId) {
const idx = form.value.option_ids.indexOf(optId)
if (idx >= 0) form.value.option_ids.splice(idx, 1)
else form.value.option_ids.push(optId)
}
async function save() {
if (!form.value.title.trim()) return ElMessage.warning('请输入资源标题')
if (!form.value.category_id) return ElMessage.warning('请选择分类')
if (form.value.type === 'command' && !form.value.content.trim()) return ElMessage.warning('请输入命令内容')
if (needUrl.value && !form.value.url.trim()) return ElMessage.warning('请输入链接地址')
const payload = { ...form.value }
if (payload.id) await adminApi.updateResource(payload.id, payload)
else await adminApi.createResource(payload)
ElMessage.success('已保存')
dialog.value = false
load()
}
async function del(res) {
await ElMessageBox.confirm(`删除资源「${res.title}」?`, '警告', { type: 'warning' })
await adminApi.deleteResource(res.id)
ElMessage.success('已删除')
load()
}
async function toggleActive(res) {
await adminApi.updateResource(res.id, { is_active: !res.is_active })
ElMessage.success(res.is_active ? '已下架' : '已上架')
load()
}
onMounted(load)
</script>
<template>
<div v-loading="loading" class="resources-page">
<!-- Header Section -->
<div class="page-header">
<div class="header-info">
<h1>资源管理</h1>
<p>管理所有类型的资源内容</p>
</div>
<div class="header-actions">
<el-select v-model="filter.category_id" placeholder="全部分类" clearable class="filter-select" @change="load">
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
<el-select v-model="filter.type" placeholder="全部类型" clearable class="filter-select" @change="load">
<el-option v-for="(v, k) in typeMap" :key="k" :label="v" :value="k" />
</el-select>
<el-button type="primary" class="btn-primary" @click="openForm(null)">
<el-icon><Plus /></el-icon>
发布资源
</el-button>
</div>
</div>
<!-- Resources Table Card -->
<div class="content-card">
<el-table :data="resources" class="modern-table" stripe>
<el-table-column label="类型" width="100" align="center">
<template #default="{ row }">
<span class="type-badge" :style="{
background: typeColors[row.type].bg,
color: typeColors[row.type].text,
borderColor: typeColors[row.type].border
}">
{{ typeMap[row.type] }}
</span>
</template>
</el-table-column>
<el-table-column prop="title" label="资源标题" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<div class="title-cell">
<span class="title-text">{{ row.title }}</span>
<span v-if="row.description" class="title-desc">{{ row.description }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="分类" width="120">
<template #default="{ row }">
<span class="cat-tag">{{ catName(row.category_id) }}</span>
</template>
</el-table-column>
<el-table-column label="内容预览" min-width="260" show-overflow-tooltip>
<template #default="{ row }">
<code v-if="row.type === 'command'" class="cmd-preview">{{ row.content }}</code>
<span v-else-if="row.type === 'link'" class="link-preview">{{ row.url }}</span>
<span v-else>{{ row.content }}</span>
</template>
</el-table-column>
<el-table-column label="筛选标签" width="110" align="center">
<template #default="{ row }">
<el-tag v-if="row.option_ids.length" size="small" class="opt-tag">
{{ row.option_ids.length }}
</el-tag>
<span v-else class="no-tag">通用</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-switch
:model-value="row.is_active"
@change="toggleActive(row)"
size="small"
active-color="#10b981"
inactive-color="#cbd5e1"
/>
</template>
</el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="160" />
<el-table-column label="操作" width="140" align="center" fixed="right">
<template #default="{ row }">
<el-button size="small" class="btn-icon" @click="openForm(row)">
<el-icon><Edit /></el-icon>
</el-button>
<el-button size="small" class="btn-icon btn-danger" @click="del(row)">
<el-icon><Delete /></el-icon>
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- Form Dialog -->
<el-dialog v-model="dialog" :title="form.id ? '编辑资源' : '发布资源'" width="680px" class="modern-dialog">
<template #header>
<div class="dialog-header">
<div class="dialog-icon" style="background: linear-gradient(135deg, #8b5cf6, #a78bfa)">
<el-icon><DocumentAdd /></el-icon>
</div>
<div>
<span>{{ form.id ? '编辑资源' : '发布资源' }}</span>
<small>配置资源详细信息</small>
</div>
</div>
</template>
<el-form label-width="100px" class="resource-form">
<el-form-item label="资源标题">
<el-input v-model="form.title" placeholder="输入资源标题,如:配置内部 pip 源" size="large" />
</el-form-item>
<el-form-item label="所属分类">
<el-select v-model="form.category_id" style="width: 100%" size="large" @change="onCatChange">
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="资源类型">
<el-radio-group v-model="form.type" class="type-radio-group">
<el-radio-button v-for="(v, k) in typeMap" :key="k" :value="k" class="type-radio">
{{ v }}
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item :label="form.type === 'command' ? '命令内容' : '正文内容'" v-if="form.type !== 'link' || true">
<el-input
v-model="form.content"
type="textarea"
:rows="form.type === 'command' ? 3 : 5"
:placeholder="form.type === 'command' ? '输入命令内容,如:mkdir -p ~/.pip && wget ...' : '输入说明文字(支持多行)'"
resize="none"
/>
</el-form-item>
<el-form-item :label="form.type === 'file' ? '文件地址' : '链接地址'" v-if="needUrl">
<div class="upload-wrap">
<el-input v-model="form.url" placeholder="http://..." style="flex: 1" />
<el-button type="primary" :loading="uploading" @click="$refs.fileInput.click()" class="upload-btn">
{{ uploading ? `上传中 ${uploadProgress}%` : '上传文件' }}
</el-button>
</div>
<input ref="fileInput" type="file" style="display:none" @change="handleFileUpload">
<div v-if="form.url && form.type === 'file'" class="file-preview">
<el-icon><Document /></el-icon>
{{ form.url }}
</div>
</el-form-item>
<el-form-item label="备注说明">
<el-input v-model="form.description" placeholder="显示在标题右侧的简短说明" />
</el-form-item>
<el-form-item label="筛选标签" v-if="formCategory?.rows?.length">
<div class="opt-groups">
<div v-for="row in formCategory.rows" :key="row.id" class="opt-group">
<span class="opt-group-title">{{ row.title }}</span>
<el-checkbox-group v-model="form.option_ids" class="opt-checkbox-group">
<el-checkbox v-for="o in row.options" :key="o.id" :label="o.id" class="opt-checkbox">
{{ o.icon ? o.icon + ' ' : '' }}{{ o.label }}
</el-checkbox>
</el-checkbox-group>
</div>
<div class="opt-tip">
<el-icon><InfoFilled /></el-icon>
不勾选 = 通用资源任何筛选条件下都展示勾选后仅在匹配时展示
</div>
</div>
</el-form-item>
<div class="form-row">
<el-form-item label="排序权重" style="flex: 1">
<el-input-number v-model="form.sort_order" :min="0" :step="1" controls-position="right" />
</el-form-item>
<el-form-item label="立即上架" style="flex: 1">
<el-switch v-model="form.is_active" active-color="#10b981" inactive-color="#cbd5e1" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="dialog = false" class="btn-secondary">取消</el-button>
<el-button type="primary" @click="save" class="btn-primary">保存资源</el-button>
</template>
</el-dialog>
</div>
</template>
<style>
.resources-page {
max-width: 100%;
}
/* Page Header */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
}
.page-header h1 {
font-size: 22px;
font-weight: 700;
color: #0f172a;
margin: 0 0 4px;
}
.page-header p {
font-size: 14px;
color: #64748b;
margin: 0;
}
.header-actions {
display: flex;
gap: 12px;
align-items: center;
}
.filter-select {
width: 160px;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
border-radius: 10px;
font-weight: 500;
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
border: none;
box-shadow: 0 4px 14px rgba(139, 92, 246, 0.35);
transition: all 0.2s ease;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(139, 92, 246, 0.45);
}
/* Content Card */
.content-card {
background: #ffffff;
border-radius: 16px;
border: 1px solid #e2e8f0;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
/* Modern Table */
.content-card :deep(.el-table__header-wrapper) {
background: linear-gradient(to bottom, #f8fafc, #ffffff);
}
.content-card :deep(.el-table__header th) {
font-weight: 600;
color: #475569;
background: transparent;
border-bottom: 2px solid #e2e8f0;
}
.content-card :deep(.el-table__body tr) {
transition: all 0.15s ease;
}
.content-card :deep(.el-table__body tr:hover) {
background: #f8fafc !important;
}
.content-card :deep(.el-table__body tr:hover td) {
background: transparent !important;
}
.type-badge {
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
border: 1px solid;
}
.title-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.title-text {
font-weight: 500;
color: #0f172a;
}
.title-desc {
font-size: 12px;
color: #94a3b8;
}
.cat-tag {
background: #f1f5f9;
color: #475569;
padding: 3px 10px;
border-radius: 6px;
font-size: 12px;
}
.cmd-preview {
font-family: 'JetBrains Mono', ui-monospace, Consolas, monospace;
font-size: 12px;
background: #0f172a;
color: #e2e8f0;
padding: 4px 10px;
border-radius: 6px;
}
.link-preview {
color: #6366f1;
font-size: 13px;
}
.opt-tag {
background: linear-gradient(135deg, #e0e7ff, #c7d2fe);
color: #4338ca;
border: none;
font-weight: 500;
}
.no-tag {
color: #94a3b8;
font-size: 13px;
}
.btn-icon {
padding: 6px 10px;
border-radius: 8px;
border: 1px solid #e2e8f0;
background: #ffffff;
color: #64748b;
transition: all 0.15s ease;
}
.btn-icon:hover {
border-color: #cbd5e1;
background: #f8fafc;
color: #475569;
}
.btn-danger:hover {
border-color: rgba(239, 68, 68, 0.3);
background: rgba(239, 68, 68, 0.05);
color: #ef4444;
}
/* Dialog Styles */
.modern-dialog :deep(.el-dialog) {
border-radius: 16px;
overflow: hidden;
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.3);
}
.modern-dialog :deep(.el-dialog__header) {
padding: 24px 28px 20px;
background: linear-gradient(135deg, #f8fafc 0%, #ffffff 100%);
border-bottom: 1px solid #e2e8f0;
margin: 0;
}
.dialog-header {
display: flex;
align-items: center;
gap: 14px;
}
.dialog-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.dialog-header > div {
display: flex;
flex-direction: column;
}
.dialog-header span {
font-size: 18px;
font-weight: 600;
color: #0f172a;
}
.dialog-header small {
font-size: 12px;
color: #64748b;
margin-top: 2px;
}
.modern-dialog :deep(.el-dialog__body) {
padding: 28px;
}
.resource-form :deep(.el-form-item__label) {
font-weight: 500;
color: #334155;
}
.type-radio-group :deep(.el-radio-button__inner) {
padding: 10px 20px;
font-weight: 500;
}
.type-radio-group :deep(.el-radio-button.is-active .el-radio-button__inner) {
background: linear-gradient(135deg, #8b5cf6, #6366f1);
border-color: #6366f1;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
}
.upload-wrap {
display: flex;
gap: 10px;
}
.upload-btn {
background: linear-gradient(135deg, #8b5cf6, #6366f1);
border: none;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.25);
}
.file-preview {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
padding: 10px 14px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
font-size: 13px;
color: #475569;
}
.opt-groups {
width: 100%;
}
.opt-group {
margin-bottom: 12px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.opt-group-title {
font-size: 14px;
color: #475569;
font-weight: 500;
margin-right: 8px;
}
.opt-checkbox {
margin-right: 16px;
}
.opt-tip {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #64748b;
margin-top: 8px;
padding: 10px 14px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.form-row {
display: flex;
gap: 20px;
}
.modern-dialog :deep(.el-dialog__footer) {
padding: 16px 28px 24px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
.btn-secondary {
padding: 10px 24px;
border-radius: 10px;
border: 1px solid #e2e8f0;
background: #ffffff;
color: #475569;
font-weight: 500;
transition: all 0.2s ease;
}
.btn-secondary:hover {
border-color: #cbd5e1;
background: #f8fafc;
color: #334155;
}
</style>
+59
View File
@@ -0,0 +1,59 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { adminApi } from '../../api'
const form = ref({})
const loading = ref(true)
const saving = ref(false)
const fields = [
{ key: 'site_name', label: '站点名称', placeholder: 'OPS 资源中心', type: 'input' },
{ key: 'hero_tag', label: 'Hero 标签', placeholder: 'INTERNAL RESOURCES', type: 'input' },
{ key: 'hero_title', label: 'Hero 主标题', placeholder: '内部资源 快速上手', type: 'input', tip: '「快速上手」或「GET STARTED」会高亮显示' },
{ key: 'hero_subtitle', label: 'Hero 副标题', placeholder: '执行一条命令完成初始化…', type: 'textarea' },
{ key: 'hero_command', label: 'Hero 命令', placeholder: 'curl -sSL http://ops.internal/init.sh | bash', type: 'input' },
{ key: 'hero_command_label', label: '命令栏标签', placeholder: 'RUN THE COMMAND TO INSTALL', type: 'input' },
{ key: 'announcement', label: '公告(滚动条)', placeholder: '留空则不显示公告条', type: 'textarea' },
{ key: 'footer_text', label: '页脚文字', placeholder: 'OPS Resource Portal · 内部使用', type: 'input' }
]
onMounted(async () => {
try {
const { data } = await adminApi.getSettings()
form.value = data
} finally { loading.value = false }
})
async function save() {
saving.value = true
try {
await adminApi.updateSettings(form.value)
ElMessage.success('已保存,刷新门户页面查看效果')
} finally { saving.value = false }
}
</script>
<template>
<el-card v-loading="loading" style="max-width:760px">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center">
<b>站点设置门户首页文案</b>
<el-button type="primary" :loading="saving" icon="Check" @click="save">保存设置</el-button>
</div>
</template>
<el-form label-width="130px" label-position="right">
<el-form-item v-for="f in fields" :key="f.key" :label="f.label">
<el-input v-if="f.type === 'textarea'" v-model="form[f.key]" type="textarea" :rows="2"
:placeholder="f.placeholder" />
<el-input v-else v-model="form[f.key]" :placeholder="f.placeholder" />
<div v-if="f.tip" class="field-tip">{{ f.tip }}</div>
</el-form-item>
</el-form>
</el-card>
</template>
<style scoped>
.field-tip { font-size: 12px; color: #b3bfcc; margin-top: 4px; line-height: 1.5; }
</style>
+119
View File
@@ -0,0 +1,119 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { adminApi } from '../../api'
const users = ref([])
const loading = ref(true)
const dialog = ref(false)
const form = ref({ username: '', password: '' })
const resetPwdDialog = ref(false)
const resetPwdForm = ref({ id: null, password: '' })
async function load() {
loading.value = true
try {
const res = await adminApi.listUsers()
users.value = res.data
} finally { loading.value = false }
}
function openCreate() {
form.value = { username: '', password: '' }
dialog.value = true
}
async function createUser() {
if (!form.value.username.trim()) return ElMessage.warning('请输入用户名')
if (!form.value.password || form.value.password.length < 6) return ElMessage.warning('密码至少6位')
await adminApi.createUser(form.value)
ElMessage.success('用户创建成功')
dialog.value = false
load()
}
function openResetPwd(user) {
resetPwdForm.value = { id: user.id, password: '' }
resetPwdDialog.value = true
}
async function resetPassword() {
if (!resetPwdForm.value.password || resetPwdForm.value.password.length < 6) {
return ElMessage.warning('密码至少6位')
}
await adminApi.resetUserPassword(resetPwdForm.value.id, resetPwdForm.value.password)
ElMessage.success('密码已重置')
resetPwdDialog.value = false
}
async function deleteUser(user) {
if (user.username === 'admin') {
return ElMessage.warning('不能删除内置 admin 用户')
}
await ElMessageBox.confirm(`删除用户「${user.username}」?`, '警告', { type: 'warning' })
await adminApi.deleteUser(user.id)
ElMessage.success('已删除')
load()
}
onMounted(load)
</script>
<template>
<div v-loading="loading">
<el-card>
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center">
<b>用户管理</b>
<el-button type="primary" icon="Plus" @click="openCreate">添加用户</el-button>
</div>
</template>
<el-table :data="users">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户名" />
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button size="small" text icon="Key" @click="openResetPwd(row)">
重置密码
</el-button>
<el-button size="small" text type="danger" icon="Delete" @click="deleteUser(row)"
:disabled="row.username === 'admin'">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- Create User Dialog -->
<el-dialog v-model="dialog" title="添加用户" width="420px">
<el-form label-width="80px">
<el-form-item label="用户名">
<el-input v-model="form.username" placeholder="至少3个字符" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" placeholder="至少6个字符" show-password />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialog = false">取消</el-button>
<el-button type="primary" @click="createUser">创建</el-button>
</template>
</el-dialog>
<!-- Reset Password Dialog -->
<el-dialog v-model="resetPwdDialog" title="重置密码" width="420px">
<el-form label-width="80px">
<el-form-item label="新密码">
<el-input v-model="resetPwdForm.password" type="password" placeholder="至少6个字符" show-password />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="resetPwdDialog = false">取消</el-button>
<el-button type="primary" @click="resetPassword">确认重置</el-button>
</template>
</el-dialog>
</div>
</template>
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0',
port: 3001,
allowedHosts: true,
proxy: {
'/api': { target: 'http://127.0.0.1:8090', changeOrigin: true }
}
},
build: {
outDir: 'dist',
chunkSizeWarningLimit: 1500
}
})