This commit is contained in:
dlandy
2026-03-27 10:18:26 +08:00
commit 40be11adbf
116 changed files with 26138 additions and 0 deletions

18
client/index.html Normal file
View File

@@ -0,0 +1,18 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ToolsShow - Vue3 客户端</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Sora:wght@500;600;700;800&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

2289
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
client/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "toolsshow-client",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.13.1",
"element-plus": "^2.11.7",
"pinia": "^2.3.1",
"vue": "^3.5.22",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"sass": "^1.98.0",
"vite": "^7.1.5"
}
}

670
client/src/App.vue Normal file
View File

@@ -0,0 +1,670 @@
<template>
<div>
<header class="header-wrap" :class="{ 'is-scrolled': isScrolled }">
<div class="container header">
<a class="brand" href="#" aria-label="ToolsShow 首页" @click.prevent>
<span class="brand-mark">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M4 6.5C4 5.67 4.67 5 5.5 5H18.5C19.33 5 20 5.67 20 6.5V17.5C20 18.33 19.33 19 18.5 19H5.5C4.67 19 4 18.33 4 17.5V6.5Z" stroke="currentColor" stroke-width="1.8" />
<path d="M8 9H16M8 12H16M8 15H13" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
</span>
<span>ToolsShow</span>
</a>
<nav class="nav" aria-label="主导航">
<a href="#tools">工具列表</a>
<a href="#tools">分类浏览</a>
<a href="#tools">工具中心</a>
<a href="/admin">管理端</a>
<button
type="button"
class="nav-btn"
:class="{ active: overviewModalOpen }"
:aria-expanded="overviewModalOpen ? 'true' : 'false'"
@click="openOverviewModal"
>
站点概览
</button>
</nav>
</div>
</header>
<main class="container main-content">
<section class="hero">
<div class="hero-main">
<div class="search-row">
<label class="search-box" for="searchInput">
<span class="sr-only">搜索工具</span>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="1.8" />
<path d="M16.5 16.5L21 21" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
<input
id="searchInput"
v-model="filters.query"
type="search"
placeholder="搜索名称、描述..."
autocomplete="off"
@input="onQueryInput"
/>
</label>
<label class="sr-only" for="categorySelect">按分类筛选</label>
<select id="categorySelect" v-model="filters.category" class="select" :disabled="loadingMeta" @change="onCategoryChange">
<option v-for="item in categoriesWithAll" :key="item.id" :value="item.id">
{{ item.name }}
</option>
</select>
<button type="button" class="btn btn-primary" @click="resetFilters">重置筛选</button>
</div>
<div class="hot-keywords">
<span>热门搜索</span>
<button
v-for="item in hotKeywords"
:key="item.id"
type="button"
class="chip"
:class="{ active: item.keyword === filters.query.trim() }"
@click="applyHotKeyword(item.keyword)"
>
{{ item.keyword }}
</button>
<span v-if="!hotKeywords.length && !loadingMeta" class="hot-empty">暂无热门关键词</span>
</div>
</div>
</section>
<section id="tools">
<div class="tools-layout">
<aside class="category-sidebar" aria-label="分类导航">
<h2 class="sidebar-title">分类导航</h2>
<p class="sidebar-tip">点击分类可快速筛选工具</p>
<div class="category-sidebar-list">
<button
v-for="item in categoriesWithAll"
:key="`side-${item.id}`"
type="button"
class="category-side-btn"
:class="{ active: filters.category === item.id }"
:aria-pressed="filters.category === item.id ? 'true' : 'false'"
@click="selectCategory(item.id)"
>
<span class="label">{{ item.name }}</span>
<span class="count">{{ formatNumber(resolveCategoryCount(item)) }}</span>
</button>
</div>
</aside>
<div class="tools-main">
<div class="toolbar">
<p class="result-tip">{{ resultTip }}</p>
<label class="sr-only" for="sortSelect">排序方式</label>
<select id="sortSelect" v-model="filters.sortBy" class="select" @change="onSortChange">
<option value="latest">按更新时间排序</option>
<option value="popular">按下载量排序</option>
<option value="rating">按评分排序</option>
<option value="name">按名称排序</option>
</select>
</div>
<div v-if="loadingTools" class="tool-grid">
<article v-for="item in filters.pageSize" :key="`skeleton-${item}`" class="card card-skeleton">
<div class="skeleton-line skeleton-chip"></div>
<div class="skeleton-line skeleton-title"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line skeleton-btn"></div>
</article>
</div>
<div v-else-if="tools.length === 0" class="tool-grid">
<div class="empty">
<p>没有匹配结果请尝试更换关键词或分类</p>
<button type="button" class="btn" @click="resetFilters">清空筛选条件</button>
</div>
</div>
<div v-else class="tool-grid">
<article
v-for="(tool, index) in tools"
:key="tool.id"
class="card"
:style="{ '--stagger': `${index * 40}ms` }"
>
<div class="card-top">
<span class="category">{{ tool.category?.name || '未分类' }}</span>
</div>
<h3>{{ tool.name }}</h3>
<p class="desc">{{ tool.description }}</p>
<div class="tags">
<span v-for="tag in tool.tags" :key="`${tool.id}-${tag}`" class="tag">{{ tag }}</span>
</div>
<ul class="meta-list">
<li>版本<strong>{{ tool.latestVersion || '暂无版本' }}</strong></li>
<li>更新时间<strong>{{ formatDate(tool.updatedAt) }}</strong></li>
</ul>
<div class="card-foot">
<span class="download-num">
{{ toolModeSummary(tool) }}
</span>
<div class="actions">
<button type="button" class="btn-small" @click="openDetailModal(tool.id)">详情</button>
<button
type="button"
class="btn-small"
:class="tool.accessMode === 'web' ? 'btn-open' : 'btn-download'"
:disabled="isLaunchDisabled(tool)"
@click="triggerLaunch(tool)"
>
{{ launchButtonText(tool) }}
</button>
</div>
</div>
</article>
</div>
<div v-if="pagination.totalPages > 1 && !loadingTools" class="pagination">
<button
type="button"
class="btn"
:disabled="pagination.page <= 1"
@click="changePage(pagination.page - 1)"
>
上一页
</button>
<span> {{ pagination.page }} / {{ pagination.totalPages }} </span>
<button
type="button"
class="btn"
:disabled="pagination.page >= pagination.totalPages"
@click="changePage(pagination.page + 1)"
>
下一页
</button>
</div>
</div>
</div>
</section>
</main>
<div class="modal-backdrop" :class="{ open: detailModalOpen }" role="dialog" aria-modal="true" aria-labelledby="detailTitle" @click.self="closeDetailModal">
<div class="modal">
<div class="modal-head">
<h2 id="detailTitle">工具详情</h2>
<button type="button" class="icon-btn" aria-label="关闭详情" @click="closeDetailModal">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
</button>
</div>
<template v-if="detailLoading">
<p class="modal-muted">正在加载工具详情...</p>
</template>
<template v-else-if="detailError">
<p class="modal-error">{{ detailError }}</p>
</template>
<template v-else-if="detail">
<p>{{ detail.description }}</p>
<ul class="meta-list">
<li>分类<strong>{{ detail.category?.name || '-' }}</strong></li>
<li>评分<strong>{{ Number(detail.rating || 0).toFixed(1) }}</strong></li>
<li>访问方式<strong>{{ detail.accessMode === 'web' ? '网页打开' : '下载安装' }}</strong></li>
<li v-if="detail.accessMode === 'download'">
下载次数<strong>{{ formatNumber(detail.downloadCount) }}</strong>
</li>
<li v-else>
访问次数<strong>{{ formatNumber(detail.openCount) }}</strong>
</li>
<li v-if="detail.accessMode === 'download'">
最新版本<strong>{{ detail.latestVersion || '暂无版本' }}</strong>
</li>
<li v-if="detail.accessMode === 'download'">
文件大小<strong>{{ formatFileSize(detail.fileSize) }}</strong>
</li>
<li v-if="detail.accessMode === 'web' && detail.openUrl">
打开地址
<a class="inline-link" :href="detail.openUrl" target="_blank" rel="noopener noreferrer">{{ detail.openUrl }}</a>
</li>
<li>更新时间<strong>{{ formatDate(detail.updatedAt) }}</strong></li>
</ul>
<h3>核心能力</h3>
<ul v-if="detail.features?.length" class="feature-list">
<li v-for="feature in detail.features" :key="`detail-${feature}`">{{ feature }}</li>
</ul>
<p v-else class="modal-muted">暂无能力描述</p>
</template>
</div>
</div>
<div class="modal-backdrop" :class="{ open: overviewModalOpen }" role="dialog" aria-modal="true" aria-labelledby="overviewTitle" @click.self="closeOverviewModal">
<div class="modal">
<div class="modal-head">
<h2 id="overviewTitle">站点概览</h2>
<button type="button" class="icon-btn" aria-label="关闭站点概览" @click="closeOverviewModal">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
</button>
</div>
<p>展示站当前统计信息与核心能力说明</p>
<div class="kpi-grid">
<div><strong>{{ formatNumber(overview.toolTotal) }}</strong><span>工具总数</span></div>
<div><strong>{{ formatNumber(overview.categoryTotal) }}</strong><span>分类数量</span></div>
<div><strong>{{ formatNumber(overview.downloadTotal) }}</strong><span>累计下载</span></div>
<div><strong>{{ formatNumber(overview.openTotal) }}</strong><span>累计访问</span></div>
<div><strong>{{ formatNumber(pagination.total) }}</strong><span>当前结果</span></div>
</div>
<ul class="tips">
<li>浏览分页展示工具卡片</li>
<li>搜索关键词 + 分类 + 排序组合筛选</li>
<li>获取统一通过 launch 接口完成网页打开或下载</li>
</ul>
</div>
</div>
<div class="toast" :class="{ show: toast.visible }" role="status" aria-live="polite">
{{ toast.message }}
</div>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import {
fetchCategories,
fetchHotKeywords,
fetchOverview,
fetchToolDetail,
fetchTools,
getApiErrorMessage,
launchTool,
resolveActionUrl,
} from './api';
const CLIENT_VERSION = 'web-1.0.0';
const QUERY_DEBOUNCE_MS = 320;
const filters = reactive({
query: '',
category: 'all',
sortBy: 'latest',
page: 1,
pageSize: 6,
});
const categories = ref([]);
const hotKeywords = ref([]);
const tools = ref([]);
const pagination = reactive({
page: 1,
pageSize: 6,
total: 0,
totalPages: 1,
});
const overview = reactive({
toolTotal: 0,
categoryTotal: 0,
downloadTotal: 0,
openTotal: 0,
});
const loadingTools = ref(false);
const loadingMeta = ref(false);
const launchingId = ref('');
const detailModalOpen = ref(false);
const detailLoading = ref(false);
const detailError = ref('');
const detail = ref(null);
const overviewModalOpen = ref(false);
const isScrolled = ref(false);
const toast = reactive({
visible: false,
message: '',
});
let queryTimer = null;
let toastTimer = null;
let toolsRequestToken = 0;
const categoriesWithAll = computed(() => [
{
id: 'all',
name: '全部分类',
toolCount: pagination.total,
},
...categories.value,
]);
const resultTip = computed(() => {
if (loadingTools.value) {
return '正在加载工具数据...';
}
if (pagination.total === 0) {
return '没有匹配结果,请尝试更换关键词或分类。';
}
const start = (pagination.page - 1) * pagination.pageSize + 1;
const end = Math.min(start + tools.value.length - 1, pagination.total);
return `共找到 ${formatNumber(pagination.total)} 个工具,当前显示 ${start}-${end}`;
});
function formatNumber(value) {
const numeric = Number(value);
return new Intl.NumberFormat('zh-CN').format(Number.isFinite(numeric) ? numeric : 0);
}
function formatDate(dateText) {
if (!dateText) {
return '-';
}
const date = new Date(dateText);
if (Number.isNaN(date.getTime())) {
return '-';
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date);
}
function formatFileSize(bytes) {
const size = Number(bytes);
if (!Number.isFinite(size) || size <= 0) {
return '-';
}
const units = ['B', 'KB', 'MB', 'GB'];
let value = size;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[unitIndex]}`;
}
function resolveCategoryCount(item) {
if (item.id === 'all') {
return pagination.total;
}
return item.toolCount ?? 0;
}
function showToast(message) {
toast.message = message;
toast.visible = true;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toast.visible = false;
}, 2200);
}
async function loadTools() {
const currentToken = ++toolsRequestToken;
loadingTools.value = true;
try {
const payload = await fetchTools({
query: filters.query.trim() || undefined,
category: filters.category,
sortBy: filters.sortBy,
page: filters.page,
pageSize: filters.pageSize,
});
if (currentToken !== toolsRequestToken) {
return;
}
tools.value = Array.isArray(payload?.list) ? payload.list : [];
pagination.page = Number(payload?.pagination?.page ?? filters.page);
pagination.pageSize = Number(payload?.pagination?.pageSize ?? filters.pageSize);
pagination.total = Number(payload?.pagination?.total ?? 0);
pagination.totalPages = Math.max(1, Number(payload?.pagination?.totalPages ?? 1));
} catch (error) {
if (currentToken !== toolsRequestToken) {
return;
}
tools.value = [];
pagination.total = 0;
pagination.totalPages = 1;
showToast(getApiErrorMessage(error));
} finally {
if (currentToken === toolsRequestToken) {
loadingTools.value = false;
}
}
}
async function loadMeta() {
loadingMeta.value = true;
try {
const [categoryData, keywordData, overviewData] = await Promise.all([
fetchCategories(),
fetchHotKeywords(),
fetchOverview(),
]);
categories.value = Array.isArray(categoryData) ? categoryData : [];
hotKeywords.value = Array.isArray(keywordData) ? keywordData : [];
overview.toolTotal = Number(overviewData?.toolTotal ?? 0);
overview.categoryTotal = Number(overviewData?.categoryTotal ?? 0);
overview.downloadTotal = Number(overviewData?.downloadTotal ?? 0);
overview.openTotal = Number(overviewData?.openTotal ?? 0);
} catch (error) {
showToast(`初始化数据失败:${getApiErrorMessage(error)}`);
} finally {
loadingMeta.value = false;
}
}
async function refreshOverview() {
try {
const overviewData = await fetchOverview();
overview.toolTotal = Number(overviewData?.toolTotal ?? 0);
overview.categoryTotal = Number(overviewData?.categoryTotal ?? 0);
overview.downloadTotal = Number(overviewData?.downloadTotal ?? 0);
overview.openTotal = Number(overviewData?.openTotal ?? 0);
} catch {
// Keep existing overview values to avoid interrupting main flow.
}
}
function onQueryInput() {
filters.page = 1;
clearTimeout(queryTimer);
queryTimer = setTimeout(() => {
loadTools();
}, QUERY_DEBOUNCE_MS);
}
function onCategoryChange() {
filters.page = 1;
loadTools();
}
function selectCategory(categoryId) {
if (filters.category === categoryId) {
return;
}
filters.category = categoryId;
filters.page = 1;
loadTools();
}
function onSortChange() {
filters.page = 1;
loadTools();
}
function applyHotKeyword(keyword) {
filters.query = keyword;
filters.page = 1;
clearTimeout(queryTimer);
loadTools();
}
function resetFilters() {
filters.query = '';
filters.category = 'all';
filters.sortBy = 'latest';
filters.page = 1;
clearTimeout(queryTimer);
loadTools();
}
function changePage(nextPage) {
if (nextPage < 1 || nextPage > pagination.totalPages || nextPage === pagination.page) {
return;
}
filters.page = nextPage;
loadTools();
}
async function openDetailModal(toolId) {
detailModalOpen.value = true;
detailLoading.value = true;
detailError.value = '';
detail.value = null;
try {
const data = await fetchToolDetail(toolId);
detail.value = data;
} catch (error) {
detailError.value = getApiErrorMessage(error);
} finally {
detailLoading.value = false;
}
}
function closeDetailModal() {
detailModalOpen.value = false;
}
function openOverviewModal() {
overviewModalOpen.value = true;
refreshOverview();
}
function closeOverviewModal() {
overviewModalOpen.value = false;
}
function isLaunchDisabled(tool) {
if (launchingId.value === tool.id) {
return true;
}
return tool.accessMode === 'download' && !tool.hasArtifact;
}
function launchButtonText(tool) {
if (launchingId.value === tool.id) {
return '处理中...';
}
if (tool.accessMode === 'web') {
return '打开网页';
}
if (!tool.hasArtifact) {
return '暂无可下载包';
}
return '下载';
}
function toolModeSummary(tool) {
if (tool.accessMode === 'download') {
return `下载 ${formatNumber(tool.downloadCount)}`;
}
return `访问 ${formatNumber(tool.openCount)}`;
}
async function triggerLaunch(tool) {
if (isLaunchDisabled(tool)) {
return;
}
launchingId.value = tool.id;
try {
const result = await launchTool(tool.id, {
channel: 'official',
clientVersion: CLIENT_VERSION,
});
const actionUrl = resolveActionUrl(result?.actionUrl);
if (result?.mode === 'web') {
if (result.openIn === 'same_tab') {
window.location.assign(actionUrl);
return;
}
const page = window.open(actionUrl, '_blank', 'noopener,noreferrer');
if (!page) {
showToast('浏览器阻止了新窗口,请允许弹窗后重试');
return;
}
showToast(`${tool.name} 已在新标签页打开`);
} else if (result?.mode === 'download') {
const page = window.open(actionUrl, '_blank', 'noopener,noreferrer');
if (!page) {
window.location.assign(actionUrl);
return;
}
showToast(`${tool.name} 下载任务已创建`);
}
await Promise.all([loadTools(), refreshOverview()]);
} catch (error) {
showToast(getApiErrorMessage(error));
} finally {
launchingId.value = '';
}
}
function updateHeaderScrollState() {
isScrolled.value = window.scrollY > 8;
}
function handleKeydown(event) {
if (event.key !== 'Escape') {
return;
}
if (detailModalOpen.value) {
closeDetailModal();
}
if (overviewModalOpen.value) {
closeOverviewModal();
}
}
onMounted(async () => {
window.addEventListener('scroll', updateHeaderScrollState, { passive: true });
document.addEventListener('keydown', handleKeydown);
updateHeaderScrollState();
await Promise.all([loadMeta(), loadTools()]);
});
onBeforeUnmount(() => {
clearTimeout(queryTimer);
clearTimeout(toastTimer);
window.removeEventListener('scroll', updateHeaderScrollState);
document.removeEventListener('keydown', handleKeydown);
});
</script>

3
client/src/RootApp.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

File diff suppressed because it is too large Load Diff

635
client/src/admin/admin.scss Normal file
View File

@@ -0,0 +1,635 @@
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@500;600;700&family=Fira+Sans:wght@400;500;600;700&display=swap');
.admin-ref {
min-height: 100vh;
background: #e7eaee;
font-family: "Fira Sans", "PingFang SC", "Microsoft YaHei", sans-serif;
color: #1d2430;
}
.admin-center-wrap {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.admin-login-card {
width: min(460px, calc(100% - 16px));
border-radius: 0;
border: 1px solid rgba(160, 172, 192, 0.26);
box-shadow: none;
}
.admin-login-title {
display: flex;
align-items: center;
gap: 12px;
}
.admin-login-logo {
width: 42px;
height: 42px;
border-radius: 0;
display: grid;
place-items: center;
background: linear-gradient(135deg, #2f83ed, #379cff);
color: #fff;
font-family: "Fira Code", monospace;
font-weight: 700;
}
.admin-login-title h2 {
margin: 0;
font-size: 22px;
font-family: "Fira Code", monospace;
}
.admin-login-title span {
display: block;
margin-top: 2px;
font-size: 13px;
color: #697386;
}
.admin-login-alert {
margin-bottom: 16px;
}
.admin-login-btn {
width: 100%;
margin-top: 8px;
}
.dashboard-shell {
width: 100%;
margin: 0;
min-height: 100vh;
border-radius: 0;
border: 1px solid rgba(142, 154, 172, 0.3);
background: #eceef1;
box-shadow: none;
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
overflow: hidden;
}
.dashboard-sidebar {
border-right: 1px solid rgba(142, 154, 172, 0.24);
padding: 16px 14px;
display: grid;
grid-template-rows: auto 1fr auto;
gap: 14px;
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 8px 12px;
}
.brand-mark {
width: 30px;
height: 30px;
border-radius: 0;
display: grid;
place-items: center;
background: #2f83ed;
color: #fff;
font-size: 16px;
}
.brand-text {
font-family: "Fira Code", monospace;
font-size: 24px;
color: #566579;
letter-spacing: 0.02em;
}
.sidebar-menu {
display: grid;
align-content: start;
gap: 8px;
}
.menu-item {
width: 100%;
min-height: 42px;
border: 1px solid transparent;
border-radius: 14px;
background: rgba(255, 255, 255, 0.45);
display: flex;
align-items: center;
gap: 10px;
padding: 0 12px;
color: #4a586b;
cursor: pointer;
transition: background-color 220ms ease, border-color 220ms ease, transform 220ms ease;
}
.menu-item:hover {
background: rgba(255, 255, 255, 0.78);
border-color: rgba(84, 111, 146, 0.24);
transform: translateX(1px);
}
.menu-item.active {
background: #fefefe;
color: #1c2a40;
border-color: rgba(84, 111, 146, 0.28);
box-shadow: none;
}
.menu-icon {
font-size: 16px;
}
.sidebar-footer {
display: grid;
gap: 8px;
}
.sidebar-action {
width: 100%;
}
.sidebar-footer .sidebar-action + .sidebar-action {
margin-left: 0;
}
.dashboard-main {
padding: 16px 18px 18px;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 14px;
overflow: auto;
}
.dashboard-main.with-kpi {
grid-template-rows: auto auto minmax(0, 1fr);
}
.dashboard-topbar {
min-height: 56px;
border-radius: 0;
background: rgba(255, 255, 255, 0.76);
border: 1px solid rgba(149, 162, 182, 0.26);
padding: 8px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.topbar-crumb {
display: flex;
align-items: center;
gap: 8px;
color: #7b8596;
font-size: 14px;
}
.topbar-crumb strong {
color: #2c3644;
}
.topbar-crumb i {
color: #b1bac8;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.top-search {
width: 220px;
}
.top-search .el-input__wrapper {
border-radius: 12px;
}
.icon-btn {
background: rgba(255, 255, 255, 0.68);
border: 1px solid rgba(141, 154, 174, 0.28);
color: #4b5a6f;
border-radius: 999px;
}
.icon-btn:hover {
background: rgba(255, 255, 255, 0.92);
}
.kpi-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.kpi-card {
position: relative;
border-radius: 0;
padding: 16px 18px;
color: #fff;
min-height: 104px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.26);
}
.kpi-card.blue {
background: linear-gradient(135deg, #2c80ec, #46a2ff);
}
.kpi-card.dark {
background: linear-gradient(135deg, #111319, #2f3442);
}
.kpi-card p {
margin: 0;
font-size: 14px;
opacity: 0.9;
}
.kpi-value {
margin-top: 10px;
font-size: 36px;
line-height: 1;
font-family: "Fira Code", monospace;
font-weight: 700;
}
.kpi-delta {
position: absolute;
right: 16px;
bottom: 16px;
font-size: 16px;
font-weight: 600;
}
.kpi-delta.up {
color: rgba(236, 255, 246, 0.96);
}
.kpi-delta.down {
color: rgba(255, 226, 226, 0.96);
}
.dashboard-section {
display: grid;
gap: 12px;
}
.panel {
border-radius: 0;
border: 1px solid rgba(144, 157, 177, 0.28);
background: rgba(255, 255, 255, 0.83);
box-shadow: none;
}
.trend-panel {
padding: 14px 16px 8px;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.panel-tabs {
display: flex;
gap: 14px;
}
.tab {
border: none;
border-radius: 10px;
background: transparent;
color: #9299a7;
padding: 6px 2px;
font-size: 19px;
cursor: pointer;
font-family: "Fira Sans", sans-serif;
}
.tab.active {
color: #b067ff;
font-weight: 600;
}
.line-chart-wrap {
margin-top: 10px;
position: relative;
}
.line-chart-wrap svg {
width: 100%;
height: 240px;
display: block;
}
.line-main {
fill: none;
stroke: #be88ff;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
}
.line-dot {
fill: #ffffff;
stroke: #39404a;
stroke-width: 3;
}
.line-months {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
text-align: center;
color: #6b7382;
font-size: 13px;
padding: 0 14px 8px;
}
.mini-panels {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.mini-panel {
padding: 14px 16px;
}
.mini-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.mini-head h3 {
margin: 0;
font-size: 26px;
color: #1f4fb8;
font-family: "Fira Code", monospace;
}
.mini-panel:nth-child(2) .mini-head h3 {
color: #2a9a58;
}
.more-btn {
width: 34px;
height: 34px;
border-radius: 10px;
border: none;
background: #f0f2f6;
color: #808796;
cursor: pointer;
}
.bar-grid {
margin-top: 16px;
min-height: 130px;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 10px;
align-items: end;
}
.bar-item {
display: grid;
gap: 8px;
justify-items: center;
}
.bar {
width: 100%;
max-width: 46px;
border-radius: 0;
background: #d6d8dc;
transition: background-color 180ms ease, transform 180ms ease;
}
.bar-item.active .bar {
background: #2f83ed;
transform: translateY(-1px);
}
.bar-item span {
font-size: 13px;
color: #5f6878;
}
.data-panel {
padding: 14px;
}
.data-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.data-head h3 {
margin: 0;
font-size: 20px;
color: #2b3647;
font-family: "Fira Code", monospace;
}
.data-head p {
margin: 6px 0 0;
color: #6c7687;
font-size: 13px;
}
.data-head-actions {
display: flex;
gap: 8px;
}
.data-filters {
margin-top: 14px;
display: grid;
grid-template-columns: 1.2fr 1fr 1fr 1fr auto auto;
gap: 10px;
}
.data-table {
margin-top: 12px;
}
.data-pager {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.admin-request-body {
margin: 0;
max-height: 300px;
overflow: auto;
padding: 10px;
border-radius: 0;
background: #111827;
color: #d1d5db;
font-size: 12px;
line-height: 1.45;
}
.artifact-alert {
margin-bottom: 12px;
}
.artifact-form {
margin-bottom: 8px;
}
.artifact-file-input {
width: 100%;
border: 1px dashed #b8c1cf;
border-radius: 0;
background: #f8fafc;
padding: 8px 10px;
color: #2b3647;
}
.artifact-file-name {
margin: 6px 0 0;
color: #5f6878;
font-size: 12px;
}
.artifact-actions {
margin-bottom: 12px;
display: flex;
gap: 8px;
justify-content: flex-end;
}
.dashboard-main button:focus-visible,
.dashboard-main [role='button']:focus-visible,
.menu-item:focus-visible {
outline: 2px solid #3779ff;
outline-offset: 2px;
}
@media (max-width: 1280px) {
.dashboard-shell {
grid-template-columns: 188px minmax(0, 1fr);
}
.kpi-value {
font-size: 30px;
}
.tab {
font-size: 16px;
}
.mini-head h3 {
font-size: 22px;
}
.data-filters {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1024px) {
.dashboard-shell {
width: 100%;
margin: 0;
border-radius: 0;
min-height: 100vh;
grid-template-columns: 1fr;
}
.dashboard-sidebar {
border-right: none;
border-bottom: 1px solid rgba(142, 154, 172, 0.24);
grid-template-rows: auto auto;
}
.sidebar-menu {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.sidebar-footer {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.kpi-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.mini-panels {
grid-template-columns: 1fr;
}
.top-search {
width: 160px;
}
}
@media (max-width: 768px) {
.dashboard-main {
padding: 10px;
}
.dashboard-topbar {
flex-direction: column;
align-items: stretch;
}
.topbar-actions {
width: 100%;
flex-wrap: wrap;
}
.top-search {
width: 100%;
}
.kpi-row {
grid-template-columns: 1fr;
}
.data-filters {
grid-template-columns: 1fr;
}
.data-head {
flex-direction: column;
align-items: stretch;
}
.data-head-actions {
justify-content: flex-start;
}
.data-pager {
justify-content: center;
}
.sidebar-menu {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
}
}

126
client/src/admin/api.js Normal file
View File

@@ -0,0 +1,126 @@
import axios from 'axios';
const baseURL = import.meta.env.VITE_API_BASE || '/api/v1';
const http = axios.create({
baseURL,
timeout: 15000,
});
function unwrap(payload) {
if (payload && typeof payload === 'object' && 'code' in payload) {
if (payload.code !== 0) {
const error = new Error(payload.message || '请求失败');
error.payload = payload;
throw error;
}
return payload.data;
}
return payload;
}
function withToken(token, config = {}) {
return {
...config,
headers: {
...(config.headers || {}),
Authorization: `Bearer ${token}`,
},
};
}
export async function adminLogin(body) {
const response = await http.post('/admin/auth/login', body);
return unwrap(response.data);
}
export async function adminRefresh(refreshToken) {
const response = await http.post('/admin/auth/refresh', { refreshToken });
return unwrap(response.data);
}
export async function adminLogout(token) {
const response = await http.post('/admin/auth/logout', null, withToken(token));
return unwrap(response.data);
}
export async function adminMe(token) {
const response = await http.get('/admin/auth/me', withToken(token));
return unwrap(response.data);
}
export async function adminGetTools(params, token) {
const response = await http.get('/admin/tools', withToken(token, { params }));
return unwrap(response.data);
}
export async function adminCreateTool(payload, token) {
const response = await http.post('/admin/tools', payload, withToken(token));
return unwrap(response.data);
}
export async function adminUpdateTool(id, payload, token) {
const response = await http.patch(`/admin/tools/${id}`, payload, withToken(token));
return unwrap(response.data);
}
export async function adminDeleteTool(id, token) {
const response = await http.delete(`/admin/tools/${id}`, withToken(token));
return unwrap(response.data);
}
export async function adminUpdateToolStatus(id, status, token) {
const response = await http.patch(`/admin/tools/${id}/status`, { status }, withToken(token));
return unwrap(response.data);
}
export async function adminUpdateAccessMode(id, payload, token) {
const response = await http.patch(`/admin/tools/${id}/access-mode`, payload, withToken(token));
return unwrap(response.data);
}
export async function adminGetAuditLogs(params, token) {
const response = await http.get('/admin/audit-logs', withToken(token, { params }));
return unwrap(response.data);
}
export async function adminUploadArtifact(toolId, formData, token) {
const response = await http.post(`/admin/tools/${toolId}/artifacts`, formData, withToken(token));
return unwrap(response.data);
}
export async function adminGetArtifacts(toolId, token) {
const response = await http.get(`/admin/tools/${toolId}/artifacts`, withToken(token));
return unwrap(response.data);
}
export async function adminSetLatestArtifact(toolId, artifactId, token) {
const response = await http.patch(
`/admin/tools/${toolId}/artifacts/${artifactId}/latest`,
{},
withToken(token),
);
return unwrap(response.data);
}
export async function adminUpdateArtifactStatus(toolId, artifactId, status, token) {
const response = await http.patch(
`/admin/tools/${toolId}/artifacts/${artifactId}/status`,
{ status },
withToken(token),
);
return unwrap(response.data);
}
export async function adminDeleteArtifact(toolId, artifactId, token) {
const response = await http.delete(
`/admin/tools/${toolId}/artifacts/${artifactId}`,
withToken(token),
);
return unwrap(response.data);
}
export async function adminGetCategories() {
const response = await http.get('/categories');
return unwrap(response.data);
}

View File

@@ -0,0 +1,49 @@
<template>
<el-dialog v-model="visible" title="更新访问方式" width="520px" destroy-on-close>
<el-form label-width="96px">
<el-form-item label="工具">
<span>{{ modeDialog.name }}</span>
</el-form-item>
<el-form-item label="访问模式">
<el-select v-model="modeDialog.accessMode" style="width: 100%">
<el-option
v-for="item in accessModeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item v-if="modeDialog.accessMode === 'web'" label="Open URL">
<el-input v-model="modeDialog.openUrl" placeholder="https://example.com" />
</el-form-item>
<el-form-item label="新标签页">
<el-switch v-model="modeDialog.openInNewTab" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="modeDialog.submitting" @click="emit('submit')">确认更新</el-button>
</template>
</el-dialog>
</template>
<script setup>
const visible = defineModel('visible', {
type: Boolean,
default: false,
});
defineProps({
modeDialog: {
type: Object,
required: true,
},
accessModeOptions: {
type: Array,
required: true,
},
});
const emit = defineEmits(['submit']);
</script>

View File

@@ -0,0 +1,110 @@
<template>
<section class="dashboard-section">
<article class="panel data-panel">
<header class="data-head">
<h3>Audit Logs</h3>
<p>查看管理端关键操作记录</p>
</header>
<div class="data-filters">
<el-input
v-model="auditFilters.action"
placeholder="操作动作,例如 tool.update"
clearable
@keyup.enter="emit('search')"
/>
<el-input
v-model="auditFilters.resourceType"
placeholder="资源类型,例如 tool"
clearable
@keyup.enter="emit('search')"
/>
<el-input
v-model="auditFilters.adminUserId"
placeholder="管理员ID"
clearable
@keyup.enter="emit('search')"
/>
<el-button type="primary" :loading="auditLoading" @click="emit('search')">查询</el-button>
<el-button @click="emit('reset')">重置</el-button>
</div>
<el-table
:data="auditLogs"
border
stripe
v-loading="auditLoading"
class="data-table"
>
<el-table-column prop="createdAt" label="时间" width="170">
<template #default="{ row }">
{{ formatDateTime(row.createdAt) }}
</template>
</el-table-column>
<el-table-column prop="action" label="动作" min-width="160" />
<el-table-column prop="resourceType" label="资源类型" width="110" />
<el-table-column prop="resourceId" label="资源ID" min-width="150" />
<el-table-column prop="requestMethod" label="方法" width="90" />
<el-table-column prop="requestPath" label="路径" min-width="180" />
<el-table-column label="管理员" min-width="130">
<template #default="{ row }">
{{ row.adminUser?.displayName || row.adminUser?.username || '-' }}
</template>
</el-table-column>
<el-table-column label="请求体" width="130">
<template #default="{ row }">
<el-popover placement="left" trigger="click" :width="360">
<template #reference>
<el-button size="small">查看</el-button>
</template>
<pre class="admin-request-body">{{ stringifyBody(row.requestBody) }}</pre>
</el-popover>
</template>
</el-table-column>
</el-table>
<div class="data-pager">
<el-pagination
v-model:current-page="auditPagination.page"
v-model:page-size="auditPagination.pageSize"
layout="total, sizes, prev, pager, next"
:page-sizes="[20, 50, 100]"
:total="auditPagination.total"
@current-change="emit('page-change', $event)"
@size-change="emit('size-change', $event)"
/>
</div>
</article>
</section>
</template>
<script setup>
defineProps({
auditFilters: {
type: Object,
required: true,
},
auditLoading: {
type: Boolean,
required: true,
},
auditLogs: {
type: Array,
required: true,
},
auditPagination: {
type: Object,
required: true,
},
formatDateTime: {
type: Function,
required: true,
},
stringifyBody: {
type: Function,
required: true,
},
});
const emit = defineEmits(['search', 'reset', 'page-change', 'size-change']);
</script>

View File

@@ -0,0 +1,29 @@
<template>
<section class="kpi-row">
<article
v-for="card in kpiCards"
:key="card.key"
class="kpi-card"
:class="card.theme"
>
<p>{{ card.label }}</p>
<div class="kpi-value">{{ formatNumber(card.value) }}</div>
<span class="kpi-delta" :class="card.delta >= 0 ? 'up' : 'down'">
{{ card.delta >= 0 ? '+' : '' }}{{ card.delta.toFixed(2) }}%
</span>
</article>
</section>
</template>
<script setup>
defineProps({
kpiCards: {
type: Array,
required: true,
},
formatNumber: {
type: Function,
required: true,
},
});
</script>

View File

@@ -0,0 +1,101 @@
<template>
<section class="dashboard-section">
<section class="panel trend-panel">
<header class="panel-head">
<div class="panel-tabs">
<button type="button" class="tab active">Users</button>
<button type="button" class="tab">Projects</button>
<button type="button" class="tab">Operating Status</button>
</div>
<div class="panel-controls">
<el-select v-model="trendRange" size="small" style="width: 108px">
<el-option label="Week" value="week" />
<el-option label="Month" value="month" />
<el-option label="Quarter" value="quarter" />
</el-select>
</div>
</header>
<div class="line-chart-wrap" aria-hidden="true">
<svg viewBox="0 0 760 220" preserveAspectRatio="none">
<polyline class="line-main" :points="trendPolyline" />
<circle
v-for="point in trendMarkers"
:key="`${point.x}-${point.y}`"
class="line-dot"
:cx="point.x"
:cy="point.y"
r="5.5"
/>
</svg>
<div class="line-months">
<span v-for="month in ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']" :key="month">{{ month }}</span>
</div>
</div>
</section>
<section class="mini-panels">
<article class="panel mini-panel">
<header class="mini-head">
<h3>Device Traffic</h3>
<button type="button" class="more-btn">···</button>
</header>
<div class="bar-grid">
<div
v-for="item in deviceTraffic"
:key="item.name"
class="bar-item"
:class="{ active: item.active }"
>
<div class="bar" :style="{ height: `${item.value}%` }"></div>
<span>{{ item.name }}</span>
</div>
</div>
</article>
<article class="panel mini-panel">
<header class="mini-head">
<h3>Location Traffic</h3>
<button type="button" class="more-btn">···</button>
</header>
<div class="bar-grid">
<div
v-for="item in locationTraffic"
:key="item.name"
class="bar-item"
:class="{ active: item.active }"
>
<div class="bar" :style="{ height: `${item.value}%` }"></div>
<span>{{ item.name }}</span>
</div>
</div>
</article>
</section>
</section>
</template>
<script setup>
defineProps({
trendPolyline: {
type: String,
required: true,
},
trendMarkers: {
type: Array,
required: true,
},
deviceTraffic: {
type: Array,
required: true,
},
locationTraffic: {
type: Array,
required: true,
},
});
const trendRange = defineModel('trendRange', {
type: String,
default: 'week',
});
</script>

View File

@@ -0,0 +1,60 @@
<template>
<aside class="dashboard-sidebar">
<div class="sidebar-brand">
<span class="brand-mark">*</span>
<span class="brand-text">snowui</span>
</div>
<div class="sidebar-menu">
<button
v-for="item in menuItems"
:key="item.key"
type="button"
class="menu-item"
:class="{ active: activeMenu === item.key }"
@click="emit('menu-change', item.key)"
>
<el-icon class="menu-icon">
<component :is="item.icon" />
</el-icon>
<span>{{ item.label }}</span>
</button>
</div>
<div class="sidebar-footer">
<el-button class="sidebar-action" @click="emit('go-public')">返回前台</el-button>
<el-button class="sidebar-action" type="danger" plain @click="emit('logout')">退出</el-button>
</div>
</aside>
</template>
<script setup>
import {
ChatDotRound,
DataAnalysis,
Document,
Management,
Setting,
ShoppingBag,
User,
} from '@element-plus/icons-vue';
defineProps({
activeMenu: {
type: String,
required: true,
},
});
const emit = defineEmits(['menu-change', 'go-public', 'logout']);
const menuItems = [
{ key: 'overview', label: 'Overview', icon: DataAnalysis },
{ key: 'tools', label: 'Tool Management', icon: ShoppingBag },
{ key: 'audit', label: 'Audit Logs', icon: Document },
{ key: 'projects', label: 'Projects', icon: Management },
{ key: 'profile', label: 'User Profile', icon: User },
{ key: 'account', label: 'Account', icon: Setting },
{ key: 'social', label: 'Social', icon: ChatDotRound },
];
</script>

View File

@@ -0,0 +1,188 @@
<template>
<section class="dashboard-section">
<article class="panel data-panel">
<header class="data-head">
<div>
<h3>Tools Management</h3>
<p>支持工具信息维护包上传与版本管理</p>
</div>
<div class="data-head-actions">
<el-button type="primary" @click="emit('create')">新增工具</el-button>
</div>
</header>
<div class="data-filters">
<el-input
v-model="toolFilters.query"
placeholder="搜索名称或描述"
clearable
@keyup.enter="emit('search')"
/>
<el-select
v-model="toolFilters.categoryId"
placeholder="全部分类"
clearable
:loading="categoryLoading"
>
<el-option
v-for="item in categories"
:key="item.id"
:label="`${item.name} (${item.toolCount})`"
:value="item.id"
/>
</el-select>
<el-select
v-model="toolFilters.status"
placeholder="全部状态"
clearable
>
<el-option
v-for="item in statusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select
v-model="toolFilters.accessMode"
placeholder="全部访问模式"
clearable
>
<el-option
v-for="item in accessModeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-button type="primary" :loading="toolLoading" @click="emit('search')">查询</el-button>
<el-button @click="emit('reset')">重置</el-button>
</div>
<el-table
:data="tools"
border
stripe
v-loading="toolLoading"
class="data-table"
>
<el-table-column prop="name" label="工具名称" min-width="180" />
<el-table-column label="分类" min-width="120">
<template #default="{ row }">
{{ row.category?.name || '-' }}
</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" effect="light">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="访问方式" width="120">
<template #default="{ row }">
<el-tag :type="accessModeTagType(row.accessMode)" effect="light">{{ row.accessMode }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="rating" label="评分" width="80" />
<el-table-column label="下载/访问" width="150">
<template #default="{ row }">
{{ formatNumber(row.downloadCount) }} / {{ formatNumber(row.openCount) }}
</template>
</el-table-column>
<el-table-column label="最近更新" width="120">
<template #default="{ row }">
{{ formatDate(row.updatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="390" fixed="right">
<template #default="{ row }">
<el-space>
<el-button size="small" type="primary" plain @click="emit('edit', row)">编辑</el-button>
<el-button size="small" type="success" plain @click="emit('artifact', row)">上传包</el-button>
<el-button size="small" @click="emit('status', row)">改状态</el-button>
<el-button size="small" type="primary" plain @click="emit('mode', row)">改访问方式</el-button>
<el-button size="small" type="danger" plain @click="emit('delete', row)">删除</el-button>
</el-space>
</template>
</el-table-column>
</el-table>
<div class="data-pager">
<el-pagination
v-model:current-page="toolPagination.page"
v-model:page-size="toolPagination.pageSize"
layout="total, sizes, prev, pager, next"
:page-sizes="[10, 20, 30, 50]"
:total="toolPagination.total"
@current-change="emit('page-change', $event)"
@size-change="emit('size-change', $event)"
/>
</div>
</article>
</section>
</template>
<script setup>
defineProps({
toolFilters: {
type: Object,
required: true,
},
categoryLoading: {
type: Boolean,
required: true,
},
categories: {
type: Array,
required: true,
},
statusOptions: {
type: Array,
required: true,
},
accessModeOptions: {
type: Array,
required: true,
},
toolLoading: {
type: Boolean,
required: true,
},
tools: {
type: Array,
required: true,
},
toolPagination: {
type: Object,
required: true,
},
statusTagType: {
type: Function,
required: true,
},
accessModeTagType: {
type: Function,
required: true,
},
formatNumber: {
type: Function,
required: true,
},
formatDate: {
type: Function,
required: true,
},
});
const emit = defineEmits([
'search',
'reset',
'create',
'edit',
'artifact',
'status',
'mode',
'delete',
'page-change',
'size-change',
]);
</script>

View File

@@ -0,0 +1,54 @@
<template>
<header class="dashboard-topbar">
<div class="topbar-crumb">
<span>Dashboards</span>
<i>/</i>
<strong>{{ sectionTitle }}</strong>
</div>
<div class="topbar-actions">
<el-input
v-model="search"
class="top-search"
placeholder="Search"
clearable
@keyup.enter="emit('apply-search')"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button class="icon-btn" circle @click="emit('refresh')">
<el-icon><Refresh /></el-icon>
</el-button>
<el-button class="icon-btn" circle @click="emit('open-overview')">
<el-icon><DataAnalysis /></el-icon>
</el-button>
<el-button class="icon-btn" circle @click="emit('open-tools')">
<el-icon><Grid /></el-icon>
</el-button>
<el-button class="icon-btn" circle @click="emit('open-audit')">
<el-icon><Document /></el-icon>
</el-button>
</div>
</header>
</template>
<script setup>
import { DataAnalysis, Document, Grid, Refresh, Search } from '@element-plus/icons-vue';
defineProps({
sectionTitle: {
type: String,
required: true,
},
});
const search = defineModel('search', {
type: String,
default: '',
});
const emit = defineEmits(['apply-search', 'refresh', 'open-overview', 'open-tools', 'open-audit']);
</script>

View File

@@ -0,0 +1,164 @@
<template>
<el-dialog
v-model="visible"
title="工具包上传与维护"
width="860px"
destroy-on-close
>
<el-alert
:title="`当前工具:${artifactDialog.toolName || '-'}`"
type="info"
:closable="false"
class="artifact-alert"
/>
<el-form :model="artifactForm" label-width="96px" class="artifact-form">
<el-form-item label="版本号">
<el-input v-model="artifactForm.version" placeholder="例如1.0.0" />
</el-form-item>
<el-form-item label="发布说明">
<el-input
v-model="artifactForm.releaseNotes"
type="textarea"
:rows="3"
maxlength="1000"
show-word-limit
placeholder="可填写本次发布说明"
/>
</el-form-item>
<el-form-item label="设为最新">
<el-switch v-model="artifactForm.isLatest" />
</el-form-item>
<el-form-item label="上传文件">
<input
:key="fileInputKey"
class="artifact-file-input"
type="file"
accept=".zip,.tar.gz,.tgz,.exe,.dmg,.pkg,.msi"
@change="handleFileChange"
/>
<p v-if="artifactForm.fileName" class="artifact-file-name">已选择{{ artifactForm.fileName }}</p>
</el-form-item>
</el-form>
<div class="artifact-actions">
<el-button type="primary" :loading="artifactDialog.uploading" @click="emit('upload')">上传安装包</el-button>
<el-button :loading="artifactDialog.loading" @click="emit('refresh')">刷新列表</el-button>
</div>
<el-table
:data="artifactDialog.artifacts"
border
stripe
v-loading="artifactDialog.loading"
class="data-table"
>
<el-table-column prop="version" label="版本" width="130" />
<el-table-column prop="fileName" label="文件名" min-width="190" />
<el-table-column label="大小" width="110">
<template #default="{ row }">
{{ formatFileSize(row.fileSizeBytes) }}
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag :type="artifactStatusTagType(row.status)" effect="light">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="最新" width="90">
<template #default="{ row }">
<el-tag v-if="row.isLatest" type="success" effect="light">latest</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="上传时间" width="170">
<template #default="{ row }">
{{ formatDateTime(row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }">
<el-space>
<el-button
v-if="!row.isLatest && row.status === 'active'"
size="small"
type="primary"
plain
:loading="artifactDialog.actionLoadingId === row.id"
@click="emit('set-latest', row)"
>
设为最新
</el-button>
<el-button
v-if="row.status === 'active'"
size="small"
plain
:loading="artifactDialog.actionLoadingId === row.id"
@click="emit('change-status', row, 'deprecated')"
>
标记废弃
</el-button>
<el-button
v-if="row.status === 'deprecated'"
size="small"
plain
:loading="artifactDialog.actionLoadingId === row.id"
@click="emit('change-status', row, 'active')"
>
恢复启用
</el-button>
<el-button
v-if="row.status !== 'deleted'"
size="small"
type="danger"
plain
:loading="artifactDialog.actionLoadingId === row.id"
@click="emit('delete-artifact', row)"
>
删除
</el-button>
</el-space>
</template>
</el-table-column>
</el-table>
</el-dialog>
</template>
<script setup>
const visible = defineModel('visible', {
type: Boolean,
default: false,
});
const emit = defineEmits(['upload', 'refresh', 'file-change', 'set-latest', 'change-status', 'delete-artifact']);
defineProps({
artifactDialog: {
type: Object,
required: true,
},
artifactForm: {
type: Object,
required: true,
},
fileInputKey: {
type: Number,
required: true,
},
formatFileSize: {
type: Function,
required: true,
},
formatDateTime: {
type: Function,
required: true,
},
artifactStatusTagType: {
type: Function,
required: true,
},
});
function handleFileChange(event) {
const file = event?.target?.files?.[0] || null;
emit('file-change', file);
}
</script>

View File

@@ -0,0 +1,43 @@
<template>
<el-dialog v-model="visible" title="更新工具状态" width="420px" destroy-on-close>
<el-form label-width="86px">
<el-form-item label="工具">
<span>{{ statusDialog.name }}</span>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="statusDialog.status" style="width: 100%">
<el-option
v-for="item in statusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="statusDialog.submitting" @click="emit('submit')">确认更新</el-button>
</template>
</el-dialog>
</template>
<script setup>
const visible = defineModel('visible', {
type: Boolean,
default: false,
});
defineProps({
statusDialog: {
type: Object,
required: true,
},
statusOptions: {
type: Array,
required: true,
},
});
const emit = defineEmits(['submit']);
</script>

View File

@@ -0,0 +1,141 @@
<template>
<el-dialog
v-model="visible"
:title="mode === 'create' ? '新增工具' : '编辑工具'"
width="700px"
destroy-on-close
>
<el-form
ref="toolFormRef"
:model="toolForm"
:rules="toolFormRules"
label-width="92px"
>
<el-form-item label="工具名称" prop="name">
<el-input v-model="toolForm.name" placeholder="例如Dev Helper" maxlength="120" show-word-limit />
</el-form-item>
<el-form-item label="工具分类" prop="categoryId">
<el-select
v-model="toolForm.categoryId"
placeholder="请选择分类"
filterable
style="width: 100%"
:loading="categoryLoading"
>
<el-option
v-for="item in categories"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="工具简介" prop="description">
<el-input
v-model="toolForm.description"
type="textarea"
:rows="4"
maxlength="2000"
show-word-limit
placeholder="请描述工具用途、适用场景与优势"
/>
</el-form-item>
<el-form-item label="评分" prop="rating">
<el-input-number v-model="toolForm.rating" :min="0" :max="5" :step="0.1" :precision="1" />
</el-form-item>
<el-form-item label="功能点">
<el-input
v-model="toolForm.featuresText"
type="textarea"
:rows="4"
placeholder="每行一个功能点,例如&#10;支持离线模式&#10;支持自动更新"
/>
</el-form-item>
<el-form-item label="访问方式" prop="accessMode">
<el-select v-model="toolForm.accessMode" style="width: 100%">
<el-option
v-for="item in accessModeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item v-if="toolForm.accessMode === 'web'" label="Open URL" prop="openUrl">
<el-input v-model="toolForm.openUrl" placeholder="https://example.com" />
</el-form-item>
<el-form-item label="新标签页">
<el-switch v-model="toolForm.openInNewTab" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="toolForm.status" style="width: 100%">
<el-option
v-for="item in statusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="emit('submit')">
{{ mode === 'create' ? '创建工具' : '保存修改' }}
</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref } from 'vue';
const visible = defineModel('visible', {
type: Boolean,
default: false,
});
defineProps({
mode: {
type: String,
required: true,
},
toolForm: {
type: Object,
required: true,
},
toolFormRules: {
type: Object,
required: true,
},
categories: {
type: Array,
required: true,
},
categoryLoading: {
type: Boolean,
required: true,
},
accessModeOptions: {
type: Array,
required: true,
},
statusOptions: {
type: Array,
required: true,
},
submitting: {
type: Boolean,
required: true,
},
});
const emit = defineEmits(['submit']);
const toolFormRef = ref(null);
defineExpose({
validate: () => toolFormRef.value?.validate?.(),
clearValidate: () => toolFormRef.value?.clearValidate?.(),
});
</script>

View File

@@ -0,0 +1,21 @@
import { createRouter, createWebHistory } from 'vue-router';
import PublicApp from '../App.vue';
import AdminApp from './AdminApp.vue';
const routes = [
{
path: '/',
name: 'public-home',
component: PublicApp,
},
{
path: '/admin',
name: 'admin-home',
component: AdminApp,
},
];
export const router = createRouter({
history: createWebHistory(),
routes,
});

View File

@@ -0,0 +1,141 @@
import { defineStore } from 'pinia';
import { adminLogin, adminLogout, adminMe, adminRefresh } from '../api';
const STORAGE_KEY = 'toolsshow_admin_auth';
function parseStorage() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return null;
}
return JSON.parse(raw);
} catch {
return null;
}
}
export const useAdminAuthStore = defineStore('admin-auth', {
state: () => ({
accessToken: '',
refreshToken: '',
expiresIn: 0,
profile: null,
initialized: false,
bootstrapping: false,
loggingIn: false,
}),
getters: {
isAuthenticated(state) {
return Boolean(state.accessToken && state.profile?.id);
},
},
actions: {
persist() {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
accessToken: this.accessToken,
refreshToken: this.refreshToken,
expiresIn: this.expiresIn,
profile: this.profile,
}),
);
},
hydrate() {
const stored = parseStorage();
if (!stored) {
return;
}
this.accessToken = stored.accessToken || '';
this.refreshToken = stored.refreshToken || '';
this.expiresIn = Number(stored.expiresIn || 0);
this.profile = stored.profile || null;
},
setTokens(data) {
this.accessToken = data.accessToken || '';
this.refreshToken = data.refreshToken || this.refreshToken;
this.expiresIn = Number(data.expiresIn || 0);
this.persist();
},
clearSession() {
this.accessToken = '';
this.refreshToken = '';
this.expiresIn = 0;
this.profile = null;
localStorage.removeItem(STORAGE_KEY);
},
async initialize() {
if (this.initialized) {
return;
}
this.hydrate();
this.bootstrapping = true;
try {
if (!this.accessToken) {
return;
}
await this.fetchMe();
} catch {
try {
if (this.refreshToken) {
await this.refreshAccessToken();
await this.fetchMe();
} else {
this.clearSession();
}
} catch {
this.clearSession();
}
} finally {
this.initialized = true;
this.bootstrapping = false;
}
},
async login(payload) {
this.loggingIn = true;
try {
const data = await adminLogin(payload);
this.accessToken = data.accessToken || '';
this.refreshToken = data.refreshToken || '';
this.expiresIn = Number(data.expiresIn || 0);
this.profile = data.profile || null;
this.persist();
if (!this.profile) {
await this.fetchMe();
}
} finally {
this.loggingIn = false;
}
},
async refreshAccessToken() {
if (!this.refreshToken) {
throw new Error('refresh token not available');
}
const data = await adminRefresh(this.refreshToken);
this.setTokens(data);
return this.accessToken;
},
async fetchMe() {
if (!this.accessToken) {
throw new Error('access token not available');
}
const data = await adminMe(this.accessToken);
this.profile = data;
this.persist();
return data;
},
async logout() {
try {
if (this.accessToken) {
await adminLogout(this.accessToken);
}
} finally {
this.clearSession();
}
},
},
});

View File

@@ -0,0 +1,164 @@
import { defineStore } from 'pinia';
import {
adminCreateTool,
adminDeleteArtifact,
adminDeleteTool,
adminGetArtifacts,
adminGetAuditLogs,
adminGetCategories,
adminGetTools,
adminSetLatestArtifact,
adminUpdateArtifactStatus,
adminUpdateTool,
adminUpdateAccessMode,
adminUpdateToolStatus,
adminUploadArtifact,
} from '../api';
export const useAdminConsoleStore = defineStore('admin-console', {
state: () => ({
categories: [],
categoryLoading: false,
toolFilters: {
query: '',
categoryId: '',
status: '',
accessMode: '',
page: 1,
pageSize: 10,
},
tools: [],
toolLoading: false,
toolPagination: {
page: 1,
pageSize: 10,
total: 0,
totalPages: 1,
},
auditFilters: {
action: '',
resourceType: '',
adminUserId: '',
page: 1,
pageSize: 20,
},
auditLogs: [],
auditLoading: false,
auditPagination: {
page: 1,
pageSize: 20,
total: 0,
totalPages: 1,
},
}),
actions: {
async loadCategories() {
this.categoryLoading = true;
try {
const data = await adminGetCategories();
this.categories = Array.isArray(data) ? data : [];
} finally {
this.categoryLoading = false;
}
},
setToolPage(page) {
this.toolFilters.page = page;
},
resetToolFilters() {
this.toolFilters.query = '';
this.toolFilters.categoryId = '';
this.toolFilters.status = '';
this.toolFilters.accessMode = '';
this.toolFilters.page = 1;
},
async loadTools(token) {
this.toolLoading = true;
try {
const data = await adminGetTools(
{
query: this.toolFilters.query || undefined,
categoryId: this.toolFilters.categoryId || undefined,
status: this.toolFilters.status || undefined,
accessMode: this.toolFilters.accessMode || undefined,
page: this.toolFilters.page,
pageSize: this.toolFilters.pageSize,
},
token,
);
this.tools = Array.isArray(data?.list) ? data.list : [];
this.toolPagination.page = Number(data?.pagination?.page ?? this.toolFilters.page);
this.toolPagination.pageSize = Number(data?.pagination?.pageSize ?? this.toolFilters.pageSize);
this.toolPagination.total = Number(data?.pagination?.total ?? 0);
this.toolPagination.totalPages = Math.max(1, Number(data?.pagination?.totalPages ?? 1));
} finally {
this.toolLoading = false;
}
},
async updateToolStatus(id, status, token) {
await adminUpdateToolStatus(id, status, token);
},
async createTool(payload, token) {
return adminCreateTool(payload, token);
},
async updateTool(id, payload, token) {
return adminUpdateTool(id, payload, token);
},
async deleteTool(id, token) {
return adminDeleteTool(id, token);
},
async updateAccessMode(id, payload, token) {
await adminUpdateAccessMode(id, payload, token);
},
async uploadArtifact(toolId, formData, token) {
return adminUploadArtifact(toolId, formData, token);
},
async getArtifacts(toolId, token) {
return adminGetArtifacts(toolId, token);
},
async setLatestArtifact(toolId, artifactId, token) {
return adminSetLatestArtifact(toolId, artifactId, token);
},
async updateArtifactStatus(toolId, artifactId, status, token) {
return adminUpdateArtifactStatus(toolId, artifactId, status, token);
},
async deleteArtifact(toolId, artifactId, token) {
return adminDeleteArtifact(toolId, artifactId, token);
},
setAuditPage(page) {
this.auditFilters.page = page;
},
resetAuditFilters() {
this.auditFilters.action = '';
this.auditFilters.resourceType = '';
this.auditFilters.adminUserId = '';
this.auditFilters.page = 1;
},
async loadAuditLogs(token) {
this.auditLoading = true;
try {
const data = await adminGetAuditLogs(
{
action: this.auditFilters.action || undefined,
resourceType: this.auditFilters.resourceType || undefined,
adminUserId: this.auditFilters.adminUserId || undefined,
page: this.auditFilters.page,
pageSize: this.auditFilters.pageSize,
},
token,
);
this.auditLogs = Array.isArray(data?.list) ? data.list : [];
this.auditPagination.page = Number(data?.pagination?.page ?? this.auditFilters.page);
this.auditPagination.pageSize = Number(data?.pagination?.pageSize ?? this.auditFilters.pageSize);
this.auditPagination.total = Number(data?.pagination?.total ?? 0);
this.auditPagination.totalPages = Math.max(1, Number(data?.pagination?.totalPages ?? 1));
} finally {
this.auditLoading = false;
}
},
},
});

87
client/src/api.js Normal file
View File

@@ -0,0 +1,87 @@
import axios from 'axios';
const baseURL = import.meta.env.VITE_API_BASE || '/api/v1';
const apiOrigin = /^https?:\/\//.test(baseURL) ? new URL(baseURL).origin : window.location.origin;
const http = axios.create({
baseURL,
timeout: 15000,
});
function unwrap(payload) {
if (payload && typeof payload === 'object' && 'code' in payload) {
if (payload.code !== 0) {
throw new Error(payload.message || '请求失败');
}
return payload.data;
}
return payload;
}
export async function apiGet(url, config = {}) {
const response = await http.get(url, config);
return unwrap(response.data);
}
export async function apiPost(url, data, config = {}) {
const response = await http.post(url, data, config);
return unwrap(response.data);
}
export function fetchTools(params) {
return apiGet('/tools', { params });
}
export function fetchToolDetail(id) {
return apiGet(`/tools/${id}`);
}
export function fetchCategories() {
return apiGet('/categories');
}
export function fetchHotKeywords() {
return apiGet('/keywords/hot');
}
export function fetchOverview() {
return apiGet('/overview');
}
export function launchTool(id, payload) {
return apiPost(`/tools/${id}/launch`, payload);
}
export function resolveActionUrl(actionUrl) {
if (!actionUrl) {
return '';
}
if (/^https?:\/\//.test(actionUrl)) {
return actionUrl;
}
return new URL(actionUrl, apiOrigin).toString();
}
export function getApiErrorMessage(error) {
if (axios.isAxiosError(error)) {
const data = error.response?.data;
if (data && typeof data === 'object' && 'message' in data && data.message) {
if (Array.isArray(data.message)) {
return data.message.join('; ');
}
return String(data.message);
}
if (error.message) {
return error.message;
}
}
if (error instanceof Error) {
return error.message;
}
return '请求失败,请稍后重试';
}

14
client/src/main.js Normal file
View File

@@ -0,0 +1,14 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import RootApp from './RootApp.vue';
import { router } from './admin/router';
import './style.scss';
import './admin/admin.scss';
const app = createApp(RootApp);
app.use(createPinia());
app.use(router);
app.use(ElementPlus);
app.mount('#app');

861
client/src/style.scss Normal file
View File

@@ -0,0 +1,861 @@
:root {
--primary: #0a8fb5;
--primary-strong: #0d7697;
--secondary: #22d3ee;
--cta: #16a34a;
--bg: #f3f8fc;
--bg-mesh-a: rgba(34, 211, 238, 0.22);
--bg-mesh-b: rgba(14, 165, 233, 0.18);
--surface: rgba(255, 255, 255, 0.78);
--surface-strong: #ffffff;
--card: #ffffff;
--text: #0f2f3d;
--muted: #4b6674;
--line: rgba(18, 117, 150, 0.2);
--line-strong: rgba(18, 117, 150, 0.34);
--focus: #0ea5e9;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--duration-fast: 160ms;
--duration-normal: 240ms;
--shadow-soft: 0 12px 34px rgba(12, 66, 92, 0.12);
--shadow-lift: 0 16px 40px rgba(12, 66, 92, 0.18);
--glass-blur: 14px;
--radius-lg: 18px;
--radius-md: 12px;
--radius-sm: 8px;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: "Manrope", "PingFang SC", "Microsoft YaHei", sans-serif;
line-height: 1.5;
}
html {
scroll-behavior: smooth;
}
body {
min-height: 100vh;
position: relative;
overflow-x: hidden;
background:
radial-gradient(460px 260px at 0% 8%, var(--bg-mesh-a), transparent 75%),
radial-gradient(380px 220px at 98% 4%, var(--bg-mesh-b), transparent 74%),
linear-gradient(180deg, #f7fcff 0%, var(--bg) 100%);
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
select {
font: inherit;
color: inherit;
}
.container {
width: min(1200px, calc(100% - 32px));
margin: 0 auto;
}
.header-wrap {
position: sticky;
top: 0;
z-index: 10;
width: 100%;
border-bottom: 1px solid var(--line);
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(var(--glass-blur));
transition:
box-shadow var(--duration-normal) var(--ease-standard),
border-color var(--duration-normal) var(--ease-standard);
}
.header-wrap.is-scrolled {
border-bottom-color: var(--line-strong);
box-shadow: 0 8px 22px rgba(10, 72, 103, 0.1);
}
.header {
padding: 12px 0;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
font-family: "Sora", sans-serif;
font-weight: 700;
letter-spacing: 0.01em;
}
.brand-mark {
width: 32px;
height: 32px;
border-radius: 10px;
background: var(--primary);
color: #fff;
display: grid;
place-items: center;
}
.nav {
display: flex;
flex-wrap: wrap;
gap: 10px;
color: var(--muted);
font-size: 14px;
}
.nav a,
.nav-btn {
border-radius: var(--radius-sm);
padding: 8px 10px;
border: 1px solid rgba(255, 255, 255, 0);
background: rgba(255, 255, 255, 0.42);
color: inherit;
cursor: pointer;
transition:
background-color var(--duration-fast) var(--ease-standard),
color var(--duration-fast) var(--ease-standard),
transform var(--duration-fast) var(--ease-standard),
border-color var(--duration-fast) var(--ease-standard);
}
.nav a:hover,
.nav-btn:hover {
background: rgba(233, 249, 255, 0.86);
border-color: rgba(20, 143, 179, 0.2);
color: var(--text);
}
.nav-btn.active {
background: rgba(217, 246, 255, 0.9);
color: var(--text);
border-color: rgba(20, 143, 179, 0.34);
}
.hero {
margin-top: 14px;
display: block;
}
.hero-main {
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: linear-gradient(145deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.72));
backdrop-filter: blur(10px);
box-shadow: var(--shadow-soft);
padding: 14px;
}
.main-content {
animation: content-fade-in 420ms var(--ease-standard) both;
}
h1,
h2,
h3 {
margin: 0;
font-family: "Sora", sans-serif;
}
.search-row {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 10px;
}
.search-box {
min-height: 44px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.94);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
}
.search-box input {
border: none;
outline: none;
width: 100%;
min-height: 42px;
background: transparent;
}
.select,
.btn {
min-height: 44px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.9);
padding: 0 12px;
box-shadow: 0 2px 8px rgba(13, 88, 124, 0.06);
}
.select {
cursor: pointer;
}
.btn {
cursor: pointer;
transition:
background-color var(--duration-fast) var(--ease-standard),
border-color var(--duration-fast) var(--ease-standard),
transform var(--duration-fast) var(--ease-standard),
box-shadow var(--duration-fast) var(--ease-standard);
}
.btn:hover {
background: rgba(241, 252, 255, 0.95);
border-color: var(--line-strong);
box-shadow: 0 8px 20px rgba(13, 88, 124, 0.12);
}
.btn-primary {
background: linear-gradient(135deg, var(--primary), #0ea5c8);
border-color: var(--primary);
color: #fff;
box-shadow: 0 12px 24px rgba(10, 143, 181, 0.24);
}
.btn-primary:hover {
background: linear-gradient(135deg, #0e81a2, #1091b0);
border-color: #0e81a2;
}
.hot-keywords {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.hot-keywords > span {
font-size: 13px;
color: var(--muted);
}
.hot-empty {
font-size: 13px;
}
.chip {
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.88);
border-radius: 999px;
padding: 7px 12px;
font-size: 13px;
cursor: pointer;
transition:
background-color var(--duration-fast) var(--ease-standard),
border-color var(--duration-fast) var(--ease-standard),
transform var(--duration-fast) var(--ease-standard);
}
.chip:hover,
.chip.active {
background: rgba(217, 246, 255, 0.92);
border-color: rgba(18, 117, 150, 0.34);
}
.tips {
margin: 12px 0 0;
padding-left: 18px;
color: var(--muted);
display: grid;
gap: 8px;
font-size: 14px;
}
.toolbar {
margin-top: 0;
margin-bottom: 14px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.toolbar p {
margin: 0;
color: var(--muted);
font-size: 14px;
}
.tool-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(268px, 1fr));
gap: 12px;
}
.tools-layout {
margin-top: 14px;
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
gap: 14px;
align-items: start;
}
.tools-main {
min-width: 0;
}
.category-sidebar {
position: sticky;
top: 74px;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: linear-gradient(150deg, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.78));
backdrop-filter: blur(12px);
box-shadow: var(--shadow-soft);
padding: 14px;
}
.sidebar-title {
font-size: 18px;
}
.sidebar-tip {
margin: 6px 0 12px;
color: var(--muted);
font-size: 13px;
}
.category-sidebar-list {
display: grid;
gap: 8px;
}
.category-side-btn {
width: 100%;
min-height: 42px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.86);
color: var(--text);
padding: 0 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
text-align: left;
transition:
background-color var(--duration-fast) var(--ease-standard),
border-color var(--duration-fast) var(--ease-standard),
transform var(--duration-fast) var(--ease-standard);
}
.category-side-btn:hover {
background: rgba(239, 251, 255, 0.96);
border-color: rgba(20, 143, 179, 0.3);
}
.category-side-btn.active {
background: linear-gradient(135deg, rgba(214, 247, 255, 0.94), rgba(225, 252, 255, 0.9));
border-color: rgba(20, 143, 179, 0.34);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.category-side-btn .label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.category-side-btn .count {
border: 1px solid rgba(18, 117, 150, 0.22);
border-radius: 999px;
background: rgba(241, 252, 255, 0.9);
color: #0b6d8a;
font-size: 12px;
line-height: 1;
padding: 4px 8px;
flex-shrink: 0;
}
.card {
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: linear-gradient(160deg, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.82));
backdrop-filter: blur(12px);
box-shadow: var(--shadow-soft);
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
min-height: 280px;
opacity: 0;
transform: translateY(8px);
will-change: transform, opacity;
animation: card-enter 380ms var(--ease-standard) both;
animation-delay: var(--stagger, 0ms);
transition:
border-color var(--duration-normal) var(--ease-standard),
background-color var(--duration-normal) var(--ease-standard),
transform var(--duration-normal) var(--ease-standard),
box-shadow var(--duration-normal) var(--ease-standard);
}
.card:hover {
border-color: rgba(18, 117, 150, 0.34);
background: linear-gradient(160deg, rgba(255, 255, 255, 0.95), rgba(246, 252, 255, 0.88));
transform: translateY(-2px);
box-shadow: var(--shadow-lift);
}
.card-skeleton {
pointer-events: none;
animation: none;
opacity: 1;
transform: none;
}
.skeleton-line {
width: 100%;
height: 10px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(226, 240, 245, 0.9), rgba(241, 249, 252, 0.96), rgba(226, 240, 245, 0.9));
background-size: 200% 100%;
animation: shimmer 1.2s linear infinite;
}
.skeleton-chip {
width: 40%;
}
.skeleton-title {
width: 62%;
height: 18px;
}
.skeleton-btn {
width: 48%;
height: 32px;
margin-top: auto;
}
.card-top,
.card-foot {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.category {
border: 1px solid rgba(21, 128, 110, 0.24);
background: rgba(236, 253, 245, 0.9);
color: #0f766e;
font-size: 12px;
border-radius: 999px;
padding: 2px 9px;
}
.card h3 {
font-size: 18px;
}
.desc {
margin: 0;
color: var(--muted);
font-size: 14px;
min-height: 42px;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag {
border: 1px solid rgba(18, 117, 150, 0.2);
background: rgba(236, 251, 255, 0.92);
color: #0c6f8d;
border-radius: 999px;
font-size: 12px;
padding: 2px 8px;
}
.meta-list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 4px;
color: var(--muted);
font-size: 13px;
}
.meta-list strong {
color: var(--text);
}
.card-foot {
margin-top: auto;
}
.download-num {
color: var(--muted);
font-size: 13px;
}
.actions {
display: flex;
gap: 8px;
}
.btn-small {
min-height: 36px;
border-radius: var(--radius-sm);
padding: 0 10px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.92);
cursor: pointer;
transition:
background-color var(--duration-fast) var(--ease-standard),
border-color var(--duration-fast) var(--ease-standard),
transform var(--duration-fast) var(--ease-standard);
}
.btn-small:hover {
background: rgba(240, 251, 255, 0.95);
border-color: rgba(20, 143, 179, 0.3);
}
.btn-small:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-download {
border-color: rgba(22, 163, 74, 0.34);
background: linear-gradient(135deg, rgba(236, 253, 243, 0.95), rgba(220, 252, 231, 0.92));
color: #166534;
}
.btn-download:hover {
border-color: rgba(22, 163, 74, 0.5);
background: linear-gradient(135deg, rgba(220, 252, 231, 0.95), rgba(199, 246, 212, 0.92));
}
.btn-open {
border-color: rgba(14, 165, 233, 0.36);
background: linear-gradient(135deg, rgba(224, 242, 254, 0.96), rgba(207, 234, 254, 0.92));
color: #0b5f87;
}
.btn-open:hover {
border-color: rgba(14, 165, 233, 0.52);
background: linear-gradient(135deg, rgba(209, 233, 253, 0.96), rgba(188, 224, 252, 0.92));
}
.empty {
grid-column: 1 / -1;
border: 1px dashed rgba(18, 117, 150, 0.35);
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(8px);
padding: 24px;
text-align: center;
color: var(--muted);
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
margin: 18px 0 34px;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(7, 31, 44, 0.36);
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
opacity var(--duration-normal) var(--ease-standard),
visibility var(--duration-normal) var(--ease-standard);
}
.modal-backdrop.open {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.modal {
width: min(680px, 100%);
max-height: 86vh;
overflow: auto;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: linear-gradient(150deg, rgba(255, 255, 255, 0.95), rgba(248, 253, 255, 0.88));
backdrop-filter: blur(16px);
box-shadow: var(--shadow-lift);
padding: 20px;
opacity: 0;
transform: translateY(14px) scale(0.985);
transition:
transform var(--duration-normal) var(--ease-standard),
opacity var(--duration-normal) var(--ease-standard);
}
.modal-backdrop.open .modal {
opacity: 1;
transform: translateY(0) scale(1);
}
.modal-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.modal p {
color: var(--muted);
}
.modal-muted {
color: var(--muted);
}
.modal-error {
border: 1px solid rgba(220, 38, 38, 0.22);
background: rgba(254, 242, 242, 0.82);
color: #b91c1c;
border-radius: var(--radius-sm);
padding: 10px 12px;
margin: 10px 0;
}
.inline-link {
color: var(--primary-strong);
word-break: break-all;
}
.inline-link:hover {
text-decoration: underline;
}
.feature-list {
margin: 0;
padding-left: 18px;
display: grid;
gap: 6px;
}
.icon-btn {
width: 36px;
height: 36px;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.9);
cursor: pointer;
transition:
background-color var(--duration-fast) var(--ease-standard),
transform var(--duration-fast) var(--ease-standard);
}
.icon-btn:hover {
background: rgba(240, 251, 255, 0.95);
border-color: var(--line-strong);
}
.kpi-grid {
margin-top: 10px;
display: grid;
grid-template-columns: repeat(2, minmax(120px, 1fr));
gap: 10px;
}
.kpi-grid > div {
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: rgba(248, 253, 255, 0.92);
padding: 12px;
}
.kpi-grid strong {
display: block;
font-size: 24px;
line-height: 1;
}
.kpi-grid span {
color: var(--muted);
font-size: 13px;
}
.toast {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 30;
border: 1px solid rgba(14, 157, 127, 0.5);
border-radius: 10px;
background: linear-gradient(135deg, #0d8f82, #0f766e);
box-shadow: 0 12px 28px rgba(6, 78, 73, 0.26);
color: #fff;
padding: 10px 14px;
font-size: 14px;
opacity: 0;
pointer-events: none;
transform: translateY(8px);
transition:
opacity var(--duration-normal) var(--ease-standard),
transform var(--duration-normal) var(--ease-standard);
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
clip-path: inset(50%);
white-space: nowrap;
}
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
@media (hover: hover) and (pointer: fine) {
.btn:hover,
.btn-small:hover,
.icon-btn:hover,
.chip:hover,
.nav a:hover,
.nav-btn:hover {
transform: translateY(-1px);
}
}
.btn:active,
.btn-small:active,
.icon-btn:active,
.chip:active {
transform: translateY(0);
}
@media (max-width: 768px) {
.container {
width: min(1200px, calc(100% - 24px));
}
.header {
flex-direction: column;
align-items: flex-start;
}
.nav {
width: 100%;
}
.search-row {
grid-template-columns: 1fr;
}
.select,
.btn {
width: 100%;
}
}
@media (max-width: 1024px) {
.tools-layout {
grid-template-columns: 1fr;
}
.category-sidebar {
position: static;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
scroll-behavior: auto !important;
}
}
@keyframes content-fade-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes card-enter {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}

15
client/vite.config.js Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
});