This commit is contained in:
dlandy
2026-04-08 17:56:12 +08:00
parent 5a6328561f
commit e6c2d76238
41 changed files with 1361 additions and 335 deletions

View File

@@ -0,0 +1,2 @@
VITE_API_BASE=/api/v1
VITE_API_PROXY_TARGET=http://localhost:3000

View File

@@ -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>

View File

@@ -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",

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

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
View 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

View 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;
}

View File

@@ -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()]);

View File

@@ -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) => {

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -57,8 +57,9 @@
:rows="4"
maxlength="2000"
show-word-limit
placeholder="请描述工具用途、适用场景与优势"
placeholder="支持 Markdown例如## 用途&#10;支持 **加粗**、`代码`、[链接](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="每行一个功能点,例如&#10;支持离线模式&#10;支持自动更新"
placeholder="每行一个功能点,支持 Markdown例如&#10;支持 **离线模式**&#10;支持 [自动更新](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" />

View File

@@ -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>

View File

@@ -15,7 +15,7 @@ const routes = [
{
path: '/admin',
component: AdminApp,
redirect: '/admin/tools',
redirect: '/admin/overview',
children: [
{
path: 'overview',

View File

@@ -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);
},

View File

@@ -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;

View File

@@ -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,

View 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;
}

View File

@@ -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,
},
},
},
},
});
};
});