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:
@@ -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>
|
||||
Generated
+1870
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
@@ -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
|
||||
@@ -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')
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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> 关闭 <kbd>↑</kbd><kbd>↓</kbd> 导航
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- content -->
|
||||
<div class="loading-wrap" v-if="loading">正在加载资源…</div>
|
||||
|
||||
<main class="content-wrap" v-else>
|
||||
<!-- left menu -->
|
||||
<nav class="side-menu">
|
||||
<div class="side-menu-title">CATEGORIES</div>
|
||||
<div v-for="cat in categories" :key="cat.id"
|
||||
class="menu-item" :class="{ active: cat.id === activeCatId }"
|
||||
@click="selectCategory(cat.id)">
|
||||
<span class="mi-icon">{{ cat.icon }}</span>
|
||||
<span>{{ cat.name }}</span>
|
||||
<span class="mi-count">{{ cat.resource_count }}</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- center -->
|
||||
<section class="center-panel">
|
||||
<div class="panel-head" v-if="activeCat">
|
||||
<h2>{{ activeCat.icon }} {{ activeCat.name }}</h2>
|
||||
<p>{{ activeCat.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- option tiles -->
|
||||
<div v-for="row in (activeCat?.rows || [])" :key="row.id" class="option-row">
|
||||
<div class="option-row-title">{{ row.title }}</div>
|
||||
<div class="tiles">
|
||||
<div v-for="opt in row.options" :key="opt.id"
|
||||
class="tile" :class="{ selected: selections[row.id] === opt.id }"
|
||||
@click="toggleOption(row.id, opt.id)">
|
||||
<span class="tile-icon" v-if="opt.icon">{{ opt.icon }}</span>
|
||||
{{ opt.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- resources -->
|
||||
<div class="res-list" v-if="resources.length">
|
||||
<article v-for="(res, idx) in resources" :key="res.id" class="res-card"
|
||||
:style="{ animationDelay: (idx * 60) + 'ms' }">
|
||||
<div class="res-head">
|
||||
<span class="res-badge" :class="res.type">{{ typeLabel[res.type] || res.type }}</span>
|
||||
<span class="res-title">{{ res.title }}</span>
|
||||
<span class="res-desc" v-if="res.description">{{ res.description }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="res.type === 'command'" class="cmd-block">
|
||||
<code>{{ res.content }}</code>
|
||||
<button class="cmd-copy" :class="{ copied: copiedKey === 'res' + res.id }"
|
||||
@click="copyText(res.content, 'res' + res.id)">
|
||||
{{ copiedKey === 'res' + res.id ? '✓ 已复制' : '复制' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-else-if="res.type === 'link'">
|
||||
<div class="res-body" v-if="res.content">{{ res.content }}</div>
|
||||
<a class="res-jump" :href="res.url" target="_blank" rel="noopener">
|
||||
{{ res.url }} 跳转 →
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<template v-else-if="res.type === 'file'">
|
||||
<div class="res-body" v-if="res.content">{{ res.content }}</div>
|
||||
<a class="res-jump" :href="res.url" target="_blank" rel="noopener">下载文件 ↓</a>
|
||||
</template>
|
||||
|
||||
<div v-else class="res-body">{{ res.content }}</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="empty-hint" v-else-if="!resLoading">
|
||||
<span class="eh-icon">📭</span>
|
||||
该分类下暂无资源,请在管理后台添加
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- right links -->
|
||||
<aside class="links-panel" v-if="links.length">
|
||||
<h3>其他链接</h3>
|
||||
<a v-for="l in links" :key="l.id" class="link-item" :href="l.url" target="_blank" rel="noopener">
|
||||
{{ l.title }}
|
||||
</a>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<footer class="portal-footer">
|
||||
{{ settings.footer_text || 'OPS Resource Portal' }}
|
||||
<router-link to="/admin">管理后台</router-link>
|
||||
</footer>
|
||||
|
||||
<router-link to="/admin" class="admin-fab" title="管理后台">⚙</router-link>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user