update
This commit is contained in:
2
client/.env.development.example
Normal file
2
client/.env.development.example
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE=/api/v1
|
||||
VITE_API_PROXY_TARGET=http://localhost:3000
|
||||
@@ -3,16 +3,14 @@
|
||||
<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"
|
||||
/>
|
||||
<title>资源导航站点</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="256x256" href="/favicon.png" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="/local-fonts.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
30
client/package-lock.json
generated
30
client/package-lock.json
generated
@@ -9,7 +9,9 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.13.1",
|
||||
"dompurify": "^3.3.3",
|
||||
"element-plus": "^2.11.7",
|
||||
"marked": "^17.0.5",
|
||||
"pinia": "^2.3.1",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.6.3"
|
||||
@@ -1257,6 +1259,13 @@
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.20",
|
||||
"resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
|
||||
@@ -1518,6 +1527,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.3.3.tgz",
|
||||
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -1893,6 +1911,18 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "17.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/marked/-/marked-17.0.5.tgz",
|
||||
"integrity": "sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.1",
|
||||
"dompurify": "^3.3.3",
|
||||
"element-plus": "^2.11.7",
|
||||
"marked": "^17.0.5",
|
||||
"pinia": "^2.3.1",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.6.3"
|
||||
|
||||
BIN
client/public/favicon.ico
Normal file
BIN
client/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
client/public/favicon.png
Normal file
BIN
client/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.8 KiB |
40
client/public/favicon.svg
Normal file
40
client/public/favicon.svg
Normal file
@@ -0,0 +1,40 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="tile" x1="16" y1="18" x2="48" y2="50" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E8FBFF"/>
|
||||
<stop offset="1" stop-color="#BCEFFA"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="needle" x1="34" y1="17" x2="47" y2="31" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFF8D9"/>
|
||||
<stop offset="1" stop-color="#F4FCFF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="15" y="17" width="14" height="12" rx="4" fill="url(#tile)" stroke="#0A8FB5" stroke-width="2"/>
|
||||
<rect x="15" y="33" width="14" height="16" rx="4" fill="url(#tile)" stroke="#0A8FB5" stroke-width="2"/>
|
||||
<rect x="33" y="35" width="15" height="14" rx="4" fill="url(#tile)" fill-opacity="0.92" stroke="#0A8FB5" stroke-width="2"/>
|
||||
</g>
|
||||
|
||||
<path d="M35.2 17.4L49.2 22.4L38.2 33.3L35.6 27.2L29.6 24.6L35.2 17.4Z" fill="url(#needle)"/>
|
||||
<path d="M35.2 17.4L40.3 27.1L49.2 22.4" stroke="#74DFF2" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
|
||||
<path d="M18.5 22.2H25.5" stroke="#0C7D9E" stroke-opacity="0.72" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M18.5 37.8H25.5" stroke="#0C7D9E" stroke-opacity="0.72" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M18.5 42.8H24" stroke="#0C7D9E" stroke-opacity="0.72" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M36.5 40.8H44.5" stroke="#0C7D9E" stroke-opacity="0.64" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M36.5 45.6H42.5" stroke="#0C7D9E" stroke-opacity="0.64" stroke-width="2" stroke-linecap="round"/>
|
||||
|
||||
<defs>
|
||||
<filter id="shadow" x="11" y="14" width="41" height="39" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="1.5"/>
|
||||
<feGaussianBlur stdDeviation="1.5"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.016 0 0 0 0 0.282 0 0 0 0 0.38 0 0 0 0.14 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1_1"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1_1" result="shape"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
6
client/public/local-fonts.css
Normal file
6
client/public/local-fonts.css
Normal file
@@ -0,0 +1,6 @@
|
||||
:root {
|
||||
--font-sans: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
--font-display: "Avenir Next", "Trebuchet MS", "Segoe UI", "PingFang SC", sans-serif;
|
||||
--font-admin-sans: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
--font-admin-mono: "Cascadia Code", "Consolas", "SFMono-Regular", monospace;
|
||||
}
|
||||
@@ -3,18 +3,14 @@
|
||||
<header class="header-wrap" :class="{ 'is-scrolled': isScrolled }">
|
||||
<div class="container header">
|
||||
<a class="brand" href="#" aria-label="Tools工具" @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 class="brand-mark" aria-hidden="true">
|
||||
<img src="/favicon.svg" alt="" width="32" height="32" />
|
||||
</span>
|
||||
<span>资源导航</span>
|
||||
</a>
|
||||
<nav class="nav" aria-label="主导航">
|
||||
<a href="#tools">工具列表</a>
|
||||
<a href="#tools">分类浏览</a>
|
||||
<a href="#tools">工具中心</a>
|
||||
<button
|
||||
type="button"
|
||||
class="nav-btn"
|
||||
@@ -101,6 +97,7 @@
|
||||
<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="created">按创建时间排序</option>
|
||||
<option value="latest">按更新时间排序</option>
|
||||
<option value="popular">按下载量排序</option>
|
||||
<option value="rating">按评分排序</option>
|
||||
@@ -137,7 +134,7 @@
|
||||
</div>
|
||||
|
||||
<h3>{{ tool.name }}</h3>
|
||||
<p class="desc">{{ tool.description }}</p>
|
||||
<div class="desc markdown markdown-inline" v-html="renderInlineMarkdown(tool.description)"></div>
|
||||
|
||||
<div class="tags">
|
||||
<span v-for="tag in tool.tags" :key="`${tool.id}-${tag}`" class="tag">{{ tag }}</span>
|
||||
@@ -210,7 +207,7 @@
|
||||
<p class="modal-error">{{ detailError }}</p>
|
||||
</template>
|
||||
<template v-else-if="detail">
|
||||
<p>{{ detail.description }}</p>
|
||||
<div class="markdown markdown-detail" v-html="renderMarkdown(detail.description)"></div>
|
||||
<ul class="meta-list">
|
||||
<li>分类:<strong>{{ detail.category?.name || '-' }}</strong></li>
|
||||
<li>评分:<strong>{{ Number(detail.rating || 0).toFixed(1) }}</strong></li>
|
||||
@@ -224,9 +221,13 @@
|
||||
<li v-if="detail.accessMode === 'download'">
|
||||
最新版本:<strong>{{ detail.latestVersion || '暂无版本' }}</strong>
|
||||
</li>
|
||||
<li v-if="detail.accessMode === 'download'">
|
||||
<li v-if="detail.accessMode === 'download' && detail.fileSize !== null">
|
||||
文件大小:<strong>{{ formatFileSize(detail.fileSize) }}</strong>
|
||||
</li>
|
||||
<li v-if="detail.accessMode === 'download' && detail.openUrl">
|
||||
下载地址:
|
||||
<a class="inline-link" :href="detail.openUrl" target="_blank" rel="noopener noreferrer">{{ detail.openUrl }}</a>
|
||||
</li>
|
||||
<li v-if="detail.accessMode === 'web' && detail.openUrl">
|
||||
打开地址:
|
||||
<a class="inline-link" :href="detail.openUrl" target="_blank" rel="noopener noreferrer">{{ detail.openUrl }}</a>
|
||||
@@ -235,7 +236,9 @@
|
||||
</ul>
|
||||
<h3>核心能力</h3>
|
||||
<ul v-if="detail.features?.length" class="feature-list">
|
||||
<li v-for="feature in detail.features" :key="`detail-${feature}`">{{ feature }}</li>
|
||||
<li v-for="(feature, featureIndex) in detail.features" :key="`detail-${featureIndex}`">
|
||||
<div class="markdown markdown-inline" v-html="renderInlineMarkdown(feature)"></div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="modal-muted">暂无能力描述</p>
|
||||
</template>
|
||||
@@ -284,8 +287,10 @@ import {
|
||||
fetchTools,
|
||||
getApiErrorMessage,
|
||||
launchTool,
|
||||
notifyToolInteraction,
|
||||
resolveActionUrl,
|
||||
} from './api';
|
||||
import { renderInlineMarkdown, renderMarkdown } from './utils/markdown';
|
||||
|
||||
const CLIENT_VERSION = 'web-1.0.0';
|
||||
const QUERY_DEBOUNCE_MS = 320;
|
||||
@@ -293,7 +298,7 @@ const QUERY_DEBOUNCE_MS = 320;
|
||||
const filters = reactive({
|
||||
query: '',
|
||||
category: 'all',
|
||||
sortBy: 'latest',
|
||||
sortBy: 'created',
|
||||
page: 1,
|
||||
pageSize: 6,
|
||||
});
|
||||
@@ -523,7 +528,7 @@ function applyHotKeyword(keyword) {
|
||||
function resetFilters() {
|
||||
filters.query = '';
|
||||
filters.category = 'all';
|
||||
filters.sortBy = 'latest';
|
||||
filters.sortBy = 'created';
|
||||
filters.page = 1;
|
||||
clearTimeout(queryTimer);
|
||||
loadTools();
|
||||
@@ -571,7 +576,7 @@ function isLaunchDisabled(tool) {
|
||||
if (launchingId.value === tool.id) {
|
||||
return true;
|
||||
}
|
||||
return tool.accessMode === 'download' && !tool.hasArtifact;
|
||||
return tool.accessMode === 'download' && !tool.downloadReady;
|
||||
}
|
||||
|
||||
function launchButtonText(tool) {
|
||||
@@ -581,8 +586,8 @@ function launchButtonText(tool) {
|
||||
if (tool.accessMode === 'web') {
|
||||
return '打开网页';
|
||||
}
|
||||
if (!tool.hasArtifact) {
|
||||
return '暂无可下载包';
|
||||
if (!tool.downloadReady) {
|
||||
return '暂无可下载资源';
|
||||
}
|
||||
return '下载';
|
||||
}
|
||||
@@ -606,14 +611,26 @@ async function triggerLaunch(tool) {
|
||||
channel: 'official',
|
||||
clientVersion: CLIENT_VERSION,
|
||||
});
|
||||
const isWebLaunch = result?.mode === 'web';
|
||||
const isDownloadLaunch = result?.mode === 'download';
|
||||
if (isWebLaunch || isDownloadLaunch) {
|
||||
notifyToolInteraction(tool.id, {
|
||||
action: isWebLaunch ? 'open' : 'download',
|
||||
channel: 'official',
|
||||
clientVersion: CLIENT_VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
const actionUrl = resolveActionUrl(result?.actionUrl);
|
||||
|
||||
if (result?.mode === 'web') {
|
||||
if (isWebLaunch || isDownloadLaunch) {
|
||||
if (result.openIn === 'same_tab') {
|
||||
window.location.assign(actionUrl);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isWebLaunch) {
|
||||
const page = window.open(actionUrl, '_blank', 'noopener,noreferrer');
|
||||
if (!page) {
|
||||
showToast('浏览器阻止了新窗口,请允许弹窗后重试');
|
||||
@@ -626,7 +643,7 @@ async function triggerLaunch(tool) {
|
||||
window.location.assign(actionUrl);
|
||||
return;
|
||||
}
|
||||
showToast(`${tool.name} 下载任务已创建`);
|
||||
showToast(`${tool.name} 已开始下载`);
|
||||
}
|
||||
|
||||
await Promise.all([loadTools(), refreshOverview()]);
|
||||
|
||||
@@ -173,28 +173,6 @@ const activeMenu = computed(() => {
|
||||
return typeof menuKey === 'string' && menuKey ? menuKey : 'tools';
|
||||
});
|
||||
const topSearch = ref('');
|
||||
const trendRange = ref('week');
|
||||
|
||||
const trendValues = [63, 66, 55, 58, 59, 67, 66, 65, 74, 62, 65, 60, 64, 65];
|
||||
const trendMarkers = computed(() => [2, 4, 6, 8, 10, 12].map((idx) => calcTrendPoint(idx)));
|
||||
|
||||
const deviceTraffic = [
|
||||
{ name: 'Linux', value: 46, active: false },
|
||||
{ name: 'Mac', value: 72, active: false },
|
||||
{ name: 'iOS', value: 54, active: false },
|
||||
{ name: 'Windows', value: 80, active: false },
|
||||
{ name: 'Android', value: 60, active: true },
|
||||
{ name: 'Other', value: 34, active: false },
|
||||
];
|
||||
|
||||
const locationTraffic = [
|
||||
{ name: 'US', value: 52, active: false },
|
||||
{ name: 'Canada', value: 74, active: false },
|
||||
{ name: 'Mexico', value: 60, active: false },
|
||||
{ name: 'China', value: 35, active: false },
|
||||
{ name: 'Japan', value: 80, active: true },
|
||||
{ name: 'Australia', value: 45, active: false },
|
||||
];
|
||||
|
||||
const sectionTitle = computed(() => {
|
||||
const routeTitle = route.meta?.sectionTitle;
|
||||
@@ -202,46 +180,37 @@ const sectionTitle = computed(() => {
|
||||
});
|
||||
const isOverviewRoute = computed(() => route.meta?.withKpi === true);
|
||||
|
||||
const overviewSummary = computed(() => consoleStore.overview.summary || {});
|
||||
|
||||
const kpiCards = computed(() => {
|
||||
const toolTotal = consoleStore.toolPagination.total;
|
||||
const openTotal = consoleStore.tools.reduce((sum, item) => sum + Number(item.openCount || 0), 0);
|
||||
const downloadTotal = consoleStore.tools.reduce((sum, item) => sum + Number(item.downloadCount || 0), 0);
|
||||
const publishedCount = consoleStore.tools.filter((item) => item.status === 'published').length;
|
||||
const auditTotal = consoleStore.auditPagination.total;
|
||||
const summary = overviewSummary.value;
|
||||
const publishRate = formatPercent(summary.publishedTotal, summary.toolTotal);
|
||||
const downloadReadyRate = formatPercent(summary.downloadReadyToolTotal, summary.downloadToolTotal);
|
||||
|
||||
return [
|
||||
{ key: 'views', label: 'Views', value: toolTotal, delta: 11.01, theme: 'blue' },
|
||||
{ key: 'visits', label: 'Visits', value: openTotal, delta: -0.03, theme: 'dark' },
|
||||
{ key: 'new-users', label: 'New Users', value: publishedCount, delta: 15.03, theme: 'blue' },
|
||||
{ key: 'tool-total', label: '工具总数', value: summary.toolTotal, note: `已发布率 ${publishRate}`, theme: 'blue' },
|
||||
{ key: 'category-total', label: '分类总数', value: summary.categoryTotal, note: `标签 ${formatNumber(summary.tagTotal)}`, theme: 'dark' },
|
||||
{ key: 'open-total', label: '累计访问', value: summary.openTotal, note: `交互总量 ${formatNumber(summary.interactionTotal)}`, theme: 'blue' },
|
||||
{ key: 'download-total', label: '累计下载', value: summary.downloadTotal, note: `下载模式 ${formatNumber(summary.downloadToolTotal)}`, theme: 'dark' },
|
||||
{
|
||||
key: 'active-users',
|
||||
label: 'Active Users',
|
||||
value: downloadTotal + auditTotal,
|
||||
delta: 6.08,
|
||||
theme: 'dark',
|
||||
key: 'download-ready',
|
||||
label: '下载就绪工具',
|
||||
value: summary.downloadReadyToolTotal,
|
||||
note: `就绪率 ${downloadReadyRate}`,
|
||||
theme: 'blue',
|
||||
},
|
||||
{ key: 'audit-total', label: '审计日志总量', value: summary.auditLogTotal, note: `活跃版本 ${formatNumber(summary.activeArtifactTotal)}`, theme: 'dark' },
|
||||
];
|
||||
});
|
||||
|
||||
const trendPolyline = computed(() =>
|
||||
trendValues
|
||||
.map((_, idx) => {
|
||||
const point = calcTrendPoint(idx);
|
||||
return `${point.x},${point.y}`;
|
||||
})
|
||||
.join(' '),
|
||||
);
|
||||
|
||||
const currentPageProps = computed(() => {
|
||||
if (activeMenu.value === 'overview') {
|
||||
return {
|
||||
kpiCards: kpiCards.value,
|
||||
trendRange: trendRange.value,
|
||||
loadingOverview: consoleStore.overviewLoading,
|
||||
overview: consoleStore.overview,
|
||||
formatNumber,
|
||||
trendPolyline: trendPolyline.value,
|
||||
trendMarkers: trendMarkers.value,
|
||||
deviceTraffic,
|
||||
locationTraffic,
|
||||
formatDate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -282,9 +251,7 @@ const currentPageProps = computed(() => {
|
||||
|
||||
const currentPageEvents = computed(() => {
|
||||
if (activeMenu.value === 'overview') {
|
||||
return {
|
||||
'update:trend-range': updateTrendRange,
|
||||
};
|
||||
return {};
|
||||
}
|
||||
|
||||
if (activeMenu.value === 'categories') {
|
||||
@@ -394,23 +361,24 @@ const toolFormRules = {
|
||||
openUrl: [
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (toolForm.accessMode !== 'web') {
|
||||
const normalized = String(value || '').trim();
|
||||
if (toolForm.accessMode !== 'web' && !normalized) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
if (!value || !String(value).trim()) {
|
||||
if (!normalized) {
|
||||
callback(new Error('网页模式必须填写 Open URL'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(String(value).trim());
|
||||
const parsed = new URL(normalized);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
callback(new Error('Open URL 必须是 http/https 地址'));
|
||||
callback(new Error('地址必须是 http/https 链接'));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
} catch {
|
||||
callback(new Error('Open URL 格式不正确'));
|
||||
callback(new Error('地址格式不正确'));
|
||||
}
|
||||
},
|
||||
trigger: 'blur',
|
||||
@@ -501,28 +469,20 @@ const modeDialog = reactive({
|
||||
|
||||
const currentToken = computed(() => authStore.accessToken);
|
||||
|
||||
function calcTrendPoint(index) {
|
||||
const width = 760;
|
||||
const height = 220;
|
||||
const xPadding = 16;
|
||||
const yPadding = 18;
|
||||
const min = 50;
|
||||
const max = 80;
|
||||
const x = xPadding + (index * (width - xPadding * 2)) / (trendValues.length - 1);
|
||||
const normalized = (trendValues[index] - min) / (max - min);
|
||||
const y = height - yPadding - normalized * (height - yPadding * 2);
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
function updateTrendRange(nextRange) {
|
||||
trendRange.value = nextRange;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
const numeric = Number(value);
|
||||
return new Intl.NumberFormat('zh-CN').format(Number.isFinite(numeric) ? numeric : 0);
|
||||
}
|
||||
|
||||
function formatPercent(value, total) {
|
||||
const numerator = Number(value || 0);
|
||||
const denominator = Number(total || 0);
|
||||
if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator <= 0) {
|
||||
return '0.0%';
|
||||
}
|
||||
return `${((numerator / denominator) * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatDate(dateText) {
|
||||
if (!dateText) {
|
||||
return '-';
|
||||
@@ -635,6 +595,15 @@ function normalizeTagName(value) {
|
||||
return String(value || '').trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function isValidHttpUrl(value) {
|
||||
try {
|
||||
const parsed = new URL(String(value || '').trim());
|
||||
return ['http:', 'https:'].includes(parsed.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function splitTagSelections(values) {
|
||||
const selected = Array.isArray(values) ? values : [];
|
||||
const sourceTags = Array.isArray(consoleStore.tags) ? consoleStore.tags : [];
|
||||
@@ -734,6 +703,7 @@ async function runWithAuth(fn) {
|
||||
async function initializeAdminData() {
|
||||
try {
|
||||
await Promise.all([
|
||||
runWithAuth((token) => consoleStore.loadOverview(token)),
|
||||
runWithAuth((token) => consoleStore.loadCategories(token)),
|
||||
runWithAuth((token) => consoleStore.loadTags(token)),
|
||||
runWithAuth((token) => consoleStore.loadTools(token)),
|
||||
@@ -806,6 +776,10 @@ function openAuditSection() {
|
||||
}
|
||||
|
||||
async function refreshCurrentSection() {
|
||||
if (activeMenu.value === 'overview') {
|
||||
await loadOverview();
|
||||
return;
|
||||
}
|
||||
if (activeMenu.value === 'tools') {
|
||||
await loadTools();
|
||||
return;
|
||||
@@ -818,7 +792,7 @@ async function refreshCurrentSection() {
|
||||
await loadAuditLogs();
|
||||
return;
|
||||
}
|
||||
await Promise.all([loadCategories(), loadTools(), loadAuditLogs()]);
|
||||
await Promise.all([loadOverview(), loadCategories(), loadTools(), loadAuditLogs()]);
|
||||
}
|
||||
|
||||
function applyTopSearch() {
|
||||
@@ -889,6 +863,20 @@ async function loadCategories() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOverview() {
|
||||
try {
|
||||
await runWithAuth((token) => consoleStore.loadOverview(token));
|
||||
} catch (error) {
|
||||
if (isUnauthorized(error)) {
|
||||
await authStore.logout();
|
||||
consoleStore.$reset();
|
||||
ElMessage.error('登录已过期,请重新登录');
|
||||
return;
|
||||
}
|
||||
ElMessage.error(getApiErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTools() {
|
||||
try {
|
||||
await runWithAuth((token) => consoleStore.loadTools(token));
|
||||
@@ -1004,6 +992,7 @@ function openEditToolDialog(row) {
|
||||
}
|
||||
|
||||
function buildToolPayload(tagIds) {
|
||||
const openUrl = toolForm.openUrl.trim();
|
||||
const payload = {
|
||||
name: toolForm.name.trim(),
|
||||
categoryId: toolForm.categoryId,
|
||||
@@ -1014,14 +1003,9 @@ function buildToolPayload(tagIds) {
|
||||
accessMode: toolForm.accessMode,
|
||||
openInNewTab: toolForm.openInNewTab,
|
||||
status: toolForm.status,
|
||||
openUrl: openUrl || null,
|
||||
};
|
||||
|
||||
if (toolForm.accessMode === 'web') {
|
||||
payload.openUrl = toolForm.openUrl.trim();
|
||||
} else if (toolDialog.mode === 'edit') {
|
||||
payload.openUrl = null;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -1237,10 +1221,15 @@ function openModeDialog(row) {
|
||||
}
|
||||
|
||||
async function submitAccessModeUpdate() {
|
||||
if (modeDialog.accessMode === 'web' && !modeDialog.openUrl.trim()) {
|
||||
const openUrl = modeDialog.openUrl.trim();
|
||||
if (modeDialog.accessMode === 'web' && !openUrl) {
|
||||
ElMessage.warning('网页模式必须填写 Open URL');
|
||||
return;
|
||||
}
|
||||
if (openUrl && !isValidHttpUrl(openUrl)) {
|
||||
ElMessage.warning('请输入有效的 http/https 地址');
|
||||
return;
|
||||
}
|
||||
|
||||
modeDialog.submitting = true;
|
||||
try {
|
||||
@@ -1249,7 +1238,7 @@ async function submitAccessModeUpdate() {
|
||||
modeDialog.id,
|
||||
{
|
||||
accessMode: modeDialog.accessMode,
|
||||
openUrl: modeDialog.accessMode === 'web' ? modeDialog.openUrl.trim() : undefined,
|
||||
openUrl: openUrl || null,
|
||||
openInNewTab: modeDialog.openInNewTab,
|
||||
},
|
||||
token,
|
||||
@@ -1302,6 +1291,9 @@ async function loadAuditLogs() {
|
||||
}
|
||||
|
||||
watch(activeMenu, async (nextMenu) => {
|
||||
if (nextMenu === 'overview' && !consoleStore.overview.generatedAt) {
|
||||
await loadOverview();
|
||||
}
|
||||
if (nextMenu === 'categories' && !consoleStore.categories.length) {
|
||||
await loadCategories();
|
||||
}
|
||||
@@ -1313,15 +1305,6 @@ watch(activeMenu, async (nextMenu) => {
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => toolForm.accessMode,
|
||||
(mode) => {
|
||||
if (mode === 'download') {
|
||||
toolForm.openUrl = '';
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => toolDialog.visible,
|
||||
(visible) => {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
@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;
|
||||
font-family: var(--font-admin-sans);
|
||||
color: #1d2430;
|
||||
}
|
||||
|
||||
@@ -36,14 +34,14 @@
|
||||
place-items: center;
|
||||
background: linear-gradient(135deg, #2f83ed, #379cff);
|
||||
color: #fff;
|
||||
font-family: "Fira Code", monospace;
|
||||
font-family: var(--font-admin-mono);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-login-title h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-family: "Fira Code", monospace;
|
||||
font-family: var(--font-admin-mono);
|
||||
}
|
||||
|
||||
.admin-login-title span {
|
||||
@@ -102,7 +100,7 @@
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-family: "Fira Code", monospace;
|
||||
font-family: var(--font-admin-mono);
|
||||
font-size: 24px;
|
||||
color: #566579;
|
||||
letter-spacing: 0.02em;
|
||||
@@ -168,7 +166,7 @@
|
||||
}
|
||||
|
||||
.dashboard-main.with-kpi {
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.dashboard-topbar {
|
||||
@@ -226,7 +224,7 @@
|
||||
|
||||
.kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -235,7 +233,7 @@
|
||||
border-radius: 0;
|
||||
padding: 16px 18px;
|
||||
color: #fff;
|
||||
min-height: 104px;
|
||||
min-height: 112px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.26);
|
||||
}
|
||||
@@ -256,26 +254,18 @@
|
||||
|
||||
.kpi-value {
|
||||
margin-top: 10px;
|
||||
font-size: 36px;
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
font-family: "Fira Code", monospace;
|
||||
font-family: var(--font-admin-mono);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.kpi-delta {
|
||||
.kpi-note {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
left: 18px;
|
||||
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);
|
||||
font-size: 13px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.dashboard-section {
|
||||
@@ -283,6 +273,78 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.overview-dashboard {
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.overview-block {
|
||||
border: 1px solid rgba(144, 157, 177, 0.24);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.overview-block-head h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #253244;
|
||||
}
|
||||
|
||||
.metric-list {
|
||||
list-style: none;
|
||||
margin: 12px 0 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.metric-item {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.metric-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
color: #4f5e73;
|
||||
}
|
||||
|
||||
.metric-head strong {
|
||||
color: #1f2a3a;
|
||||
font-family: var(--font-admin-mono);
|
||||
}
|
||||
|
||||
.metric-bar {
|
||||
height: 8px;
|
||||
background: rgba(145, 161, 186, 0.2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.metric-bar > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #3d8dff, #2268de);
|
||||
}
|
||||
|
||||
.overview-bottom {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.overview-loading {
|
||||
color: #5f6878;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-radius: 0;
|
||||
border: 1px solid rgba(144, 157, 177, 0.28);
|
||||
@@ -314,7 +376,7 @@
|
||||
padding: 6px 2px;
|
||||
font-size: 19px;
|
||||
cursor: pointer;
|
||||
font-family: "Fira Sans", sans-serif;
|
||||
font-family: var(--font-admin-sans);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
@@ -377,7 +439,7 @@
|
||||
margin: 0;
|
||||
font-size: 26px;
|
||||
color: #1f4fb8;
|
||||
font-family: "Fira Code", monospace;
|
||||
font-family: var(--font-admin-mono);
|
||||
}
|
||||
|
||||
.mini-panel:nth-child(2) .mini-head h3 {
|
||||
@@ -442,7 +504,7 @@
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
color: #2b3647;
|
||||
font-family: "Fira Code", monospace;
|
||||
font-family: var(--font-admin-mono);
|
||||
}
|
||||
|
||||
.data-head p {
|
||||
@@ -546,6 +608,10 @@
|
||||
.data-filters {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
@@ -575,6 +641,10 @@
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.overview-bottom {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.mini-panels {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -607,6 +677,10 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.data-filters {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,11 @@ export async function adminGetTools(params, token) {
|
||||
return unwrap(response.data);
|
||||
}
|
||||
|
||||
export async function adminGetOverview(token) {
|
||||
const response = await http.get('/admin/overview', withToken(token));
|
||||
return unwrap(response.data);
|
||||
}
|
||||
|
||||
export async function adminCreateTool(payload, token) {
|
||||
const response = await http.post('/admin/tools', payload, withToken(token));
|
||||
return unwrap(response.data);
|
||||
|
||||
@@ -14,8 +14,14 @@
|
||||
/>
|
||||
</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
|
||||
v-if="modeDialog.accessMode === 'web' || modeDialog.accessMode === 'download'"
|
||||
:label="modeDialog.accessMode === 'web' ? 'Open URL' : '下载地址'"
|
||||
>
|
||||
<el-input
|
||||
v-model="modeDialog.openUrl"
|
||||
:placeholder="modeDialog.accessMode === 'web' ? 'https://example.com' : 'https://gitlab.example.com/...' "
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="新标签页">
|
||||
<el-switch v-model="modeDialog.openInNewTab" />
|
||||
|
||||
@@ -8,9 +8,7 @@
|
||||
>
|
||||
<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>
|
||||
<span v-if="card.note" class="kpi-note">{{ card.note }}</span>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,101 +1,230 @@
|
||||
<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>
|
||||
<section class="dashboard-section overview-dashboard">
|
||||
<section class="panel overview-grid">
|
||||
<article class="overview-block">
|
||||
<header class="overview-block-head">
|
||||
<h3>工具状态分布</h3>
|
||||
</header>
|
||||
<ul class="metric-list">
|
||||
<li v-for="item in statusStats" :key="item.key" class="metric-item">
|
||||
<div class="metric-head">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ formatNumber(item.value) }}</strong>
|
||||
</div>
|
||||
<div class="metric-bar">
|
||||
<span :style="{ width: `${item.ratio}%` }"></span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<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>
|
||||
<article class="overview-block">
|
||||
<header class="overview-block-head">
|
||||
<h3>访问模式分布</h3>
|
||||
</header>
|
||||
<ul class="metric-list">
|
||||
<li v-for="item in accessModeStats" :key="item.key" class="metric-item">
|
||||
<div class="metric-head">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ formatNumber(item.value) }}</strong>
|
||||
</div>
|
||||
<div class="metric-bar">
|
||||
<span :style="{ width: `${item.ratio}%` }"></span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</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>
|
||||
<section class="panel data-panel">
|
||||
<header class="data-head">
|
||||
<div>
|
||||
<h3>近 7 天访问行为</h3>
|
||||
<p>每日打开、下载、审计日志动作统计</p>
|
||||
</div>
|
||||
</article>
|
||||
</header>
|
||||
<el-table :data="activityRows" size="small" stripe class="data-table">
|
||||
<el-table-column prop="date" label="日期" min-width="120" />
|
||||
<el-table-column label="打开次数" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ formatNumber(row.openCount) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="下载次数" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ formatNumber(row.downloadCount) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="交互总量" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ formatNumber(row.interactionTotal) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="审计日志" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ formatNumber(row.auditCount) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<section class="overview-bottom">
|
||||
<section class="panel data-panel">
|
||||
<header class="data-head">
|
||||
<div>
|
||||
<h3>分类贡献 Top 8</h3>
|
||||
<p>按交互总量排序</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</header>
|
||||
<el-table :data="topCategoriesRows" size="small" stripe class="data-table">
|
||||
<el-table-column prop="categoryName" label="分类" min-width="140" />
|
||||
<el-table-column label="工具数" min-width="90">
|
||||
<template #default="{ row }">
|
||||
{{ formatNumber(row.toolTotal) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="访问量" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ formatNumber(row.openTotal) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="下载量" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ formatNumber(row.downloadTotal) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="交互总量" min-width="110">
|
||||
<template #default="{ row }">
|
||||
{{ formatNumber(row.interactionTotal) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section class="panel data-panel">
|
||||
<header class="data-head">
|
||||
<div>
|
||||
<h3>工具价值 Top 8</h3>
|
||||
<p>按交互总量排序</p>
|
||||
</div>
|
||||
</header>
|
||||
<el-table :data="topToolsRows" size="small" stripe class="data-table">
|
||||
<el-table-column prop="name" label="工具" min-width="160" />
|
||||
<el-table-column prop="categoryName" label="分类" min-width="120" />
|
||||
<el-table-column label="模式" min-width="80">
|
||||
<template #default="{ row }">
|
||||
{{ row.accessMode === 'web' ? '网页' : '下载' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="交互总量" min-width="110">
|
||||
<template #default="{ row }">
|
||||
{{ formatNumber(row.interactionTotal) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近更新" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.updatedAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section v-if="loading" class="panel data-panel overview-loading">
|
||||
正在加载业务概览数据...
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
trendPolyline: {
|
||||
type: String,
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
overview: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
trendMarkers: {
|
||||
type: Array,
|
||||
formatNumber: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
deviceTraffic: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
locationTraffic: {
|
||||
type: Array,
|
||||
formatDate: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const trendRange = defineModel('trendRange', {
|
||||
type: String,
|
||||
default: 'week',
|
||||
const summary = computed(() => props.overview?.summary || {});
|
||||
|
||||
function toPercent(value, total) {
|
||||
const numerator = Number(value || 0);
|
||||
const denominator = Number(total || 0);
|
||||
if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const ratio = (numerator / denominator) * 100;
|
||||
return Math.max(0, Math.min(100, Number(ratio.toFixed(1))));
|
||||
}
|
||||
|
||||
const statusStats = computed(() => {
|
||||
const total = Number(summary.value.toolTotal || 0);
|
||||
return [
|
||||
{
|
||||
key: 'published',
|
||||
label: '已发布',
|
||||
value: summary.value.publishedTotal || 0,
|
||||
ratio: toPercent(summary.value.publishedTotal, total),
|
||||
},
|
||||
{
|
||||
key: 'draft',
|
||||
label: '草稿',
|
||||
value: summary.value.draftTotal || 0,
|
||||
ratio: toPercent(summary.value.draftTotal, total),
|
||||
},
|
||||
{
|
||||
key: 'archived',
|
||||
label: '已归档',
|
||||
value: summary.value.archivedTotal || 0,
|
||||
ratio: toPercent(summary.value.archivedTotal, total),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const accessModeStats = computed(() => {
|
||||
const total = Number(summary.value.toolTotal || 0);
|
||||
return [
|
||||
{
|
||||
key: 'download',
|
||||
label: '下载模式',
|
||||
value: summary.value.downloadToolTotal || 0,
|
||||
ratio: toPercent(summary.value.downloadToolTotal, total),
|
||||
},
|
||||
{
|
||||
key: 'web',
|
||||
label: '网页模式',
|
||||
value: summary.value.webToolTotal || 0,
|
||||
ratio: toPercent(summary.value.webToolTotal, total),
|
||||
},
|
||||
{
|
||||
key: 'ready',
|
||||
label: '下载就绪',
|
||||
value: summary.value.downloadReadyToolTotal || 0,
|
||||
ratio: toPercent(summary.value.downloadReadyToolTotal, summary.value.downloadToolTotal || 0),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const activityRows = computed(() =>
|
||||
Array.isArray(props.overview?.dailyActivity) ? props.overview.dailyActivity : [],
|
||||
);
|
||||
const topCategoriesRows = computed(() =>
|
||||
Array.isArray(props.overview?.topCategories) ? props.overview.topCategories : [],
|
||||
);
|
||||
const topToolsRows = computed(() =>
|
||||
Array.isArray(props.overview?.topTools) ? props.overview.topTools : [],
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<header class="data-head">
|
||||
<div>
|
||||
<h3>Tools Management</h3>
|
||||
<p>支持工具信息维护、包上传与版本管理</p>
|
||||
<p>支持工具信息维护、包上传或 GitLab 下载地址配置</p>
|
||||
</div>
|
||||
<div class="data-head-actions">
|
||||
<el-button type="primary" @click="emit('create')">新增工具</el-button>
|
||||
@@ -97,7 +97,7 @@
|
||||
<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" 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>
|
||||
|
||||
@@ -57,8 +57,9 @@
|
||||
:rows="4"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
placeholder="请描述工具用途、适用场景与优势"
|
||||
placeholder="支持 Markdown,例如:## 用途 支持 **加粗**、`代码`、[链接](https://example.com)"
|
||||
/>
|
||||
<div class="el-form-item__description">简介支持 Markdown 渲染。</div>
|
||||
</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" />
|
||||
@@ -68,8 +69,9 @@
|
||||
v-model="toolForm.featuresText"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="每行一个功能点,例如 支持离线模式 支持自动更新"
|
||||
placeholder="每行一个功能点,支持 Markdown,例如 支持 **离线模式** 支持 [自动更新](https://example.com)"
|
||||
/>
|
||||
<div class="el-form-item__description">每行作为一个功能点并按 Markdown 展示。</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="访问方式" prop="accessMode">
|
||||
<el-select v-model="toolForm.accessMode" style="width: 100%">
|
||||
@@ -81,8 +83,18 @@
|
||||
/>
|
||||
</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
|
||||
v-if="toolForm.accessMode === 'web' || toolForm.accessMode === 'download'"
|
||||
:label="toolForm.accessMode === 'web' ? 'Open URL' : '下载地址'"
|
||||
prop="openUrl"
|
||||
>
|
||||
<el-input
|
||||
v-model="toolForm.openUrl"
|
||||
:placeholder="toolForm.accessMode === 'web' ? 'https://example.com' : 'https://gitlab.example.com/...' "
|
||||
/>
|
||||
<div class="el-form-item__description">
|
||||
{{ toolForm.accessMode === 'web' ? '网页模式下用于打开页面。' : '下载模式下可直接填写 GitLab 下载地址,不上传文件也可使用。' }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="新标签页">
|
||||
<el-switch v-model="toolForm.openInNewTab" />
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
<AdminKpiRow :kpi-cards="kpiCards" :format-number="formatNumber" />
|
||||
|
||||
<AdminOverviewSection
|
||||
:trend-range="trendRange"
|
||||
:trend-polyline="trendPolyline"
|
||||
:trend-markers="trendMarkers"
|
||||
:device-traffic="deviceTraffic"
|
||||
:location-traffic="locationTraffic"
|
||||
@update:trend-range="emit('update:trend-range', $event)"
|
||||
:loading="loadingOverview"
|
||||
:overview="overview"
|
||||
:format-number="formatNumber"
|
||||
:format-date="formatDate"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -22,31 +20,21 @@ defineProps({
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
trendRange: {
|
||||
type: String,
|
||||
loadingOverview: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
overview: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
formatNumber: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
trendPolyline: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
trendMarkers: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
deviceTraffic: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
locationTraffic: {
|
||||
type: Array,
|
||||
formatDate: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:trend-range']);
|
||||
</script>
|
||||
|
||||
@@ -15,7 +15,7 @@ const routes = [
|
||||
{
|
||||
path: '/admin',
|
||||
component: AdminApp,
|
||||
redirect: '/admin/tools',
|
||||
redirect: '/admin/overview',
|
||||
children: [
|
||||
{
|
||||
path: 'overview',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
adminCreateTool,
|
||||
adminDeleteCategory,
|
||||
adminDeleteArtifact,
|
||||
adminGetOverview,
|
||||
adminDeleteTool,
|
||||
adminGetArtifacts,
|
||||
adminGetAuditLogs,
|
||||
@@ -20,6 +21,32 @@ import {
|
||||
adminUploadArtifact,
|
||||
} from '../api';
|
||||
|
||||
function createEmptyOverviewState() {
|
||||
return {
|
||||
generatedAt: '',
|
||||
summary: {
|
||||
toolTotal: 0,
|
||||
draftTotal: 0,
|
||||
publishedTotal: 0,
|
||||
archivedTotal: 0,
|
||||
categoryTotal: 0,
|
||||
tagTotal: 0,
|
||||
webToolTotal: 0,
|
||||
downloadToolTotal: 0,
|
||||
downloadReadyToolTotal: 0,
|
||||
openTotal: 0,
|
||||
downloadTotal: 0,
|
||||
interactionTotal: 0,
|
||||
artifactTotal: 0,
|
||||
activeArtifactTotal: 0,
|
||||
auditLogTotal: 0,
|
||||
},
|
||||
dailyActivity: [],
|
||||
topCategories: [],
|
||||
topTools: [],
|
||||
};
|
||||
}
|
||||
|
||||
export const useAdminConsoleStore = defineStore('admin-console', {
|
||||
state: () => ({
|
||||
categories: [],
|
||||
@@ -46,6 +73,8 @@ export const useAdminConsoleStore = defineStore('admin-console', {
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
},
|
||||
overviewLoading: false,
|
||||
overview: createEmptyOverviewState(),
|
||||
|
||||
auditFilters: {
|
||||
action: '',
|
||||
@@ -150,6 +179,28 @@ export const useAdminConsoleStore = defineStore('admin-console', {
|
||||
this.toolLoading = false;
|
||||
}
|
||||
},
|
||||
async loadOverview(token) {
|
||||
this.overviewLoading = true;
|
||||
try {
|
||||
const data = await adminGetOverview(token);
|
||||
const defaults = createEmptyOverviewState();
|
||||
const payload = data && typeof data === 'object' ? data : {};
|
||||
|
||||
this.overview = {
|
||||
...defaults,
|
||||
...payload,
|
||||
summary: {
|
||||
...defaults.summary,
|
||||
...(payload.summary && typeof payload.summary === 'object' ? payload.summary : {}),
|
||||
},
|
||||
dailyActivity: Array.isArray(payload.dailyActivity) ? payload.dailyActivity : [],
|
||||
topCategories: Array.isArray(payload.topCategories) ? payload.topCategories : [],
|
||||
topTools: Array.isArray(payload.topTools) ? payload.topTools : [],
|
||||
};
|
||||
} finally {
|
||||
this.overviewLoading = false;
|
||||
}
|
||||
},
|
||||
async updateToolStatus(id, status, token) {
|
||||
await adminUpdateToolStatus(id, status, token);
|
||||
},
|
||||
|
||||
@@ -64,6 +64,53 @@ export function resolveActionUrl(actionUrl) {
|
||||
return new URL(actionUrl, apiOrigin).toString();
|
||||
}
|
||||
|
||||
function resolveApiUrl(path) {
|
||||
if (/^https?:\/\//.test(path)) {
|
||||
return path;
|
||||
}
|
||||
return new URL(path, apiOrigin).toString();
|
||||
}
|
||||
|
||||
export function notifyToolInteraction(toolId, payload) {
|
||||
const pathBase = baseURL.replace(/\/$/, '');
|
||||
const endpoint = `${pathBase}/tools/${toolId}/interaction`;
|
||||
const url = resolveApiUrl(endpoint);
|
||||
const body = JSON.stringify(payload || {});
|
||||
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
|
||||
const queued = navigator.sendBeacon(
|
||||
url,
|
||||
new Blob([body], { type: 'application/json' }),
|
||||
);
|
||||
if (queued) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore tracking errors to avoid interrupting user flow.
|
||||
}
|
||||
|
||||
if (typeof fetch === 'function') {
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body,
|
||||
keepalive: true,
|
||||
credentials: 'same-origin',
|
||||
}).catch(() => {
|
||||
// Ignore tracking errors to avoid interrupting user flow.
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
http.post(`/tools/${toolId}/interaction`, payload).catch(() => {
|
||||
// Ignore tracking errors to avoid interrupting user flow.
|
||||
});
|
||||
}
|
||||
|
||||
export function getApiErrorMessage(error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const data = error.response?.data;
|
||||
|
||||
@@ -35,7 +35,7 @@ body {
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: "Manrope", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ select {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-family: "Sora", sans-serif;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
@@ -108,11 +108,13 @@ select {
|
||||
.brand-mark {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.brand-mark img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nav {
|
||||
@@ -173,7 +175,7 @@ h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-family: "Sora", sans-serif;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.search-row {
|
||||
@@ -408,11 +410,9 @@ h3 {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 280px;
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
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),
|
||||
@@ -486,6 +486,66 @@ h3 {
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.card .desc.markdown-inline {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.markdown {
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.markdown :where(h1, h2, h3, h4, h5, h6, p, ul, ol, blockquote, pre) {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.markdown :where(h1, h2, h3, h4, h5, h6) {
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.markdown :where(p, li) {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.markdown :where(ul, ol) {
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.markdown :where(p, ul, ol, blockquote, pre):last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown a {
|
||||
color: var(--primary-strong);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.markdown code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
background: rgba(15, 47, 61, 0.08);
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.markdown pre {
|
||||
background: rgba(15, 47, 61, 0.08);
|
||||
border: 1px solid rgba(18, 117, 150, 0.16);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.markdown pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -650,6 +710,10 @@ h3 {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.markdown-detail {
|
||||
margin: 10px 0 12px;
|
||||
}
|
||||
|
||||
.modal-muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
@@ -679,18 +743,31 @@ h3 {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.feature-list li {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--duration-fast) var(--ease-standard),
|
||||
transform var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.icon-btn svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: rgba(240, 251, 255, 0.95);
|
||||
border-color: var(--line-strong);
|
||||
@@ -778,6 +855,15 @@ select:focus-visible {
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.card {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
animation: card-enter 380ms var(--ease-standard) both;
|
||||
animation-delay: var(--stagger, 0ms);
|
||||
}
|
||||
}
|
||||
|
||||
.btn:active,
|
||||
.btn-small:active,
|
||||
.icon-btn:active,
|
||||
|
||||
72
client/src/utils/markdown.js
Normal file
72
client/src/utils/markdown.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
import { marked } from 'marked';
|
||||
|
||||
marked.setOptions({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
});
|
||||
|
||||
const markdownCache = new Map();
|
||||
const inlineCache = new Map();
|
||||
|
||||
function sanitizeHtml(html) {
|
||||
return DOMPurify.sanitize(html, {
|
||||
USE_PROFILES: { html: true },
|
||||
});
|
||||
}
|
||||
|
||||
function toSafeString(value) {
|
||||
if (typeof value === 'string') {
|
||||
return value.trim();
|
||||
}
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function parseMarkdown(source) {
|
||||
const parsed = marked.parse(source);
|
||||
if (typeof parsed === 'string') {
|
||||
return parsed;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function parseInlineMarkdown(source) {
|
||||
const parsed = marked.parseInline(source);
|
||||
if (typeof parsed === 'string') {
|
||||
return parsed;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function renderMarkdown(value) {
|
||||
const source = toSafeString(value);
|
||||
if (!source) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (markdownCache.has(source)) {
|
||||
return markdownCache.get(source);
|
||||
}
|
||||
|
||||
const html = sanitizeHtml(parseMarkdown(source));
|
||||
markdownCache.set(source, html);
|
||||
return html;
|
||||
}
|
||||
|
||||
export function renderInlineMarkdown(value) {
|
||||
const source = toSafeString(value);
|
||||
if (!source) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (inlineCache.has(source)) {
|
||||
return inlineCache.get(source);
|
||||
}
|
||||
|
||||
const html = sanitizeHtml(parseInlineMarkdown(source));
|
||||
inlineCache.set(source, html);
|
||||
return html;
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
const proxyTarget = env.VITE_API_PROXY_TARGET || 'http://localhost:3000';
|
||||
|
||||
return {
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: proxyTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user