feat: Verdaccio 缓存可视化管理面板(前端 + 后端 + Docker)

- 前端 Vue3+Vite+Element Plus 按需引入,双主题(bright/dark)CSS 变量
- 三端响应式(桌面/平板/手机),登录/概览/检索/阈值/忽略/动态 6 页面
- 后端 Express + better-sqlite3,目录扫描 + demo 模式,清理原子写入
- Dockerfile 多阶段构建 + docker-compose(storage 只读挂载)
This commit is contained in:
陈银军
2026-09-19 15:00:04 +08:00
parent 338c1a0a95
commit 0a9ca8894a
43 changed files with 7036 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Verdaccio Manager · 你的缓存尽在掌握</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"element-plus": "^2.14.6",
"vue": "^3.5.42",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.8",
"unplugin-auto-import": "^21.1.0",
"unplugin-vue-components": "^32.1.0",
"vite": "^8.3.0"
}
}
+1136
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#FFB347"/>
<stop offset="1" stop-color="#FF8C00"/>
</linearGradient>
</defs>
<rect width="100" height="100" rx="26" fill="url(#g)"/>
<text x="50" y="68" font-size="52" text-anchor="middle">📦</text>
</svg>

After

Width:  |  Height:  |  Size: 387 B

+89
View File
@@ -0,0 +1,89 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import AppTopbar from './components/AppTopbar.vue'
import MobileTabbar from './components/MobileTabbar.vue'
import SettingDrawer from './components/SettingDrawer.vue'
import { useStore } from './store'
const route = useRoute()
const { refreshAll } = useStore()
const settingsOpen = ref(false)
const isLogin = () => route.path === '/login'
const isMobile = () => window.matchMedia('(max-width: 767px)').matches
onMounted(() => {
if (!isLogin()) refreshAll()
})
</script>
<template>
<div class="ambient"></div>
<div v-if="!isLogin()">
<!-- 桌面/平板顶栏 -->
<AppTopbar class="desktop-topbar" @open-settings="settingsOpen = true" />
<!-- 移动端顶栏:☰ + 品牌 -->
<header class="m-topbar">
<div class="m-brand">
<div class="m-logo">📦</div>
<div><b>Verdaccio</b>&nbsp;<span>Manager</span></div>
</div>
<div class="m-menu" @click="settingsOpen = true">☰</div>
</header>
</div>
<router-view v-slot="{ Component }">
<transition name="fade-slide" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
<MobileTabbar v-if="!isLogin() && isMobile()" />
<SettingDrawer v-model="settingsOpen" />
</template>
<style scoped>
.desktop-topbar { display: block; }
.m-topbar { display: none; }
@media (max-width: 767px) {
.desktop-topbar { display: none; }
.m-topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px 10px;
background: var(--card);
border-bottom: 1px solid var(--line);
position: sticky;
top: 0;
z-index: 100;
}
.m-brand { display: flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 700; }
.m-logo {
width: 30px;
height: 30px;
border-radius: 10px;
display: grid;
place-items: center;
font-size: 15px;
background: var(--grad-main);
}
.m-brand b { color: var(--orange); }
.m-brand span { color: var(--ink-faint); font-weight: 600; font-size: 12px; }
.m-menu {
width: 34px;
height: 34px;
border-radius: 11px;
display: grid;
place-items: center;
background: var(--card-2);
border: 1px solid var(--line);
font-size: 16px;
color: var(--ink-dim);
cursor: pointer;
}
}
</style>
+211
View File
@@ -0,0 +1,211 @@
/**
* API 封装 —— 对接后端 §4.2 REST API
* 开发期默认走 mock(VITE_USE_MOCK=false 时切真实接口)
*/
import { reactive } from 'vue'
import * as mock from '../mock/data.js'
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
// —— mock 内存态(模拟服务端持久化) ——
const mockState = reactive({
versionThreshold: 15,
sizeThreshold: 500,
exceptions: [
{ pkgName: 'tensorflow', versionThreshold: 30, sizeThreshold: 2000 },
],
ignored: new Set(['moment', '@vue/compiler-sfc', 'mocha', 'cheerio']),
packages: [...mock.packages],
logs: [...mock.logs],
})
function delay(ms = 180) {
return new Promise(r => setTimeout(r, ms))
}
function clone(v) {
return JSON.parse(JSON.stringify(v))
}
// 包排序:预警置顶 → 正常 → 忽略;同状态按版本数降序
function sortPackages(list) {
const rank = { warn: 0, ok: 1, ignored: 2, broken: 3 }
return [...list].sort((a, b) => {
if (rank[a.status] !== rank[b.status]) return rank[a.status] - rank[b.status]
return b.versions - a.versions
})
}
function computeStatus(p, threshold) {
if (p.status === 'ignored') return 'ignored'
if (p.status === 'broken') return 'broken'
return p.versions > threshold ? 'warn' : 'ok'
}
const api = {
async getSummary() {
if (USE_MOCK) {
await delay()
return mock.summary()
}
const r = await fetch('/api/packages')
const { packages } = await r.json()
const valid = packages.filter(p => p.status !== 'broken')
const warnCount = valid.filter(p => p.status === 'warn').length
return {
pkgCount: valid.length,
totalSize: valid.reduce((s, p) => s + p.size, 0),
warnCount,
cleanable: valid.filter(p => p.status === 'warn').reduce((s, p) => s + p.size, 0),
}
},
async getPackages(keyword = '') {
if (USE_MOCK) {
await delay()
const kw = keyword.trim().toLowerCase()
let list = clone(mockState.packages)
list = list.map(p => ({ ...p, status: computeStatus(p, mockState.versionThreshold) }))
if (kw) list = list.filter(p => p.name.toLowerCase().includes(kw))
return sortPackages(list)
}
const r = await fetch(`/api/packages?keyword=${encodeURIComponent(keyword)}`)
const { packages } = await r.json()
return packages
},
async getThresholds() {
if (USE_MOCK) {
await delay(80)
return {
versionThreshold: mockState.versionThreshold,
sizeThreshold: mockState.sizeThreshold,
exceptions: clone(mockState.exceptions),
}
}
const r = await fetch('/api/thresholds')
return r.json()
},
async saveThresholds(payload) {
if (USE_MOCK) {
await delay()
mockState.versionThreshold = payload.versionThreshold
mockState.sizeThreshold = payload.sizeThreshold
mockState.exceptions = clone(payload.exceptions || [])
return { ok: true }
}
const r = await fetch('/api/thresholds', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
return r.json()
},
async getIgnored() {
if (USE_MOCK) {
await delay(80)
return [...mockState.ignored]
}
const r = await fetch('/api/ignored')
return r.json()
},
async ignore(pkgName) {
if (USE_MOCK) {
await delay(100)
mockState.ignored.add(pkgName)
const p = mockState.packages.find(x => x.name === pkgName)
if (p) { p.status = 'ignored'; p.ignoredAt = new Date().toISOString().slice(0, 10) }
return { ok: true }
}
const r = await fetch('/api/ignore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pkgName }),
})
return r.json()
},
async unignore(pkgName) {
if (USE_MOCK) {
await delay(100)
mockState.ignored.delete(pkgName)
const p = mockState.packages.find(x => x.name === pkgName)
if (p) {
p.status = computeStatus(p, mockState.versionThreshold)
delete p.ignoredAt
}
return { ok: true }
}
const r = await fetch(`/api/ignore/${encodeURIComponent(pkgName)}`, { method: 'DELETE' })
return r.json()
},
/**
* 清理:保留最新 keep 个版本
* @returns {Promise<{ok, deletedCount, freedSpace, deletedVersions}>}
*/
async clean(pkgName, keep) {
if (USE_MOCK) {
await delay(420)
const p = mockState.packages.find(x => x.name === pkgName)
if (!p) return { ok: false, message: '包不存在' }
const vers = [...p.versionsDetail].sort((a, b) => a.version.localeCompare(b.version, undefined, { numeric: true }))
const toDel = vers.slice(0, Math.max(0, vers.length - keep))
if (!toDel.length) return { ok: false, message: '无需清理' }
const freed = toDel.reduce((s, v) => s + v.size, 0)
p.versionsDetail = vers.slice(-keep)
p.versions = p.versionsDetail.length
p.size = Number(Math.max(0.1, p.size - freed).toFixed(1))
p.status = computeStatus(p, mockState.versionThreshold)
const log = {
id: Date.now(),
pkgName,
deletedVersions: toDel.map(v => v.version).join(', '),
deletedCount: toDel.length,
freedSpace: `${freed.toFixed(1)} MB`,
created_at: new Date().toISOString().slice(0, 16).replace('T', ' '),
}
mockState.logs.unshift(log)
return { ok: true, deletedCount: toDel.length, freedSpace: `${freed.toFixed(1)} MB`, deletedVersions: log.deletedVersions }
}
const r = await fetch('/api/clean', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pkgName, keep }),
})
return r.json()
},
async deleteVersion(pkgName, version) {
if (USE_MOCK) {
await delay(150)
const p = mockState.packages.find(x => x.name === pkgName)
if (!p) return { ok: false }
const idx = p.versionsDetail.findIndex(v => v.version === version)
if (idx === -1) return { ok: false }
const [v] = p.versionsDetail.splice(idx, 1)
p.versions = p.versionsDetail.length
p.size = Number(Math.max(0.1, p.size - v.size).toFixed(1))
p.status = computeStatus(p, mockState.versionThreshold)
return { ok: true, freedSpace: `${v.size.toFixed(1)} MB` }
}
const r = await fetch(`/api/packages/${encodeURIComponent(pkgName)}/versions/${encodeURIComponent(version)}`, {
method: 'DELETE',
})
return r.json()
},
async getLogs() {
if (USE_MOCK) {
await delay(80)
return clone(mockState.logs)
}
const r = await fetch('/api/logs')
return r.json()
},
}
export default api
+183
View File
@@ -0,0 +1,183 @@
<script setup>
import { useRoute, useRouter } from 'vue-router'
import { useTheme } from '../composables/useTheme'
import { useStore } from '../store'
const route = useRoute()
const router = useRouter()
const { theme, toggle } = useTheme()
const { state } = useStore()
defineEmits(['open-settings'])
const navs = [
{ path: '/', label: '概览', emo: '🏠' },
{ path: '/search', label: '检索', emo: '🔍' },
{ path: '/thresholds', label: '阈值', emo: '⚙️' },
{ path: '/logs', label: '动态', emo: '🕒' },
{ path: '/ignored', label: '忽略', emo: '🚫' },
]
function go(path) {
if (route.path !== path) router.push(path)
}
</script>
<template>
<header class="topbar">
<div class="brand" @click="go('/')">
<div class="logo-emoji">📦</div>
<div>
<div class="brand-name">Verdaccio Manager</div>
<div class="brand-sub">你的缓存 · 尽在掌握</div>
</div>
</div>
<nav class="nav">
<span
v-for="n in navs"
:key="n.path"
class="nav-item"
:class="{ active: route.path === n.path }"
@click="go(n.path)"
>
{{ n.emo }} {{ n.label }}
</span>
</nav>
<div class="topbar-right">
<button class="theme-toggle" :title="theme === 'dark' ? '切换到亮色' : '切换到暗色'" @click="toggle">
{{ theme === 'dark' ? '☀️ 切到亮色' : '🌙 切到暗色' }}
</button>
<div class="avatar" title="设置" @click="$emit('open-settings')">👤</div>
</div>
</header>
</template>
<style scoped>
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
height: 64px;
padding: 0 28px;
background: var(--card);
border-bottom: 1px solid var(--line);
position: sticky;
top: 0;
z-index: 100;
}
.brand {
display: flex;
align-items: center;
gap: 11px;
cursor: pointer;
flex-shrink: 0;
}
.logo-emoji {
width: 38px;
height: 38px;
border-radius: 13px;
display: grid;
place-items: center;
font-size: 20px;
background: var(--grad-main);
box-shadow: 0 8px 18px -6px rgba(255, 140, 0, .55);
}
.brand-name {
font-size: 16.5px;
font-weight: 700;
color: var(--orange);
letter-spacing: -.2px;
}
.brand-sub {
font-size: 11px;
color: var(--ink-faint);
margin-top: 1px;
}
.nav {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
justify-content: center;
min-width: 0;
}
.nav-item {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 9px 18px;
border-radius: 99px;
font-size: 13.5px;
font-weight: 600;
color: var(--ink-dim);
cursor: pointer;
transition: .18s;
white-space: nowrap;
}
.nav-item:hover {
background: var(--nav-hover-bg);
color: var(--orange);
}
.nav-item.active {
background: var(--grad-main);
color: #fff;
box-shadow: 0 8px 18px -6px rgba(255, 140, 0, .5);
}
.topbar-right {
display: flex;
align-items: center;
gap: 14px;
flex-shrink: 0;
}
.avatar {
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--grad-avatar);
color: #fff;
display: grid;
place-items: center;
font-size: 14px;
box-shadow: 0 6px 14px -4px rgba(30, 144, 255, .5);
cursor: pointer;
}
.theme-toggle {
display: inline-flex;
align-items: center;
gap: 7px;
border: 1.5px solid var(--line);
background: var(--toggle-bg);
color: var(--ink-dim);
font-size: 12.5px;
font-weight: 600;
padding: 8px 15px;
border-radius: 99px;
cursor: pointer;
transition: .16s;
white-space: nowrap;
}
.theme-toggle:hover {
border-color: var(--orange);
color: var(--orange);
box-shadow: 0 6px 14px -6px rgba(255, 140, 0, .4);
transform: translateY(-1px);
}
/* 平板(768~1023):压缩导航 */
@media (max-width: 1023px) {
.topbar { padding: 0 16px; gap: 12px; }
.brand-sub { display: none; }
.nav-item { padding: 8px 13px; font-size: 13px; }
.theme-toggle span { display: none; }
}
/* 手机 <768:顶栏转为 M-Topbar(☰ + 品牌) */
@media (max-width: 767px) {
.topbar {
display: none;
}
}
</style>
+231
View File
@@ -0,0 +1,231 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { useStore } from '../store'
const props = defineProps({
visible: Boolean,
pkg: { type: Object, default: null },
threshold: { type: Number, default: 15 },
})
const emit = defineEmits(['close', 'confirmed'])
const keep = ref(15)
const cleaning = ref(false)
watch(
() => props.visible,
(v) => {
if (v && props.pkg) {
keep.value = props.threshold
}
},
)
// 按 semver 数值排序:低版本在前
const toDelete = computed(() => {
if (!props.pkg?.versionsDetail?.length) return []
const sorted = [...props.pkg.versionsDetail].sort((a, b) =>
a.version.localeCompare(b.version, undefined, { numeric: true }),
)
const n = Math.max(0, sorted.length - keep.value)
return sorted.slice(0, n)
})
const freedSpace = computed(() => toDelete.value.reduce((s, v) => s + v.size, 0))
async function confirm() {
cleaning.value = true
try {
const res = await useStore().cleanPkg(props.pkg.name, keep.value)
if (res.ok) {
emit('confirmed', res)
emit('close')
} else {
ElMessage.warning(res.message || '清理失败')
}
} finally {
cleaning.value = false
}
}
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="visible" class="cd-overlay" @click.self="emit('close')"></div>
</Transition>
<Transition name="pop">
<div v-if="visible && pkg" class="cd-dialog">
<div class="cd-head">
<span class="cd-title">🧹 确认清理</span>
<div class="cd-close" @click="emit('close')">✕</div>
</div>
<div class="cd-body">
<div class="cd-pkg">
<span class="cd-pkg-name">{{ pkg.name }}</span>
<span class="cd-pkg-meta">当前 {{ pkg.versions }} 个版本 · {{ pkg.size }} MB</span>
</div>
<div class="cd-row">
<label class="cd-label">保留最新版本数</label>
<div class="th-num">
<button @click="keep = Math.max(1, keep - 1)">−</button>
<input v-model.number="keep" type="number" min="1" :max="pkg.versions" />
<button @click="keep = Math.min(pkg.versions, keep + 1)">+</button>
</div>
<span class="th-unit">将删除 {{ toDelete.length }} 个版本</span>
</div>
<div v-if="toDelete.length" class="cd-del">
<div class="cd-del-title">将删除版本({{ toDelete.length }} 个)</div>
<div class="cd-del-list">
<span v-for="v in toDelete" :key="v.version" class="cd-del-tag">{{ v.version }}</span>
</div>
</div>
<div v-else class="cd-none">当前版本数未超过保留值,无需清理</div>
</div>
<div class="cd-foot">
<span class="cd-freed">预计释放 {{ freedSpace.toFixed(1) }} MB</span>
<button class="btn unignore" @click="emit('close')">取消</button>
<button class="btn clean" :class="{ disabled: !toDelete.length }" :disabled="cleaning || !toDelete.length" @click="confirm">
{{ cleaning ? '清理中…' : `确认清理 ${toDelete.length} 个` }}
</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.cd-overlay {
position: fixed;
inset: 0;
background: rgba(60, 45, 30, .4);
backdrop-filter: blur(2px);
z-index: 140;
}
.cd-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(480px, 92vw);
max-height: 84vh;
z-index: 141;
background: var(--card);
border-radius: 22px;
box-shadow: 0 30px 80px -24px rgba(0, 0, 0, .5);
display: flex;
flex-direction: column;
overflow: hidden;
}
.cd-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 22px;
border-bottom: 1px solid var(--line);
}
.cd-title { font-size: 16px; font-weight: 700; }
.cd-close {
width: 30px;
height: 30px;
border-radius: 50%;
display: grid;
place-items: center;
cursor: pointer;
background: var(--num-bg);
color: var(--ink-dim);
font-size: 12px;
}
.cd-close:hover { background: var(--red-soft); color: var(--red); }
.cd-body { padding: 18px 22px; overflow-y: auto; }
.cd-pkg { margin-bottom: 16px; }
.cd-pkg-name {
font-size: 15px;
font-weight: 700;
word-break: break-all;
color: var(--ink);
}
.cd-pkg-meta { display: block; font-size: 11.5px; color: var(--ink-faint); margin-top: 3px; }
.cd-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.cd-label { font-size: 12.5px; color: var(--ink-dim); font-weight: 600; }
.th-num {
display: flex;
align-items: center;
gap: 8px;
background: var(--card);
border: 1.5px solid var(--line);
border-radius: 14px;
padding: 6px;
transition: .16s;
}
.th-num:focus-within { border-color: var(--orange); box-shadow: 0 0 0 3px rgba(255, 140, 0, .12); }
.th-num button {
width: 34px;
height: 34px;
border-radius: 10px;
border: none;
cursor: pointer;
font-size: 17px;
font-weight: 700;
color: var(--orange);
background: var(--orange-soft);
transition: .15s;
}
.th-num button:hover { background: var(--orange); color: #fff; }
.th-num input {
width: 72px;
border: none;
outline: none;
text-align: center;
background: transparent;
font-family: var(--num);
font-size: 20px;
font-weight: 700;
color: var(--ink);
-moz-appearance: textfield;
}
.th-num input::-webkit-outer-spin-button,
.th-num input::-webkit-inner-spin-button { -webkit-appearance: none; }
.th-unit { font-size: 12px; color: var(--red); font-family: var(--num); font-weight: 600; }
.cd-del { margin-top: 16px; }
.cd-del-title { font-size: 12px; font-weight: 700; margin-bottom: 9px; color: var(--ink-dim); }
.cd-del-list { display: flex; flex-wrap: wrap; gap: 7px; max-height: 150px; overflow-y: auto; }
.cd-del-tag {
font-family: var(--num);
font-size: 12px;
font-weight: 600;
padding: 4px 11px;
border-radius: 9px;
background: var(--red-soft);
color: var(--red);
}
.cd-none {
margin-top: 16px;
padding: 16px;
text-align: center;
color: var(--ink-faint);
font-size: 12.5px;
background: var(--bg);
border-radius: 14px;
border: 1.5px dashed var(--line);
}
.cd-foot {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 22px;
border-top: 1px solid var(--line);
background: var(--bg-deep);
}
.cd-freed { font-family: var(--num); font-size: 12.5px; color: var(--green-deep); font-weight: 700; margin-right: auto; }
.fade-enter-active, .fade-leave-active { transition: opacity .25s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.pop-enter-active { transition: transform .3s cubic-bezier(.2, .8, .25, 1), opacity .3s; }
.pop-leave-active { transition: transform .2s ease-in, opacity .2s; }
.pop-enter-from, .pop-leave-to { transform: translate(-50%, -46%) scale(.96); opacity: 0; }
</style>
+68
View File
@@ -0,0 +1,68 @@
<script setup>
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const tabs = [
{ path: '/', label: '概览', emo: '🏠' },
{ path: '/search', label: '检索', emo: '🔍' },
{ path: '/thresholds', label: '阈值', emo: '⚙️' },
{ path: '/logs', label: '动态', emo: '🕒' },
]
function go(path) {
if (route.path !== path) router.push(path)
}
</script>
<template>
<nav class="m-tabbar">
<div
v-for="t in tabs"
:key="t.path"
class="tab"
:class="{ active: route.path === t.path }"
@click="go(t.path)"
>
<span>{{ t.emo }}</span>
<i>{{ t.label }}</i>
</div>
</nav>
</template>
<style scoped>
.m-tabbar {
display: flex;
border-top: 1px solid var(--line);
background: var(--card);
padding: 6px 6px 12px;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 90;
}
.tab {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
font-size: 19px;
color: var(--ink-faint);
padding: 4px 0;
border-radius: 12px;
cursor: pointer;
transition: .15s;
}
.tab i {
font-style: normal;
font-size: 10px;
font-weight: 600;
font-family: var(--sans);
}
.tab:active { transform: scale(.94); }
.tab.active { color: var(--orange); }
.tab.active i { color: var(--orange); }
</style>
+136
View File
@@ -0,0 +1,136 @@
<script setup>
import StatusTag from './StatusTag.vue'
import { iconOf } from '../mock/data.js'
defineProps({
pkg: { type: Object, required: true },
threshold: { type: Number, default: 15 },
})
const emit = defineEmits(['open', 'clean', 'ignore', 'unignore'])
function splitName(name) {
const i = name.lastIndexOf('/')
if (i > 0) return { scope: name.slice(0, i + 1), base: name.slice(i + 1) }
return { scope: '', base: name }
}
</script>
<template>
<div
class="pkg-card"
:class="{ warn: pkg.status === 'warn', ignored: pkg.status === 'ignored', broken: pkg.status === 'broken' }"
@click="emit('open', pkg)"
>
<div class="p-top">
<div class="pkg-ico">{{ iconOf(pkg.name) }}</div>
<div style="flex: 1; min-width: 0">
<div class="pkg-name">
<span class="nm">
<span v-if="splitName(pkg.name).scope" class="scope">{{ splitName(pkg.name).scope }}</span>{{ splitName(pkg.name).base }}
</span>
<StatusTag :status="pkg.status" />
</div>
<div v-if="pkg.status === 'warn'" class="warn-tip">超阈值 {{ pkg.versions - threshold }} 个版本</div>
<div v-else-if="pkg.status === 'broken'" class="warn-tip">元数据异常,不影响其他包</div>
<div v-else class="warn-tip">&nbsp;</div>
</div>
</div>
<div class="p-meta">
<div class="m-item">
<div class="m-v">{{ pkg.versions }}</div>
<div class="m-l">版本数</div>
</div>
<div class="m-item">
<div class="m-v">{{ pkg.size }} MB</div>
<div class="m-l">占用空间</div>
</div>
</div>
<div class="p-ops" @click.stop>
<template v-if="pkg.status === 'warn'">
<button class="btn clean" @click="emit('clean', pkg)">✨ 清理</button>
<button class="btn ignore" @click="emit('ignore', pkg)">💤 忽略</button>
</template>
<template v-else-if="pkg.status === 'ignored'">
<button class="btn unignore" @click="emit('unignore', pkg)">↩️ 取消忽略</button>
</template>
<template v-else-if="pkg.status === 'broken'">
<button class="btn unignore" @click="emit('open', pkg)">🔍 查看</button>
</template>
<template v-else>
<button class="btn ignore" @click="emit('ignore', pkg)">💤 忽略</button>
</template>
</div>
</div>
</template>
<style scoped>
.pkg-card {
position: relative;
background: var(--card);
border: 1.5px solid var(--line);
border-radius: var(--r-lg);
padding: 18px 20px;
box-shadow: var(--shadow);
transition: .2s;
animation: fadeUp .5s both;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 168px;
cursor: pointer;
}
.pkg-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-hover); }
.pkg-card.warn {
border-color: color-mix(in srgb, var(--red) 50%, transparent);
animation: fadeUp .5s both, breathe 2.6s ease-in-out infinite;
}
.pkg-card.ignored { background: var(--ignored-card); opacity: .82; }
.pkg-card.broken { border-style: dashed; }
.p-top { display: flex; align-items: center; gap: 12px; }
.pkg-ico {
width: 44px;
height: 44px;
border-radius: 14px;
display: grid;
place-items: center;
font-size: 20px;
background: var(--grad-pkg-ico);
box-shadow: inset 0 0 0 1.5px rgba(255, 140, 0, .18);
flex-shrink: 0;
}
.pkg-name {
font-size: 15px;
font-weight: 700;
letter-spacing: -.2px;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.pkg-name .nm {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.pkg-name .scope { color: var(--ink-faint); font-weight: 500; }
.warn-tip {
font-size: 11.5px;
color: var(--ink-faint);
margin-top: 3px;
min-height: 15px;
}
.pkg-card.warn .warn-tip { color: var(--red); font-weight: 600; }
.p-meta {
display: flex;
gap: 18px;
margin: 14px 0 15px;
padding: 11px 0;
border-top: 1px dashed var(--line);
border-bottom: 1px dashed var(--line);
}
.m-item .m-v { font-family: var(--num); font-size: 17px; font-weight: 700; }
.m-item .m-l { font-size: 11px; color: var(--ink-faint); margin-top: 2px; }
.pkg-card.warn .m-item .m-v { color: var(--red); }
.p-ops { margin-top: auto; display: flex; align-items: center; gap: 9px; }
</style>
+291
View File
@@ -0,0 +1,291 @@
<script setup>
import StatusTag from './StatusTag.vue'
import { iconOf } from '../mock/data.js'
const props = defineProps({
visible: Boolean,
pkg: { type: Object, default: null },
threshold: { type: Number, default: 15 },
})
const emit = defineEmits(['close', 'clean', 'ignore', 'unignore', 'delete-version'])
function splitName(name) {
if (!name) return { scope: '', base: '' }
const i = name.lastIndexOf('/')
if (i > 0) return { scope: name.slice(0, i + 1), base: name.slice(i + 1) }
return { scope: '', base: name }
}
function fmtDate(d) {
return d || '—'
}
function delVersion(v) {
emit('delete-version', props.pkg, v)
}
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="visible && pkg" class="drawer-overlay" @click.self="emit('close')"></div>
</Transition>
<Transition :name="isMobile ? 'drawer-up' : 'drawer-r'">
<aside v-if="visible && pkg" class="drawer">
<div class="drawer-head">
<div class="drawer-title">
<div class="pkg-ico">{{ iconOf(pkg.name) }}</div>
<div>
<div style="display:flex;align-items:center;gap:9px;flex-wrap:wrap">
<span style="font-size:17px;font-weight:700">
<span v-if="splitName(pkg.name).scope" class="scope">{{ splitName(pkg.name).scope }}</span>{{ splitName(pkg.name).base }}
</span>
<StatusTag :status="pkg.status" />
</div>
<div style="font-size:11.5px;color:var(--ink-faint);margin-top:2px">{{ pkg.name }}</div>
</div>
</div>
<div class="drawer-close" @click="emit('close')">✕</div>
</div>
<div class="drawer-body">
<div class="d-kv">
<div class="kv">
<div class="k">版本数</div>
<div class="v">{{ pkg.versions }}</div>
</div>
<div class="kv">
<div class="k">占用空间</div>
<div class="v">{{ pkg.size }} MB</div>
</div>
<div class="kv">
<div class="k">最近更新</div>
<div class="v">{{ fmtDate(pkg.lastUpdated) }}</div>
</div>
<div class="kv">
<div class="k">预警阈值</div>
<div class="v" :class="{ warn: pkg.versions > threshold }">{{ threshold }} 版</div>
</div>
</div>
<div class="d-sub">
版本列表
<span class="cnt">{{ pkg.versionsDetail?.length || pkg.versions }} 个版本</span>
<span v-if="pkg.status === 'warn'" class="excess">超阈值 {{ pkg.versions - threshold }} 个</span>
</div>
<div class="ver-list">
<div v-if="!pkg.versionsDetail?.length" class="empty-tip">
{{ pkg.status === 'broken' ? '元数据异常,无法读取版本列表' : '暂无版本信息' }}
</div>
<div v-for="v in pkg.versionsDetail" :key="v.version" class="ver-row">
<span class="ver-no">{{ v.version }}</span>
<span class="ver-size">{{ v.size }} MB</span>
<span class="ver-date">{{ fmtDate(v.date) }}</span>
<button class="del-btn" title="删除此版本" @click="delVersion(v)">🗑</button>
</div>
</div>
</div>
<div class="drawer-foot">
<template v-if="pkg.status === 'warn'">
<button class="btn clean block" @click="emit('clean', pkg)">✨ 一键清理</button>
<button class="btn ignore block" @click="emit('ignore', pkg)">💤 忽略</button>
</template>
<template v-else-if="pkg.status === 'ignored'">
<button class="btn unignore block" @click="emit('unignore', pkg)">↩️ 取消忽略</button>
</template>
<template v-else>
<button class="btn ignore block" @click="emit('ignore', pkg)">💤 忽略</button>
</template>
</div>
</aside>
</Transition>
</Teleport>
</template>
<script>
// 非响应式:抽屉打开时根据当前视口决定滑入方向即可
export default {
computed: {
isMobile() {
return window.matchMedia('(max-width: 767px)').matches
},
},
}
</script>
<style scoped>
.drawer-overlay {
position: fixed;
inset: 0;
background: rgba(60, 45, 30, .32);
backdrop-filter: blur(2px);
z-index: 120;
}
.drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 600px;
z-index: 121;
background: var(--card);
box-shadow: -30px 0 60px -24px rgba(120, 70, 20, .35);
display: flex;
flex-direction: column;
}
.drawer-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px 16px;
border-bottom: 1px solid var(--line);
}
.drawer-title { display: flex; align-items: center; gap: 12px; }
.drawer-title .scope { color: var(--ink-faint); font-weight: 500; }
.pkg-ico {
width: 42px;
height: 42px;
border-radius: 13px;
display: grid;
place-items: center;
font-size: 19px;
background: var(--grad-pkg-ico);
flex-shrink: 0;
}
.drawer-close {
width: 32px;
height: 32px;
border-radius: 50%;
display: grid;
place-items: center;
cursor: pointer;
background: var(--num-bg);
color: var(--ink-dim);
transition: .15s;
}
.drawer-close:hover { background: var(--red-soft); color: var(--red); }
.drawer-body {
padding: 20px 24px;
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.d-kv {
display: flex;
gap: 26px;
flex-wrap: wrap;
margin-bottom: 18px;
padding: 14px 16px;
background: var(--bg);
border-radius: 14px;
border: 1.5px dashed color-mix(in srgb, var(--orange) 45%, transparent);
}
.d-kv .kv .k { font-size: 11px; color: var(--ink-faint); font-weight: 600; }
.d-kv .kv .v { font-family: var(--num); font-size: 15px; font-weight: 700; margin-top: 3px; }
.d-kv .kv .v.warn { color: var(--red); }
.d-sub {
font-size: 13px;
font-weight: 700;
margin: 2px 0 12px;
display: flex;
align-items: center;
gap: 8px;
}
.d-sub .cnt {
font-family: var(--num);
font-size: 11px;
font-weight: 600;
color: var(--blue);
background: var(--blue-soft);
padding: 3px 10px;
border-radius: 99px;
}
.d-sub .excess {
font-family: var(--num);
font-size: 11px;
font-weight: 600;
color: var(--red);
background: var(--red-soft);
padding: 3px 10px;
border-radius: 99px;
}
.ver-list { flex: 1; }
.empty-tip {
padding: 30px 0;
text-align: center;
color: var(--ink-faint);
font-size: 12.5px;
}
.ver-row {
display: flex;
align-items: center;
gap: 14px;
padding: 11px 14px;
border: 1px solid var(--line);
border-radius: 14px;
margin-bottom: 9px;
background: var(--card);
transition: .15s;
}
.ver-row:hover { border-color: color-mix(in srgb, var(--orange) 60%, transparent); box-shadow: 0 6px 16px -8px rgba(255, 140, 0, .3); }
.ver-no {
font-family: var(--num);
font-weight: 700;
font-size: 14.5px;
color: var(--ink);
min-width: 88px;
padding: 5px 12px;
border-radius: 10px;
background: var(--num-bg);
text-align: center;
}
.ver-size { font-family: var(--num); font-size: 12px; color: var(--ink-dim); }
.ver-date { font-family: var(--num); font-size: 12px; color: var(--ink-faint); margin-left: auto; }
.del-btn {
border: none;
cursor: pointer;
width: 30px;
height: 30px;
border-radius: 10px;
background: var(--red-soft);
color: var(--red);
font-size: 14px;
transition: .15s;
display: grid;
place-items: center;
}
.del-btn:hover { background: var(--red); color: #fff; }
.drawer-foot {
display: flex;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid var(--line);
background: var(--bg-deep);
}
.fade-enter-active, .fade-leave-active { transition: opacity .25s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.drawer-r-enter-active { transition: transform .35s cubic-bezier(.2, .8, .25, 1); }
.drawer-r-leave-active { transition: transform .25s ease-in; }
.drawer-r-enter-from, .drawer-r-leave-to { transform: translateX(100%); }
.drawer-up-enter-active { transition: transform .35s cubic-bezier(.2, .8, .25, 1); }
.drawer-up-leave-active { transition: transform .25s ease-in; }
.drawer-up-enter-from, .drawer-up-leave-to { transform: translateY(100%); }
@media (max-width: 767px) {
.drawer {
top: auto;
left: 0;
right: 0;
width: 100%;
height: 85vh;
border-radius: 22px 22px 0 0;
}
.drawer-head { padding: 16px 16px 12px; }
.drawer-body { padding: 14px 16px; }
.drawer-foot { padding: 12px 16px; }
}
</style>
+173
View File
@@ -0,0 +1,173 @@
<script setup>
import { useRouter } from 'vue-router'
import { useTheme } from '../composables/useTheme'
const props = defineProps({ modelValue: Boolean })
const emit = defineEmits(['update:modelValue'])
const router = useRouter()
const { theme, toggle } = useTheme()
const user = JSON.parse(localStorage.getItem('vcm-user') || '{"name":"管理员","email":"admin@verdaccio.local"}')
function close() {
emit('update:modelValue', false)
}
function goThresholds() {
close()
router.push('/thresholds')
}
function logout() {
localStorage.removeItem('vcm-user')
close()
router.push('/login')
}
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="modelValue" class="overlay" @click.self="close"></div>
</Transition>
<Transition name="drawer-r">
<aside v-if="modelValue" class="m-drawer">
<div class="m-drawer-head">
<span>设置</span>
<div class="m-drawer-close" @click="close">✕</div>
</div>
<div class="m-drawer-body">
<div class="m-user">
<div class="m-user-avatar">👤</div>
<div>
<b>{{ user.name }}</b>
<span>{{ user.email }}</span>
</div>
</div>
<div class="m-drawer-item" @click="toggle">
<span style="font-family: var(--sans)">🌙 主题切换</span>
<span>{{ theme === 'dark' ? '暗色 ▸' : '亮色 ▸' }}</span>
</div>
<div class="m-drawer-item" @click="goThresholds">
<span style="font-family: var(--sans)">⚙️ 预警设置</span>
<span>阈值 ▸</span>
</div>
<div class="m-drawer-item">
<span style="font-family: var(--sans)">📦 版本信息</span>
<span>v1.0.0</span>
</div>
</div>
<div class="m-drawer-foot">
<button class="m-logout" @click="logout">退出登录</button>
</div>
</aside>
</Transition>
</Teleport>
</template>
<style scoped>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, .45);
z-index: 120;
}
.m-drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: min(340px, 84vw);
z-index: 121;
display: flex;
flex-direction: column;
background: var(--card);
box-shadow: -22px 0 50px -16px rgba(0, 0, 0, .55);
}
.m-drawer-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 16px;
border-bottom: 1px solid var(--line);
font-size: 14px;
font-weight: 700;
}
.m-drawer-close {
width: 30px;
height: 30px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--card);
border: 1px solid var(--line);
color: var(--ink-dim);
font-size: 12px;
cursor: pointer;
}
.m-drawer-close:hover { background: var(--red-soft); color: var(--red); }
.m-drawer-body {
flex: 1;
overflow-y: auto;
padding: 14px;
}
.m-user {
display: flex;
align-items: center;
gap: 11px;
padding: 12px;
background: var(--card-2);
border-radius: 14px;
margin-bottom: 12px;
}
.m-user-avatar {
width: 38px;
height: 38px;
border-radius: 50%;
color: #fff;
display: grid;
place-items: center;
font-size: 16px;
background: var(--grad-avatar);
flex-shrink: 0;
}
.m-user b { font-size: 13.5px; display: block; }
.m-user span { font-size: 11px; color: var(--ink-faint); }
.m-drawer-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 13px 12px;
font-size: 13px;
font-weight: 600;
border-radius: 12px;
margin-bottom: 6px;
cursor: pointer;
transition: .15s;
}
.m-drawer-item:hover { background: var(--orange-soft); color: var(--orange); }
.m-drawer-item span { font-family: var(--num); font-size: 11px; color: var(--ink-faint); font-weight: 600; white-space: nowrap; }
.m-drawer-item span:first-child { font-family: var(--sans); font-size: 13px; color: var(--ink); font-weight: 600; }
.m-drawer-item:hover span:first-child { color: var(--orange); }
.m-drawer-foot { padding: 12px 14px; border-top: 1px solid var(--line); }
.m-logout {
width: 100%;
padding: 12px 0;
border: none;
border-radius: 14px;
cursor: pointer;
font-size: 13px;
font-weight: 700;
background: var(--red-soft);
color: var(--red);
transition: .15s;
}
.m-logout:hover { background: var(--red); color: #fff; }
.fade-enter-active, .fade-leave-active { transition: opacity .25s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.drawer-r-enter-active { transition: transform .35s cubic-bezier(.2, .8, .25, 1); }
.drawer-r-leave-active { transition: transform .25s ease-in; }
.drawer-r-enter-from, .drawer-r-leave-to { transform: translateX(100%); }
</style>
+112
View File
@@ -0,0 +1,112 @@
<script setup>
// 核心统计卡 Stat1(4 个):图标 + Fredoka 数字 + 标签;t-red 呼吸光晕
defineProps({
tone: { type: String, default: 'green' }, // green | orange | red | blue
label: { type: String, required: true },
value: { type: String, required: true },
unit: { type: String, default: '' },
ico: { type: String, default: '🗃️' },
cta: { type: String, default: '' },
})
const emit = defineEmits(['cta'])
</script>
<template>
<div class="stat1" :class="`t-${tone}`">
<div class="top">
<span class="label">{{ label }}</span>
<span class="ico">{{ ico }}</span>
</div>
<div class="value">
{{ value }}<span v-if="unit" class="unit">{{ unit }}</span>
</div>
<button v-if="cta" class="cta" @click="emit('cta')">{{ cta }}</button>
</div>
</template>
<style scoped>
.stat1 {
position: relative;
background: var(--card);
border: 1.5px solid var(--line);
border-radius: var(--r-lg);
padding: 20px 22px;
box-shadow: var(--shadow);
animation: fadeUp .5s both;
overflow: hidden;
transition: transform .2s, box-shadow .2s;
min-height: 128px;
}
.stat1:hover { transform: translateY(-3px); box-shadow: var(--shadow-hover); }
.stat1::after {
content: "";
position: absolute;
right: -34px;
top: -34px;
width: 120px;
height: 120px;
border-radius: 50%;
background: var(--tone-soft);
opacity: .7;
}
.stat1 .top { display: flex; align-items: center; justify-content: space-between; }
.stat1 .ico {
width: 42px;
height: 42px;
border-radius: 13px;
display: grid;
place-items: center;
font-size: 20px;
background: var(--tone-soft);
}
.stat1 .label { font-size: 13px; color: var(--ink-dim); font-weight: 600; }
.stat1 .value {
font-family: var(--num);
font-size: 32px;
font-weight: 700;
letter-spacing: -.5px;
color: var(--tone);
line-height: 1.05;
margin-top: 12px;
display: flex;
align-items: baseline;
gap: 6px;
}
.stat1 .value .unit { font-size: 13px; color: var(--ink-faint); font-weight: 500; }
.stat1.t-green { --tone: var(--green); --tone-soft: var(--green-soft); }
.stat1.t-orange { --tone: var(--orange); --tone-soft: var(--orange-soft); }
.stat1.t-red { --tone: var(--red); --tone-soft: var(--red-soft); }
.stat1.t-blue { --tone: var(--blue); --tone-soft: var(--blue-soft); }
.stat1.t-red {
border-color: color-mix(in srgb, var(--red) 45%, transparent);
animation: fadeUp .5s both, breathe 2.4s ease-in-out infinite;
}
.stat1 .cta {
position: absolute;
right: 20px;
bottom: 18px;
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--grad-main);
color: #fff;
font-size: 12.5px;
font-weight: 600;
padding: 8px 15px;
border-radius: 99px;
cursor: pointer;
box-shadow: 0 8px 16px -6px rgba(255, 140, 0, .5);
transition: .18s;
z-index: 2;
border: none;
font-family: var(--sans);
}
.stat1 .cta:hover { transform: translateY(-1px); box-shadow: 0 12px 22px -6px rgba(255, 140, 0, .6); }
@media (max-width: 767px) {
.stat1 { padding: 14px 16px; min-height: 0; }
.stat1 .value { font-size: 24px; margin-top: 8px; }
.stat1 .ico { width: 34px; height: 34px; font-size: 16px; }
.stat1 .cta { position: static; margin-top: 10px; font-size: 11.5px; padding: 6px 12px; }
}
</style>
+78
View File
@@ -0,0 +1,78 @@
<script setup>
// 辅助指标小卡 Stat2(5 个轻量卡)
defineProps({
tone: { type: String, default: 'blue' }, // blue | green | orange | purple | pink
label: { type: String, required: true },
ico: { type: String, default: '📈' },
main: { type: String, required: true },
unit: { type: String, default: '' },
sub: { type: String, default: '' },
link: { type: String, default: '' },
})
const emit = defineEmits(['link'])
</script>
<template>
<div class="stat2" :class="`c-${tone}`" @click="link && emit('link')">
<div class="s2-top">
<span class="ico2">{{ ico }}</span>{{ label }}
<span v-if="link" class="link" @click.stop="emit('link')">{{ link }}</span>
</div>
<div class="s2-val">
<span class="main">{{ main }}</span><span v-if="unit" class="u">{{ unit }}</span>
</div>
<div v-if="sub" class="s2-sub">{{ sub }}</div>
</div>
</template>
<style scoped>
.stat2 {
background: var(--card);
border: 1.5px solid var(--line);
border-radius: var(--r-md);
padding: 15px 17px;
box-shadow: var(--shadow);
cursor: pointer;
animation: fadeUp .5s both;
transition: .18s;
}
.stat2:hover { transform: translateY(-3px); box-shadow: var(--shadow-hover); }
.s2-top { display: flex; align-items: center; gap: 9px; font-size: 12.5px; color: var(--ink-dim); font-weight: 600; }
.ico2 { font-size: 16px; }
.s2-val {
font-family: var(--num);
font-size: 20px;
font-weight: 700;
margin-top: 9px;
color: var(--ink);
display: flex;
align-items: baseline;
gap: 5px;
min-width: 0;
}
.s2-val .main { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.s2-val .u { font-size: 11.5px; color: var(--ink-faint); font-weight: 500; }
.s2-sub { font-family: var(--num); font-size: 11px; color: var(--ink-faint); margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.link {
font-size: 11.5px;
font-weight: 600;
padding: 4px 11px;
border-radius: 99px;
margin-left: auto;
background: var(--blue-soft);
color: var(--blue);
cursor: pointer;
font-family: var(--sans);
flex-shrink: 0;
}
.stat2.c-blue { border-top: 3px solid var(--blue); }
.stat2.c-green { border-top: 3px solid var(--green); }
.stat2.c-orange { border-top: 3px solid var(--orange); }
.stat2.c-purple { border-top: 3px solid var(--purple); }
.stat2.c-pink { border-top: 3px solid var(--pink); }
.stat2.c-blue .s2-val { color: var(--blue); }
.stat2.c-green .s2-val { color: var(--green-deep); }
.stat2.c-orange .s2-val { color: var(--orange); }
.stat2.c-purple .s2-val { color: var(--purple); }
.stat2.c-pink .s2-val { color: var(--pink); }
</style>
+34
View File
@@ -0,0 +1,34 @@
<script setup>
// 状态标签:warn 预警 / ok 正常 / ignored 已忽略 / broken 异常
const props = defineProps({
status: { type: String, required: true }, // warn | ok | ignored | broken
})
const MAP = {
warn: { cls: 'warn', text: '🔥 预警' },
ok: { cls: 'ok', text: '✅ 正常' },
ignored: { cls: 'ign', text: '💤 已忽略' },
broken: { cls: 'broken', text: '⚠️ 异常' },
}
</script>
<template>
<span class="tag" :class="MAP[props.status]?.cls || 'ok'">{{ MAP[props.status]?.text }}</span>
</template>
<style scoped>
.tag {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11.5px;
font-weight: 700;
padding: 5px 12px;
border-radius: 99px;
white-space: nowrap;
}
.tag.warn { background: var(--red-soft); color: var(--red); }
.tag.ok { background: var(--green-soft); color: var(--green-deep); }
.tag.ign { background: var(--blue-soft); color: var(--blue); }
.tag.broken { background: var(--orange-soft); color: var(--orange); }
</style>
+138
View File
@@ -0,0 +1,138 @@
<script setup>
// 访问次数 Top 30 排行
defineProps({ top30: { type: Array, default: () => [] } })
</script>
<template>
<div class="top30">
<div class="top30-head">
<div class="t"><span class="ico">🏆</span>访问次数 Top 30<span class="tag30">按热度排序</span></div>
</div>
<div class="tbl-scroll">
<table class="rank-tbl">
<thead>
<tr>
<th style="width: 70px">排名</th>
<th>包名</th>
<th style="width: 110px">访问次数</th>
<th>热度</th>
</tr>
</thead>
<tbody>
<tr v-for="(p, i) in top30" :key="p.name">
<td>
<span class="rank-no" :class="i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : ''">{{ i + 1 }}</span>
</td>
<td>
<span class="rank-pkg">
<span class="mini-ico">{{ p.emoji || '📦' }}</span>
<span class="nm">
<span v-if="p.name.includes('/')" class="scope">{{ p.name.slice(0, p.name.lastIndexOf('/') + 1) }}</span>{{ p.name.split('/').pop() }}
</span>
</span>
</td>
<td><span class="rank-visit">{{ (p.visits || 0).toLocaleString() }}</span></td>
<td>
<div class="heat">
<div class="heat-bar"><i :style="{ width: p.heat + '%' }"></i></div>
<span class="heat-pct">{{ p.heat }}%</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
.top30 {
background: var(--card);
border: 1.5px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow);
overflow: hidden;
margin-bottom: 26px;
animation: fadeUp .55s .55s both;
}
.top30-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 17px 22px;
border-bottom: 1px dashed var(--line);
}
.t { display: flex; align-items: center; gap: 11px; font-size: 15px; font-weight: 700; }
.t .ico { font-size: 19px; }
.tag30 {
font-family: var(--num);
font-size: 11.5px;
font-weight: 600;
color: var(--blue);
background: var(--blue-soft);
padding: 5px 13px;
border-radius: 99px;
}
.tbl-scroll { overflow-x: auto; }
.rank-tbl { width: 100%; border-collapse: collapse; min-width: 560px; }
.rank-tbl th {
font-size: 11px;
font-weight: 600;
letter-spacing: 1px;
color: var(--ink-faint);
text-align: left;
padding: 11px 22px;
border-bottom: 1px solid var(--line);
background: var(--panel-soft);
white-space: nowrap;
}
.rank-tbl td {
padding: 11px 22px;
font-size: 13.5px;
border-bottom: 1px solid var(--line-soft);
vertical-align: middle;
}
.rank-tbl tr:last-child td { border-bottom: none; }
.rank-tbl tr { transition: background .15s; }
.rank-tbl tr:hover { background: var(--bg); }
.rank-no {
width: 30px;
height: 30px;
border-radius: 10px;
display: inline-grid;
place-items: center;
font-family: var(--num);
font-weight: 700;
font-size: 14px;
background: var(--num-bg);
color: var(--ink-dim);
}
.rank-no.gold { background: linear-gradient(135deg, #FFE082, #FFC107); color: #7A5B00; box-shadow: 0 4px 10px -3px rgba(255, 193, 7, .5); }
.rank-no.silver { background: linear-gradient(135deg, #E8E8EC, #C9C9CF); color: #5A5A62; box-shadow: 0 4px 10px -3px rgba(200, 200, 210, .5); }
.rank-no.bronze { background: linear-gradient(135deg, #F2C9A0, #D9A066); color: #6E4520; box-shadow: 0 4px 10px -3px rgba(217, 160, 102, .5); }
.rank-pkg { display: flex; align-items: center; gap: 10px; font-weight: 600; }
.mini-ico {
width: 30px;
height: 30px;
border-radius: 9px;
background: var(--orange-soft);
display: grid;
place-items: center;
font-size: 14px;
flex-shrink: 0;
}
.nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.scope { color: var(--ink-faint); font-weight: 500; }
.rank-visit { font-family: var(--num); font-weight: 700; font-size: 14.5px; color: var(--ink); }
.heat { display: flex; align-items: center; gap: 12px; min-width: 180px; }
.heat-bar { flex: 1; height: 8px; border-radius: 99px; background: var(--num-bg-2); overflow: hidden; }
.heat-bar i {
display: block;
height: 100%;
border-radius: 99px;
background: var(--grad-main);
animation: grow .9s cubic-bezier(.25, .9, .35, 1) both;
transform-origin: left;
}
.heat-pct { font-family: var(--num); font-size: 11.5px; color: var(--ink-faint); font-weight: 600; width: 34px; text-align: right; }
</style>
+17
View File
@@ -0,0 +1,17 @@
import { ref } from 'vue'
const THEME_KEY = 'vcm-theme'
const theme = ref(document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'bright')
function apply(t) {
document.documentElement.setAttribute('data-theme', t)
localStorage.setItem(THEME_KEY, t)
}
export function useTheme() {
function toggle() {
theme.value = theme.value === 'dark' ? 'bright' : 'dark'
apply(theme.value)
}
return { theme, toggle }
}
+14
View File
@@ -0,0 +1,14 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './styles/base.css'
// 主题:加载时读取 localStorage(vcm-theme),默认 bright
const saved = localStorage.getItem('vcm-theme')
if (saved === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark')
}
const app = createApp(App)
app.use(router)
app.mount('#app')
+192
View File
@@ -0,0 +1,192 @@
/**
* Mock 数据层 —— 先行跑通 UI
* 数据结构与后端 §4 对齐,之后切换 api 实现即可无缝替换
*/
// 常见包名池(含 scoped)
const PKG_POOL = [
'lodash', 'axios', 'react', 'vue', 'express', 'webpack', 'typescript', 'eslint',
'prettier', 'vite', 'rollup', 'jest', 'mocha', 'dayjs', 'moment', 'uuid',
'dotenv', 'commander', 'chalk', 'semver', 'debug', 'ws', 'zod', 'yup',
'@antv/g2', '@antv/g6', '@ant-design/icons', '@vue/compiler-sfc', '@vueuse/core',
'@babel/core', '@types/node', '@types/react', '@types/lodash', '@element-plus/icons-vue',
'@testing-library/react', 'tensorflow', 'sharp', 'puppeteer', 'cheerio', 'ioredis',
'pg', 'mysql2', 'mongo-express', 'knex', 'sequelize', 'prisma', 'graphql',
'apollo-server', 'socket.io', 'three', 'd3', 'echarts', 'chart.js', 'pdf-lib',
'xlsx', 'csv-parse', 'js-yaml', 'fast-glob', 'execa', 'nanoid', 'clsx',
]
const ICON_MAP = {
lodash: '📚', axios: '🔌', react: '⚛️', vue: '💚', express: '🚀', webpack: '🧱',
typescript: '📘', eslint: '🧹', '@antv/g2': '📊', '@antv/g6': '🕸️', tensorflow: '🧠',
sharp: '🖼️', puppeteer: '🤖', ioredis: '🔴', pg: '🐘', three: '🎮', d3: '📉',
echarts: '📈', xlsx: '📑', 'socket.io': '🔌', prisma: '💎', graphql: '🔗',
dayjs: '📅', moment: '⏰', uuid: '🆔', zod: '🛡️', vite: '⚡', rollup: '🌀',
jest: '🃏', mocha: '☕', prettier: '🎀', semver: '🔢', chalk: '🖍️',
}
const SCOPED = new Set(['@antv', '@ant-design', '@vue', '@vueuse', '@babel', '@types', '@element-plus', '@testing-library'])
function pad(n) {
return String(n).padStart(2, '0')
}
function dateStr(offsetDays) {
const d = new Date(Date.now() - offsetDays * 86400000)
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`
}
function rnd(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min
}
function fmtSize(mb) {
return mb >= 1024 ? `${(mb / 1024).toFixed(1)} GB` : `${mb.toFixed(1)} MB`
}
// 生成单个包的版本明细
function genVersions(name, count, baseVersion) {
const list = []
const [major] = baseVersion.split('.')
let curMajor = Number(major)
for (let i = 0; i < count; i++) {
// 版本号随时间自然递增:越新越靠后
const minor = rnd(0, 9)
const patch = rnd(0, 20)
const v = `${curMajor}.${minor}.${patch}`
const size = rnd(2, 40) * 0.1 + 0.5
list.push({
version: v,
size: Number(size.toFixed(1)),
date: dateStr((count - i - 1) * rnd(3, 9) + rnd(0, 2)),
})
if (i === count - 1) curMajor++
}
return list
}
function iconOf(name) {
if (ICON_MAP[name]) return ICON_MAP[name]
const base = name.split('/').pop() || name
if (ICON_MAP[base]) return ICON_MAP[base]
return '📦'
}
function genPackages() {
const threshold = 15
const list = []
const seed = [
// [name, 版本数, 单版本大小基数, 访问次数, 标记]
['lodash', 25, 4, 12456],
['@antv/g2', 32, 12, 7654],
['axios', 18, 1.2, 9832],
['react', 14, 10, 6543],
['vue', 12, 9, 5218],
['express', 16, 0.8, 4376],
['tensorflow', 24, 90, 2103],
['typescript', 20, 22, 3421],
['webpack', 19, 18, 2876],
['echarts', 15, 16, 1987],
['dayjs', 8, 0.6, 1789],
['uuid', 11, 0.4, 1654],
['vite', 9, 14, 1432],
['prisma', 7, 28, 1120],
['pg', 6, 5, 986],
['three', 10, 30, 876],
['sharp', 5, 24, 654],
['chalk', 6, 0.3, 743],
['semver', 4, 0.3, 532],
['zod', 5, 0.5, 421],
]
for (const [name, verCount, sizeBase, visits] of seed) {
list.push({
name,
versions: verCount,
size: Number((verCount * sizeBase).toFixed(1)),
lastUpdated: dateStr(rnd(1, 40)),
visits,
status: verCount > threshold ? 'warn' : 'ok',
versionsDetail: genVersions(name, verCount, `1.${rnd(0, 9)}.0`),
})
}
// 程序化补充一批普通包
const rest = PKG_POOL.filter(n => !seed.some(s => s[0] === n))
for (const name of rest) {
const verCount = rnd(1, 14)
const sizeBase = rnd(3, 80) / 10
list.push({
name,
versions: verCount,
size: Number((verCount * sizeBase).toFixed(1)),
lastUpdated: dateStr(rnd(1, 60)),
visits: rnd(80, 1800),
status: 'ok',
versionsDetail: genVersions(name, verCount, `1.${rnd(0, 9)}.0`),
})
}
// 忽略包
const ignoredNames = ['moment', '@vue/compiler-sfc', 'mocha', 'cheerio']
for (const name of ignoredNames) {
const p = list.find(x => x.name === name)
if (p) {
p.status = 'ignored'
p.ignoredAt = dateStr(rnd(3, 20))
}
}
// 单个异常包(元数据损坏演示)
const broken = list.find(x => x.name === 'prettier')
if (broken) {
broken.status = 'broken'
broken.versionsDetail = []
}
return list
}
const packages = genPackages()
// 访问排行 Top30(按 visits 降序)
const top30 = [...packages]
.filter(p => p.status !== 'broken')
.sort((a, b) => b.visits - a.visits)
.slice(0, 30)
.map(p => ({ ...p, heat: Math.round((p.visits / topMax(packages)) * 100) }))
function topMax(list) {
return Math.max(...list.map(p => p.visits), 1)
}
// 日志
const logs = [
{ id: 3, pkgName: '@antv/g2', deletedVersions: '1.0.0, 1.1.0, 1.2.0', deletedCount: 3, freedSpace: '36.5 MB', created_at: dateStr(0) + ' 09:12:04' },
{ id: 2, pkgName: 'lodash', deletedVersions: '4.0.0, 4.1.0, 4.2.0, 4.3.0', deletedCount: 4, freedSpace: '15.2 MB', created_at: dateStr(1) + ' 18:40:22' },
{ id: 1, pkgName: 'axios', deletedVersions: '0.19.0', deletedCount: 1, freedSpace: '1.1 MB', created_at: dateStr(2) + ' 11:05:47' },
]
// 状态统计(与 overview 对齐)
function summary() {
const valid = packages.filter(p => p.status !== 'broken')
const totalSize = valid.reduce((s, p) => s + p.size, 0)
const warnCount = valid.filter(p => p.status === 'warn').length
const cleanable = valid.filter(p => p.status === 'warn').reduce((s, p) => s + p.size, 0)
const maxPkg = [...valid].sort((a, b) => b.size - a.size)[0]
const oldest = [...valid].sort((a, b) => a.lastUpdated.localeCompare(b.lastUpdated))[0]
return {
pkgCount: valid.length,
totalSize,
totalSizeText: fmtSize(totalSize),
warnCount,
cleanable,
cleanableText: fmtSize(cleanable),
todayDownloads: 1234,
todayTrend: '+18%',
maxPkg,
oldestPkg: oldest,
versionDist: { over50: 12, over30: 41, overThreshold: warnCount },
}
}
export { packages, top30, logs, summary, fmtSize, iconOf }
+31
View File
@@ -0,0 +1,31 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{ path: '/login', name: 'login', component: () => import('../views/LoginView.vue'), meta: { public: true } },
{ path: '/', name: 'overview', component: () => import('../views/OverviewView.vue') },
{ path: '/search', name: 'search', component: () => import('../views/SearchView.vue') },
{ path: '/thresholds', name: 'thresholds', component: () => import('../views/ThresholdsView.vue') },
{ path: '/ignored', name: 'ignored', component: () => import('../views/IgnoredView.vue') },
{ path: '/logs', name: 'logs', component: () => import('../views/LogsView.vue') },
{ path: '/:pathMatch(.*)*', redirect: '/' },
]
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior() {
return { top: 0 }
},
})
router.beforeEach((to) => {
const logged = localStorage.getItem('vcm-user') !== null
if (!to.meta.public && !logged) {
return { name: 'login', query: { redirect: to.fullPath } }
}
if (to.name === 'login' && logged) {
return { name: 'overview' }
}
})
export default router
+122
View File
@@ -0,0 +1,122 @@
/**
* 全局数据状态(模块级 reactive 单例,多页面共享)
* - packages:包列表(已排序:预警置顶 → 正常 → 忽略)
* - thresholds:全局阈值 + 单包例外
* - logs:操作日志
*/
import { reactive, computed } from 'vue'
import api from '../api'
const state = reactive({
packages: [],
threshold: { versionThreshold: 15, sizeThreshold: 500, exceptions: [] },
logs: [],
loading: false,
storageDir: localStorage.getItem('vcm-storage-dir') || '',
})
// —— 派生统计 ——
const stats = computed(() => {
const valid = state.packages.filter(p => p.status !== 'broken')
const totalSize = valid.reduce((s, p) => s + p.size, 0)
const warnCount = valid.filter(p => p.status === 'warn').length
const cleanable = valid.filter(p => p.status === 'warn').reduce((s, p) => s + p.size, 0)
const maxPkg = [...valid].sort((a, b) => b.size - a.size)[0] || null
const oldestPkg = [...valid].sort((a, b) => (a.lastUpdated || '').localeCompare(b.lastUpdated || ''))[0] || null
const top30 = [...valid].sort((a, b) => (b.visits || 0) - (a.visits || 0)).slice(0, 30)
const maxVisit = Math.max(...top30.map(p => p.visits || 0), 1)
return {
pkgCount: valid.length,
totalSize,
totalSizeText: fmtSize(totalSize),
warnCount,
cleanable,
cleanableText: fmtSize(cleanable),
maxPkg,
oldestPkg,
top30: top30.map(p => ({ ...p, heat: Math.round(((p.visits || 0) / maxVisit) * 100) })),
}
})
function fmtSize(mb) {
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`
return `${mb.toFixed(1)} MB`
}
const fmtSizeFn = fmtSize
async function refreshPackages(keyword = '') {
state.packages = await api.getPackages(keyword)
}
async function refreshThresholds() {
const t = await api.getThresholds()
state.threshold.versionThreshold = t.versionThreshold
state.threshold.sizeThreshold = t.sizeThreshold
state.threshold.exceptions = t.exceptions || []
}
async function refreshLogs() {
state.logs = await api.getLogs()
}
async function refreshAll() {
state.loading = true
try {
await Promise.all([refreshPackages(), refreshThresholds(), refreshLogs()])
} finally {
state.loading = false
}
}
async function saveThresholds(payload) {
const ok = await api.saveThresholds(payload)
if (ok) {
await refreshThresholds()
await refreshPackages()
}
return ok
}
async function ignorePkg(name) {
await api.ignore(name)
await refreshPackages()
}
async function unignorePkg(name) {
await api.unignore(name)
await refreshPackages()
}
async function cleanPkg(name, keep) {
const res = await api.clean(name, keep)
if (res.ok) {
await Promise.all([refreshPackages(), refreshLogs()])
}
return res
}
async function deleteVersion(name, version) {
const res = await api.deleteVersion(name, version)
if (res.ok) {
await Promise.all([refreshPackages(), refreshLogs()])
}
return res
}
export function useStore() {
return {
state,
stats,
fmtSize: fmtSizeFn,
refreshAll,
refreshPackages,
refreshThresholds,
refreshLogs,
saveThresholds,
ignorePkg,
unignorePkg,
cleanPkg,
deleteVersion,
}
}
+296
View File
@@ -0,0 +1,296 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#app {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+253
View File
@@ -0,0 +1,253 @@
/* ============================================================
全局基础样式 + 通用工具类(对应设计稿 body/ambient/btn/tag/动效)
============================================================ */
@import './tokens.css';
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
background: var(--bg);
color: var(--ink);
font-family: var(--sans);
-webkit-font-smoothing: antialiased;
font-size: 14px;
line-height: 1.5;
min-height: 100vh;
}
#app {
min-height: 100vh;
}
a {
color: inherit;
text-decoration: none;
}
button {
font-family: var(--sans);
}
/* —— 暖色背景氛围(双主题切换同步覆盖) —— */
.ambient {
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background:
radial-gradient(900px 460px at 82% -8%, rgba(255, 140, 0, .10), transparent 58%),
radial-gradient(760px 400px at -4% 6%, rgba(50, 205, 50, .07), transparent 55%),
radial-gradient(700px 420px at 55% 110%, rgba(30, 144, 255, .07), transparent 60%);
}
.ambient::after {
content: "";
position: absolute;
inset: 0;
opacity: .5;
background-image: radial-gradient(rgba(140, 90, 30, .05) 1px, transparent 1px);
background-size: 22px 22px;
}
html[data-theme="dark"] .ambient {
background:
radial-gradient(900px 460px at 82% -8%, rgba(255, 160, 51, .10), transparent 58%),
radial-gradient(760px 400px at -4% 6%, rgba(61, 219, 77, .05), transparent 55%),
radial-gradient(700px 420px at 55% 110%, rgba(69, 162, 255, .06), transparent 60%);
}
html[data-theme="dark"] .ambient::after {
background-image: radial-gradient(rgba(200, 160, 120, .06) 1px, transparent 1px);
}
/* —— 页面容器 —— */
.page {
max-width: 1480px;
margin: 0 auto;
padding: 26px 28px 48px;
animation: fadeUp .3s both;
}
.page-inner {
padding: 30px 28px 40px;
max-width: 1480px;
margin: 0 auto;
animation: fadeUp .3s both;
}
.page-title {
display: flex;
align-items: center;
gap: 11px;
font-size: 19px;
font-weight: 700;
margin-bottom: 6px;
}
.page-title .emo { font-size: 22px; }
.page-sub {
font-size: 12.5px;
color: var(--ink-dim);
margin-bottom: 20px;
}
.page-sub b { color: var(--blue); font-family: var(--num); }
/* —— 按钮(设计稿 btn 体系) —— */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: none;
cursor: pointer;
font-size: 12.5px;
font-weight: 600;
padding: 8px 15px;
border-radius: 99px;
transition: .18s;
white-space: nowrap;
user-select: none;
}
.btn:active { transform: scale(.95); }
.btn.clean {
background: var(--grad-main);
color: #fff;
box-shadow: 0 8px 16px -6px rgba(255, 140, 0, .55);
}
.btn.clean:hover { transform: translateY(-1px); box-shadow: 0 12px 22px -6px rgba(255, 140, 0, .65); }
.btn.ignore { background: var(--blue-soft); color: var(--blue); }
.btn.ignore:hover { background: color-mix(in srgb, var(--blue-soft), var(--blue) 10%); }
.btn.unignore {
background: var(--num-bg);
color: var(--ink-dim);
}
.btn.unignore:hover { color: var(--ink); background: color-mix(in srgb, var(--num-bg), var(--ink) 8%); }
.btn.remove { background: var(--red-soft); color: var(--red); }
.btn.remove:hover { background: var(--red); color: #fff; }
.btn.ghost {
background: var(--card);
border: 1.5px solid var(--line);
color: var(--ink-dim);
}
.btn.ghost:hover { border-color: var(--orange); color: var(--orange); }
.btn.disabled { background: var(--num-bg); color: var(--ink-faint); cursor: not-allowed; }
.btn.block { flex: 1; justify-content: center; padding: 12px 0; }
/* —— 标签(状态标签体系) —— */
.tag {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11.5px;
font-weight: 700;
padding: 5px 12px;
border-radius: 99px;
white-space: nowrap;
}
.tag.warn { background: var(--red-soft); color: var(--red); }
.tag.ok { background: var(--green-soft); color: var(--green-deep); }
.tag.ign { background: var(--blue-soft); color: var(--blue); }
.tag.broken { background: var(--orange-soft); color: var(--orange); }
/* —— 数字字体工具 —— */
.num { font-family: var(--num); }
/* —— 表格通用(无边框风 vtable/ign-table) —— */
.vtable {
background: var(--card);
border: 1.5px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow);
overflow: hidden;
}
.vtable table { width: 100%; border-collapse: collapse; }
.vtable th {
font-size: 11px;
font-weight: 600;
letter-spacing: 1px;
color: var(--ink-faint);
text-align: left;
padding: 13px 20px;
border-bottom: 1px solid var(--line);
background: var(--panel-soft);
white-space: nowrap;
}
.vtable td {
padding: 14px 20px;
border-bottom: 1px solid var(--line-soft);
font-size: 13.5px;
vertical-align: middle;
}
.vtable tr:last-child td { border-bottom: none; }
.vtable tbody tr { cursor: pointer; transition: background .13s; }
.vtable tbody tr:hover { background: var(--bg); }
.num-cell { font-family: var(--num); font-weight: 600; font-size: 14px; }
.date-cell { font-family: var(--num); font-size: 12.5px; color: var(--ink-dim); }
/* —— 滚动条 —— */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb {
background: var(--line);
border-radius: 99px;
}
::-webkit-scrollbar-thumb:hover { background: var(--ink-faint); }
::-webkit-scrollbar-track { background: transparent; }
/* —— 动效(对应设计稿 §8) —— */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes breathe {
0%, 100% { box-shadow: 0 0 0 5px rgba(255, 69, 0, .22), 0 12px 30px -12px rgba(255, 69, 0, .3); }
50% { box-shadow: 0 0 0 12px rgba(255, 69, 0, .06), 0 14px 34px -10px rgba(255, 69, 0, .4); }
}
@keyframes slideIn {
from { transform: translateX(80px); opacity: .4; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideUp {
from { transform: translateY(60px); opacity: .4; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: .35; }
}
@keyframes spin360 {
from { transform: rotate(0); }
to { transform: rotate(360deg); }
}
/* —— 路由切换过渡 —— */
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: opacity .3s, transform .3s;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateY(10px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateY(-6px);
}
/* —— 响应式断点(桌面 ≥1024 / 平板 768~1023 / 手机 <768) —— */
@media (max-width: 1023px) {
.page { padding: 20px 18px 40px; }
.page-inner { padding: 22px 18px 40px; }
}
@media (max-width: 767px) {
.page { padding: 14px 12px 96px; }
.page-inner { padding: 16px 12px 96px; }
.page-title { font-size: 17px; }
.page-title .emo { font-size: 19px; }
.page-sub { font-size: 12px; }
}
+100
View File
@@ -0,0 +1,100 @@
/* ============================================================
Verdaccio Cache Manager — 双主题 Design Tokens
一套代码:bright(浅色默认) / dark(暗色)
依据 doc/design-spec.md §1 与 bright/dark 设计稿
============================================================ */
:root {
/* —— 多巴胺色板 —— */
--green: #32CD32;
--green-soft: #E9F9E9;
--green-deep: #1F9E1F;
--orange: #FF8C00;
--orange-soft: #FFF3E2;
--red: #FF4500;
--red-soft: #FFE9E2;
--blue: #1E90FF;
--blue-soft: #E6F2FF;
--purple: #9B5DE5;
--pink: #FF5E8E;
/* —— 中性色 / 背景 —— */
--ink: #2B2B33;
--ink-dim: #8A8A94;
--ink-faint: #B9B9C2;
--line: #F1E7DA;
--line-soft: #FBF4E9;
--card: #FFFFFF;
--card-2: #FFFFFF;
--bg: #FFF8EF;
--bg-deep: #FFFDF9;
--panel-soft: #FFFDF8; /* 表头/表格 hover 底 */
--num-bg: #F4F4F6; /* 数字底 / 中性按钮底 */
--num-bg-2: #F4F0E8; /* 进度条轨道底 */
--ignored-card: #FBFBFD; /* 忽略卡片底 */
/* —— 字体 —— */
--num: 'Fredoka', 'PingFang SC', sans-serif;
--sans: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
--display: 'ZCOOL QingKe HuangYou', 'PingFang SC', sans-serif;
/* —— 圆角 / 阴影 —— */
--r-lg: 22px;
--r-md: 16px;
--shadow: 0 10px 30px -12px rgba(180, 120, 60, .18), 0 2px 8px -2px rgba(180, 120, 60, .08);
--shadow-hover: 0 18px 44px -14px rgba(180, 120, 60, .30), 0 4px 14px -4px rgba(180, 120, 60, .12);
/* —— 渐变常量 —— */
--grad-main: linear-gradient(120deg, #FFB347, #FF8C00);
--grad-warn: linear-gradient(120deg, #FF5E1A, #FF8C00);
--grad-avatar: linear-gradient(135deg, #7CC6FF, #1E90FF);
--grad-pkg-ico: linear-gradient(135deg, #FFE9C7, #FFD9A0);
/* —— 顶部导航/次级元素 hover —— */
--nav-hover-bg: #FFF4E6;
/* —— 主题切换按钮 —— */
--toggle-bg: var(--card);
}
html[data-theme="dark"] {
/* —— 多巴胺色板 —— */
--green: #3DDB4D;
--green-soft: rgba(61, 219, 77, .14);
--green-deep: #3DDB4D;
--orange: #FFA033;
--orange-soft: rgba(255, 160, 51, .14);
--red: #FF5630;
--red-soft: rgba(255, 86, 48, .15);
--blue: #45A2FF;
--blue-soft: rgba(69, 162, 255, .15);
--purple: #B083F0;
--pink: #FF7AA8;
/* —— 中性色 / 背景 —— */
--ink: #F1E8DE;
--ink-dim: #A79E91;
--ink-faint: #6E655A;
--line: #3A2F25;
--line-soft: #332A21;
--card: #221B14;
--card-2: #2A211A;
--bg: #16110C;
--bg-deep: #100C08;
--panel-soft: #2E2620;
--num-bg: #2E2620;
--num-bg-2: #2E2620;
--ignored-card: #1C1612;
/* —— 阴影 —— */
--shadow: 0 12px 32px -14px rgba(0, 0, 0, .6), 0 2px 8px -2px rgba(0, 0, 0, .4);
--shadow-hover: 0 20px 46px -16px rgba(0, 0, 0, .75), 0 4px 14px -4px rgba(0, 0, 0, .5);
/* —— 渐变常量 —— */
--grad-warn: linear-gradient(120deg, #FF5630, #FF8C00);
--grad-avatar: linear-gradient(135deg, #5FA8E8, #2B74B9);
--grad-pkg-ico: linear-gradient(135deg, #4A3622, #5C4326);
--nav-hover-bg: rgba(255, 160, 51, .10);
--toggle-bg: #2A211A;
}
+69
View File
@@ -0,0 +1,69 @@
<script setup>
import { computed } from 'vue'
import { useStore } from '../store'
const { state, unignorePkg, refreshPackages } = useStore()
const ignoredList = computed(() => state.packages.filter(p => p.status === 'ignored'))
async function doUnignore(pkg) {
try {
await ElMessageBox.confirm(`确定将 ${pkg.name} 移出忽略吗?移出后若仍超阈值将恢复预警。`, '移出忽略', {
confirmButtonText: '移出',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
await unignorePkg(pkg.name)
ElMessage.success(`已移出忽略:${pkg.name}`)
}
</script>
<template>
<div class="page-inner">
<div class="page-title"><span class="emo">🚫</span>已忽略的包</div>
<div class="page-sub">共 <b>{{ ignoredList.length }}</b> 个已忽略包 · 移出后自动恢复状态判断</div>
<div v-if="state.loading" class="loading-tip">⏳ 加载中…</div>
<div v-else class="vtable">
<table>
<thead>
<tr>
<th>包名</th>
<th style="width: 140px">版本数</th>
<th style="width: 150px">占用空间</th>
<th style="width: 170px">忽略时间</th>
<th style="width: 130px">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="p in ignoredList" :key="p.name">
<td><span class="pkg-cell">{{ p.name }}</span></td>
<td><span class="num-cell">{{ p.versions }}</span></td>
<td><span class="num-cell">{{ p.size }} MB</span></td>
<td><span class="ign-time">{{ p.ignoredAt || '—' }}</span></td>
<td>
<button class="btn unignore" @click="doUnignore(p)">↩️ 移出忽略</button>
</td>
</tr>
</tbody>
</table>
<div v-if="!ignoredList.length" class="empty-tip">
🎉 还没有忽略任何包
</div>
</div>
</div>
</template>
<style scoped>
.pkg-cell { font-weight: 600; word-break: break-all; }
.ign-time { font-family: var(--num); font-size: 12.5px; color: var(--ink-dim); }
.empty-tip { padding: 60px 0; text-align: center; color: var(--ink-faint); font-size: 13px; }
.loading-tip { text-align: center; padding: 40px 0; color: var(--ink-faint); font-size: 13px; }
@media (max-width: 767px) {
.vtable { overflow-x: auto; }
.vtable table { min-width: 600px; }
}
</style>
+200
View File
@@ -0,0 +1,200 @@
<script setup>
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useTheme } from '../composables/useTheme'
const router = useRouter()
const route = useRoute()
const { theme, toggle } = useTheme()
const username = ref('')
const password = ref('')
const remember = ref(true)
const loading = ref(false)
function login() {
if (!username.value.trim() || !password.value) {
ElMessage.warning('请输入用户名和密码')
return
}
loading.value = true
// 演示账号:任意非空即可登录(真实环境对接后端鉴权)
setTimeout(() => {
localStorage.setItem('vcm-user', JSON.stringify({
name: username.value.trim(),
email: `${username.value.trim()}@verdaccio.local`,
}))
loading.value = false
ElMessage.success('欢迎回来 👋')
router.push(route.query.redirect || '/')
}, 500)
}
</script>
<template>
<div class="login-page">
<div class="login-ambient"></div>
<button class="theme-toggle" @click="toggle">{{ theme === 'dark' ? '☀️ 亮色' : '🌙 暗色' }}</button>
<div class="login-card">
<div class="login-logo">📦</div>
<div class="login-title">Verdaccio Manager</div>
<div class="login-sub">你的缓存 · 尽在掌握</div>
<div class="field">
<span class="f-ico">👤</span>
<input v-model="username" type="text" placeholder="用户名 / 邮箱" @keyup.enter="login" />
</div>
<div class="field">
<span class="f-ico">🔑</span>
<input v-model="password" type="password" placeholder="密码" @keyup.enter="login" />
</div>
<div class="login-options">
<label class="remember">
<input v-model="remember" type="checkbox" />
<span>记住我</span>
</label>
<a class="forgot">忘记密码?</a>
</div>
<button class="login-btn" :disabled="loading" @click="login">
{{ loading ? '登录中…' : '🚀 登 录' }}
</button>
<div class="login-tip">演示环境:任意用户名 / 密码即可登录</div>
</div>
</div>
</template>
<style scoped>
.login-page {
position: relative;
min-height: 100vh;
display: grid;
place-items: center;
background: var(--bg);
overflow: hidden;
}
.login-ambient {
position: absolute;
inset: 0;
background:
radial-gradient(800px 500px at 80% -10%, rgba(255, 140, 0, .18), transparent 60%),
radial-gradient(700px 460px at -6% 8%, rgba(50, 205, 50, .12), transparent 55%),
radial-gradient(720px 500px at 50% 115%, rgba(30, 144, 255, .12), transparent 60%);
pointer-events: none;
}
.login-ambient::after {
content: "";
position: absolute;
inset: 0;
background-image: radial-gradient(rgba(140, 90, 30, .05) 1px, transparent 1px);
background-size: 22px 22px;
}
.theme-toggle {
position: absolute;
top: 22px;
right: 22px;
z-index: 5;
display: inline-flex;
align-items: center;
gap: 7px;
border: 1.5px solid var(--line);
background: var(--card);
color: var(--ink-dim);
font-size: 12.5px;
font-weight: 600;
padding: 8px 15px;
border-radius: 99px;
cursor: pointer;
transition: .16s;
}
.theme-toggle:hover { border-color: var(--orange); color: var(--orange); }
.login-card {
position: relative;
z-index: 2;
width: 420px;
max-width: 92vw;
background: var(--card);
border-radius: 24px;
box-shadow: 0 24px 60px -18px rgba(0, 0, 0, .38);
padding: 42px 40px 34px;
text-align: center;
animation: fadeUp .5s both;
}
.login-logo {
width: 62px;
height: 62px;
margin: 0 auto 16px;
border-radius: 19px;
display: grid;
place-items: center;
font-size: 30px;
background: var(--grad-main);
box-shadow: 0 14px 30px -8px rgba(255, 140, 0, .6);
}
.login-title {
font-size: 21px;
font-weight: 700;
color: var(--orange);
letter-spacing: -.3px;
}
.login-sub { font-size: 12.5px; color: var(--ink-faint); margin: 5px 0 26px; }
.field {
display: flex;
align-items: center;
gap: 12px;
background: var(--card);
border: 1.5px solid var(--line);
border-radius: 14px;
padding: 0 16px;
height: 50px;
margin-bottom: 14px;
transition: .18s;
}
.field:focus-within { border-color: var(--orange); box-shadow: 0 0 0 4px rgba(255, 140, 0, .12); }
.f-ico { font-size: 17px; color: var(--ink-faint); }
.field input {
flex: 1;
border: none;
outline: none;
background: transparent;
font-size: 14px;
font-family: var(--sans);
color: var(--ink);
}
.field input::placeholder { color: var(--ink-faint); }
.login-options {
display: flex;
align-items: center;
justify-content: space-between;
margin: 4px 2px 20px;
font-size: 12.5px;
}
.remember { display: flex; align-items: center; gap: 7px; color: var(--ink-dim); cursor: pointer; }
.remember input { accent-color: var(--orange); width: 15px; height: 15px; cursor: pointer; }
.forgot { color: var(--blue); cursor: pointer; }
.forgot:hover { text-decoration: underline; }
.login-btn {
width: 100%;
height: 48px;
border: none;
border-radius: 99px;
cursor: pointer;
background: var(--grad-main);
color: #fff;
font-size: 15px;
font-weight: 700;
font-family: var(--sans);
box-shadow: 0 12px 26px -8px rgba(255, 140, 0, .6);
transition: .18s;
}
.login-btn:hover { transform: translateY(-1px); box-shadow: 0 16px 32px -8px rgba(255, 140, 0, .7); }
.login-btn:active { transform: scale(.97); }
.login-btn:disabled { opacity: .6; cursor: not-allowed; }
.login-tip { margin-top: 16px; font-size: 11px; color: var(--ink-faint); }
</style>
+148
View File
@@ -0,0 +1,148 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useStore } from '../store'
const { state, refreshLogs } = useStore()
const detail = ref(null) // 查看日志详情的当前条
onMounted(() => refreshLogs())
function fmtTime(t) {
return (t || '').replace('T', ' ').slice(0, 19)
}
</script>
<template>
<div class="page-inner">
<div class="page-title"><span class="emo">🕒</span>最近动态</div>
<div class="page-sub">
共 <b>{{ state.logs.length }}</b> 条清理记录 · 按时间倒序
<span class="log-live"><i></i>LIVE</span>
</div>
<div class="vtable">
<table>
<thead>
<tr>
<th>时间</th>
<th>包名</th>
<th style="width: 120px">删除数量</th>
<th style="width: 120px">释放空间</th>
<th style="width: 90px">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="log in state.logs" :key="log.id" @click="detail = log">
<td><span class="date-cell">{{ fmtTime(log.created_at) }}</span></td>
<td><span class="pkg-cell">{{ log.pkgName }}</span></td>
<td><span class="chip n">− {{ log.deletedCount }} 版</span></td>
<td><span class="chip s">🟢 {{ log.freedSpace }}</span></td>
<td><button class="btn ghost" @click.stop="detail = log">详情</button></td>
</tr>
</tbody>
</table>
<div v-if="!state.logs.length" class="empty-tip">暂无清理记录,清理操作会自动记录在这里</div>
</div>
</div>
<!-- 日志详情弹窗 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="detail" class="ov" @click.self="detail = null"></div>
</Transition>
<Transition name="pop">
<div v-if="detail" class="detail-dialog">
<div class="dd-head">
<span class="dd-title">📋 清理详情</span>
<div class="dd-close" @click="detail = null">✕</div>
</div>
<div class="dd-body">
<div class="dd-row">
<span class="k">包名</span>
<span class="v">{{ detail.pkgName }}</span>
</div>
<div class="dd-row">
<span class="k">操作时间</span>
<span class="v">{{ fmtTime(detail.created_at) }}</span>
</div>
<div class="dd-row">
<span class="k">删除数量</span>
<span class="v red">− {{ detail.deletedCount }} 个版本</span>
</div>
<div class="dd-row">
<span class="k">释放空间</span>
<span class="v green">🟢 {{ detail.freedSpace }}</span>
</div>
<div class="dd-row">
<span class="k">删除版本</span>
<div class="dd-tags">
<span v-for="v in (detail.deletedVersions || '').split(',').map(s => s.trim()).filter(Boolean)" :key="v" class="dd-tag">{{ v }}</span>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.log-live {
display: inline-flex;
align-items: center;
gap: 7px;
font-family: var(--num);
font-size: 11.5px;
font-weight: 600;
color: var(--green-deep);
background: var(--green-soft);
border: 1px solid color-mix(in srgb, var(--green) 45%, transparent);
padding: 5px 12px;
border-radius: 99px;
margin-left: 8px;
}
.log-live i { width: 7px; height: 7px; border-radius: 50%; background: var(--green-deep); box-shadow: 0 0 8px var(--green-deep); animation: blink 1.6s infinite; }
.pkg-cell { font-weight: 600; word-break: break-all; }
.chip { font-family: var(--num); font-size: 12px; font-weight: 700; padding: 4px 12px; border-radius: 9px; display: inline-block; }
.chip.n { color: var(--red); background: var(--red-soft); }
.chip.s { color: var(--green-deep); background: var(--green-soft); }
.empty-tip { padding: 50px 0; text-align: center; color: var(--ink-faint); font-size: 13px; }
.ov { position: fixed; inset: 0; background: rgba(60, 45, 30, .4); z-index: 140; }
.detail-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(440px, 92vw);
z-index: 141;
background: var(--card);
border-radius: 22px;
box-shadow: 0 30px 80px -24px rgba(0, 0, 0, .5);
overflow: hidden;
}
.dd-head { display: flex; align-items: center; justify-content: space-between; padding: 18px 22px; border-bottom: 1px solid var(--line); }
.dd-title { font-size: 16px; font-weight: 700; }
.dd-close {
width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center;
cursor: pointer; background: var(--num-bg); color: var(--ink-dim); font-size: 12px;
}
.dd-close:hover { background: var(--red-soft); color: var(--red); }
.dd-body { padding: 18px 22px; }
.dd-row { display: flex; align-items: flex-start; gap: 14px; padding: 8px 0; font-size: 13px; }
.dd-row .k { flex-shrink: 0; width: 70px; color: var(--ink-faint); font-size: 12px; padding-top: 2px; }
.dd-row .v { font-weight: 600; word-break: break-all; font-family: var(--num); }
.dd-row .v.red { color: var(--red); }
.dd-row .v.green { color: var(--green-deep); }
.dd-tags { display: flex; flex-wrap: wrap; gap: 6px; }
.dd-tag {
font-family: var(--num); font-size: 12px; font-weight: 600;
padding: 3px 10px; border-radius: 9px;
background: var(--red-soft); color: var(--red);
}
.fade-enter-active, .fade-leave-active { transition: opacity .25s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.pop-enter-active { transition: transform .3s cubic-bezier(.2, .8, .25, 1), opacity .3s; }
.pop-leave-active { transition: transform .2s ease-in, opacity .2s; }
.pop-enter-from, .pop-leave-to { transform: translate(-50%, -46%) scale(.96); opacity: 0; }
</style>
+161
View File
@@ -0,0 +1,161 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import StatCard from '../components/StatCard.vue'
import StatMini from '../components/StatMini.vue'
import Top30Panel from '../components/Top30Panel.vue'
import PackageCard from '../components/PackageCard.vue'
import PackageDrawer from '../components/PackageDrawer.vue'
import CleanDialog from '../components/CleanDialog.vue'
import { useStore } from '../store'
import { iconOf } from '../mock/data.js'
const router = useRouter()
const { state, stats, refreshAll, ignorePkg, unignorePkg, cleanPkg } = useStore()
const drawerPkg = ref(null)
const drawerVisible = ref(false)
const cleanVisible = ref(false)
const cleanPkgData = ref(null)
const showTop30 = ref(true)
const warningExcess = computed(() =>
stats.value.warnCount
? state.packages
.filter(p => p.status === 'warn')
.reduce((s, p) => s + (p.versions - state.threshold.versionThreshold), 0)
: 0,
)
const top30WithIcon = computed(() =>
stats.value.top30.map(p => ({ ...p, emoji: iconOf(p.name) })),
)
async function openDrawer(pkg) {
drawerPkg.value = pkg
drawerVisible.value = true
}
function openClean(pkg) {
cleanPkgData.value = pkg
cleanVisible.value = true
}
async function doIgnore(pkg) {
await ignorePkg(pkg.name)
ElMessage.success(`已忽略 ${pkg.name},不再预警`)
}
async function doUnignore(pkg) {
await unignorePkg(pkg.name)
ElMessage.success(`已移出忽略,${pkg.name} 恢复状态判断`)
}
async function doDeleteVersion(pkg, version) {
const r = await useStore().deleteVersion(pkg.name, version)
if (r.ok) ElMessage.success(`已删除版本 ${version},释放 ${r.freedSpace}`)
else ElMessage.warning('删除失败')
}
async function onCleanConfirmed(res) {
ElMessage.success(`已清理 ${res.deletedCount} 个版本,释放 ${res.freedSpace}`)
drawerVisible.value = false
}
function quickClean() {
const warnPkg = state.packages.find(p => p.status === 'warn')
if (warnPkg) openClean(warnPkg)
else ElMessage.info('暂无预警包需要清理 🎉')
}
</script>
<template>
<div class="page">
<!-- 第一行:核心指标 4 -->
<div class="stats-1">
<StatCard tone="green" label="📦 包的数量" :value="stats.pkgCount.toLocaleString()" unit="个缓存包" ico="🗃️" />
<StatCard tone="orange" label="💾 占用空间" :value="stats.totalSizeText" unit="总计" ico="🧊" />
<StatCard tone="red" label="🚨 预警数量" :value="String(stats.warnCount)" unit="个需关注" ico="🔥" @cta="quickClean" cta="🔥 立即处理" />
<StatCard tone="blue" label="🧹 可清理空间" :value="stats.cleanableText" unit="可释放" ico="✨" cta="✨ 立即清理" @cta="quickClean" />
</div>
<!-- 第二行:辅助指标 5 -->
<div class="stats-2">
<StatMini tone="blue" label="访问排行" ico="🏆" main="Top 30" :sub="top30WithIcon.slice(0, 3).map(p => p.name).join(' · ')" link="展开" @link="showTop30 = true" />
<StatMini tone="green" label="今日下载量" ico="📈" main="1,234" sub="较昨日 +18%" />
<StatMini tone="orange" label="最大包" ico="🐘" :main="stats.maxPkg?.name || '—'" :unit="stats.maxPkg ? stats.maxPkg.size + ' MB' : ''" :sub="`${stats.maxPkg?.versions || 0} 个版本`" @link="stats.maxPkg && openDrawer(stats.maxPkg)" />
<StatMini tone="purple" label="最老包时间" ico="⏳" :main="stats.oldestPkg?.lastUpdated || '—'" unit="⏰" :sub="stats.oldestPkg ? `${stats.oldestPkg.name} · 未更新` : ''" @link="stats.oldestPkg && openDrawer(stats.oldestPkg)" />
<StatMini tone="pink" label="版本数分布" ico="🧩" main=">15 版本" :unit="`${stats.warnCount} 个`" :sub="`超阈值共 ${warningExcess} 个版本`" />
</div>
<!-- Top 30 排行(可收起) -->
<Top30Panel v-if="showTop30" :top30="top30WithIcon" />
<!-- 包卡片列表 -->
<div class="sec-head">
<span class="sec-title"><span class="emo">🎁</span>你的缓存</span>
<span class="sec-sub">共 {{ stats.pkgCount }} 个包 · 预警置顶 · 同状态按版本数降序</span>
</div>
<div v-if="state.loading" class="loading-tip">⏳ 加载中…</div>
<div class="pkg-grid">
<PackageCard
v-for="(p, i) in state.packages"
:key="p.name"
:pkg="p"
:threshold="state.threshold.versionThreshold"
:style="{ animationDelay: (i * 0.03) + 's' }"
@open="openDrawer"
@clean="openClean"
@ignore="doIgnore"
@unignore="doUnignore"
/>
</div>
<div v-if="!state.loading && !state.packages.length" class="empty-box">
暂无缓存包,请检查 Verdaccio 缓存目录配置
</div>
</div>
<!-- 详情抽屉 + 清理确认 -->
<PackageDrawer
v-model:visible="drawerVisible"
:pkg="drawerPkg"
:threshold="state.threshold.versionThreshold"
@clean="openClean"
@ignore="doIgnore"
@unignore="doUnignore"
@delete-version="doDeleteVersion"
/>
<CleanDialog
:visible="cleanVisible"
:pkg="cleanPkgData"
:threshold="state.threshold.versionThreshold"
@close="cleanVisible = false"
@confirmed="onCleanConfirmed"
/>
</template>
<style scoped>
.stats-1 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; margin-bottom: 18px; }
.stats-2 { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; margin-bottom: 24px; }
.sec-head { display: flex; align-items: center; justify-content: space-between; margin: 4px 2px 16px; gap: 12px; flex-wrap: wrap; }
.sec-title { font-size: 17px; font-weight: 700; display: flex; align-items: center; gap: 10px; }
.sec-title .emo { font-size: 19px; }
.sec-sub { font-family: var(--num); font-size: 12px; color: var(--ink-faint); font-weight: 600; }
.pkg-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.loading-tip { text-align: center; padding: 40px 0; color: var(--ink-faint); font-size: 13px; }
.empty-box { text-align: center; padding: 60px 0; color: var(--ink-faint); font-size: 13px; }
@media (max-width: 1180px) {
.stats-1 { grid-template-columns: repeat(2, 1fr); }
.stats-2 { grid-template-columns: repeat(3, 1fr); }
.pkg-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 767px) {
.stats-1 { grid-template-columns: repeat(2, 1fr); gap: 10px; }
.stats-2 { grid-template-columns: repeat(2, 1fr); gap: 10px; }
.pkg-grid { grid-template-columns: 1fr; gap: 12px; }
.sec-title { font-size: 15px; }
.sec-sub { font-size: 11px; }
}
</style>
+277
View File
@@ -0,0 +1,277 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import PackageDrawer from '../components/PackageDrawer.vue'
import CleanDialog from '../components/CleanDialog.vue'
import StatusTag from '../components/StatusTag.vue'
import { useStore } from '../store'
const { state, stats, refreshPackages, ignorePkg, unignorePkg, cleanPkg, deleteVersion } = useStore()
const keyword = ref('')
const statusFilter = ref('all') // all | warn | ok | ignored | broken
const sortMode = ref('default') // default | versions-desc | size-desc
let debounceTimer = null
onMounted(() => refreshPackages())
watch(keyword, () => {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => refreshPackages(keyword.value), 300)
})
const filtered = computed(() => {
let list = state.packages
if (statusFilter.value !== 'all') {
list = list.filter(p => p.status === statusFilter.value)
}
if (sortMode.value === 'versions-desc') {
list = [...list].sort((a, b) => b.versions - a.versions)
} else if (sortMode.value === 'size-desc') {
list = [...list].sort((a, b) => b.size - a.size)
}
return list
})
const pills = [
{ key: 'all', label: '全部' },
{ key: 'warn', label: '🔥 预警' },
{ key: 'ok', label: '✅ 正常' },
{ key: 'ignored', label: '💤 已忽略' },
{ key: 'broken', label: '⚠️ 异常' },
]
const drawerPkg = ref(null)
const drawerVisible = ref(false)
const cleanVisible = ref(false)
const cleanPkgData = ref(null)
function openDrawer(pkg) {
drawerPkg.value = pkg
drawerVisible.value = true
}
function openClean(pkg) {
cleanPkgData.value = pkg
cleanVisible.value = true
}
async function doIgnore(pkg) {
await ignorePkg(pkg.name)
ElMessage.success(`已忽略 ${pkg.name}`)
}
async function doUnignore(pkg) {
await unignorePkg(pkg.name)
ElMessage.success(`已移出忽略 ${pkg.name}`)
}
async function doDeleteVersion(pkg, version) {
const r = await deleteVersion(pkg.name, version)
if (r.ok) ElMessage.success(`已删除版本 ${version},释放 ${r.freedSpace}`)
}
async function onCleanConfirmed(res) {
ElMessage.success(`已清理 ${res.deletedCount} 个版本,释放 ${res.freedSpace}`)
drawerVisible.value = false
}
</script>
<template>
<div class="page-inner">
<div class="search-hero">
<div class="search-box">
<span class="s-ico">🔍</span>
<input
v-model="keyword"
type="text"
placeholder="搜索包名…(实时过滤 · 防抖 300ms)"
@keyup.esc="keyword = ''"
/>
<span v-if="keyword" class="s-clear" @click="keyword = ''">✕</span>
<button class="s-go">搜索</button>
</div>
<div class="filter-row">
<span class="pill split">快捷筛选</span>
<span
v-for="p in pills"
:key="p.key"
class="pill"
:class="{ on: statusFilter === p.key }"
@click="statusFilter = p.key"
>{{ p.label }}</span>
<span class="pill split">排序</span>
<span class="pill" :class="{ on: sortMode === 'default' }" @click="sortMode = 'default'">默认</span>
<span class="pill" :class="{ on: sortMode === 'versions-desc' }" @click="sortMode = 'versions-desc'">版本数 ↓</span>
<span class="pill" :class="{ on: sortMode === 'size-desc' }" @click="sortMode = 'size-desc'">占用大小 ↓</span>
</div>
</div>
<div class="result-note">
共找到 <b>{{ filtered.length }}</b> 个包
<template v-if="keyword">(关键词「{{ keyword }}」)</template>
</div>
<div v-if="state.loading" class="loading-tip">⏳ 加载中…</div>
<div v-else class="vtable">
<table>
<thead>
<tr>
<th>包名</th>
<th style="width: 90px">版本数</th>
<th style="width: 110px">占用空间</th>
<th style="width: 110px">最近更新</th>
<th style="width: 110px">状态</th>
<th style="width: 130px">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="p in filtered" :key="p.name" @click="openDrawer(p)">
<td>
<span class="pkg-cell">{{ p.name }}</span>
</td>
<td><span class="num-cell" :class="{ over: p.versions > state.threshold.versionThreshold }">{{ p.versions }}</span></td>
<td><span class="num-cell">{{ p.size }} MB</span></td>
<td><span class="date-cell">{{ p.lastUpdated || '—' }}</span></td>
<td><StatusTag :status="p.status" /></td>
<td @click.stop>
<template v-if="p.status === 'warn'">
<button class="btn clean" @click="openClean(p)">清理</button>
<button class="btn ignore" @click="doIgnore(p)">忽略</button>
</template>
<template v-else-if="p.status === 'ignored'">
<button class="btn unignore" @click="doUnignore(p)">取消忽略</button>
</template>
<template v-else>
<button class="btn ignore" @click="doIgnore(p)">忽略</button>
</template>
</td>
</tr>
</tbody>
</table>
<div v-if="!filtered.length" class="empty-tip">没有匹配的包</div>
</div>
</div>
<PackageDrawer
v-model:visible="drawerVisible"
:pkg="drawerPkg"
:threshold="state.threshold.versionThreshold"
@clean="openClean"
@ignore="doIgnore"
@unignore="doUnignore"
@delete-version="doDeleteVersion"
/>
<CleanDialog
:visible="cleanVisible"
:pkg="cleanPkgData"
:threshold="state.threshold.versionThreshold"
@close="cleanVisible = false"
@confirmed="onCleanConfirmed"
/>
</template>
<style scoped>
.search-hero { padding: 18px 0 12px; display: flex; flex-direction: column; align-items: center; }
.search-box {
display: flex;
align-items: center;
gap: 12px;
width: min(680px, 100%);
height: 52px;
background: var(--card);
border: 2px solid var(--line);
border-radius: 99px;
padding: 0 10px 0 22px;
box-shadow: 0 14px 34px -14px rgba(180, 120, 60, .25);
transition: .2s;
}
.search-box:focus-within {
border-color: var(--orange);
box-shadow: 0 0 0 4px rgba(255, 140, 0, .14), 0 14px 34px -14px rgba(255, 140, 0, .3);
}
.s-ico { font-size: 18px; color: var(--ink-faint); }
.search-box input {
flex: 1;
border: none;
outline: none;
font-size: 14.5px;
font-family: var(--sans);
color: var(--ink);
background: transparent;
min-width: 0;
}
.search-box input::placeholder { color: var(--ink-faint); }
.s-clear {
width: 26px;
height: 26px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--num-bg);
color: var(--ink-dim);
font-size: 11px;
cursor: pointer;
flex-shrink: 0;
}
.s-clear:hover { background: var(--red-soft); color: var(--red); }
.s-go {
display: inline-flex;
align-items: center;
gap: 7px;
height: 38px;
padding: 0 22px;
border: none;
border-radius: 99px;
cursor: pointer;
background: var(--grad-main);
color: #fff;
font-size: 13.5px;
font-weight: 600;
box-shadow: 0 8px 18px -6px rgba(255, 140, 0, .6);
transition: .18s;
flex-shrink: 0;
}
.s-go:hover { transform: translateY(-1px); }
.filter-row {
display: flex;
align-items: center;
gap: 9px;
flex-wrap: wrap;
justify-content: center;
margin-top: 18px;
}
.pill {
font-size: 12.5px;
font-weight: 600;
padding: 8px 17px;
border-radius: 99px;
cursor: pointer;
background: var(--card);
border: 1.5px solid var(--line);
color: var(--ink-dim);
transition: .16s;
font-family: var(--sans);
}
.pill:hover { border-color: color-mix(in srgb, var(--orange) 60%, transparent); color: var(--orange); }
.pill.on { background: var(--green); border-color: var(--green); color: #fff; box-shadow: 0 6px 14px -4px rgba(50, 205, 50, .5); }
.pill.split { background: transparent; border: none; color: var(--ink-faint); cursor: default; }
.result-note {
text-align: center;
font-family: var(--num);
font-size: 12.5px;
color: var(--ink-faint);
margin: 22px 0 14px;
font-weight: 600;
}
.result-note b { color: var(--orange); }
.pkg-cell { font-weight: 600; word-break: break-all; }
.num-cell.over { color: var(--red); }
.empty-tip { padding: 40px 0; text-align: center; color: var(--ink-faint); font-size: 12.5px; }
.loading-tip { text-align: center; padding: 40px 0; color: var(--ink-faint); font-size: 13px; }
@media (max-width: 767px) {
.search-box { height: 48px; }
.search-box .s-go { display: none; }
.filter-row { gap: 7px; }
.pill { padding: 7px 13px; font-size: 12px; }
.pill.split { display: none; }
.vtable { overflow-x: auto; }
.vtable table { min-width: 640px; }
}
</style>
+311
View File
@@ -0,0 +1,311 @@
<script setup>
import { ref, reactive, computed, watch } from 'vue'
import { useStore } from '../store'
const { state, saveThresholds } = useStore()
const form = reactive({
versionThreshold: 15,
sizeThreshold: 500,
exceptions: [],
})
const saved = ref(false)
const saving = ref(false)
watch(
() => state.threshold,
(t) => {
form.versionThreshold = t.versionThreshold
form.sizeThreshold = t.sizeThreshold
form.exceptions = t.exceptions.map(e => ({ ...e }))
},
{ immediate: true, deep: true },
)
// 新增例外表单
const addForm = reactive({ pkgName: '', versionThreshold: 15, sizeThreshold: 500 })
function clamp(v, min, max) {
const n = Number(v)
if (Number.isNaN(n)) return min
return Math.min(max, Math.max(min, n))
}
const versionMax = 100
const sizeMax = 10000 // MB
function setVersion(v) {
form.versionThreshold = clamp(v, 1, versionMax)
}
function setSize(v) {
form.sizeThreshold = clamp(v, 1, sizeMax)
}
function resetDefault() {
form.versionThreshold = 15
form.sizeThreshold = 500
ElMessage.info('已恢复默认:版本数 15 · 大小 500 MB')
}
function addException() {
const name = addForm.pkgName.trim()
if (!name) {
ElMessage.warning('请输入包名')
return
}
if (form.exceptions.some(e => e.pkgName === name)) {
ElMessage.warning('该包已有例外配置')
return
}
form.exceptions.push({
pkgName: name,
versionThreshold: clamp(addForm.versionThreshold, 1, versionMax),
sizeThreshold: clamp(addForm.sizeThreshold, 1, sizeMax),
})
addForm.pkgName = ''
addForm.versionThreshold = 15
addForm.sizeThreshold = 500
}
function removeException(name) {
form.exceptions = form.exceptions.filter(e => e.pkgName !== name)
}
async function doSave() {
saving.value = true
try {
await saveThresholds({
versionThreshold: clamp(form.versionThreshold, 1, versionMax),
sizeThreshold: clamp(form.sizeThreshold, 1, sizeMax),
exceptions: form.exceptions,
})
saved.value = true
ElMessage.success('设置已保存,包列表状态已重新计算 ✅')
setTimeout(() => (saved.value = false), 3000)
} finally {
saving.value = false
}
}
// 命中状态:该包是否存在于当前缓存
function hitOf(name) {
return state.packages.some(p => p.name === name)
}
</script>
<template>
<div class="page-inner">
<div class="page-title"><span class="emo">⚙️</span>预警设置</div>
<div class="page-sub">全局阈值与单包例外,保存后立即重算包列表状态</div>
<!-- 全局阈值 2 卡 -->
<div class="th-grid">
<div class="th-card">
<div class="th-head">
<span class="th-title"><span class="ico">📦</span>版本数阈值</span>
<span class="th-tag">默认 15 · 范围 1~100</span>
</div>
<div class="th-desc">版本数超过阈值的包将标记为<b>预警</b>并置顶,提示清理。</div>
<div class="th-row">
<div class="th-num">
<button @click="setVersion(form.versionThreshold - 1)">−</button>
<input v-model.number="form.versionThreshold" type="number" min="1" :max="versionMax" @change="setVersion(form.versionThreshold)" />
<button @click="setVersion(form.versionThreshold + 1)">+</button>
</div>
<span class="th-unit">个版本</span>
<span class="th-actions"><span class="th-reset" @click="resetDefault">恢复默认</span></span>
</div>
</div>
<div class="th-card">
<div class="th-head">
<span class="th-title"><span class="ico">🧊</span>单包大小阈值</span>
<span class="th-tag">默认 500 MB</span>
</div>
<div class="th-desc">单包占用超过该值的包标记为<b>大包</b>,便于定位体积异常缓存。</div>
<div class="th-row">
<div class="th-num">
<button @click="setSize(form.sizeThreshold - 100)">−</button>
<input v-model.number="form.sizeThreshold" type="number" min="1" :max="sizeMax" @change="setSize(form.sizeThreshold)" />
<button @click="setSize(form.sizeThreshold + 100)">+</button>
</div>
<span class="th-unit">MB</span>
<span class="th-actions"><span class="th-reset" @click="form.sizeThreshold = 500">恢复默认</span></span>
</div>
</div>
</div>
<!-- 保存区 -->
<div class="save-row">
<Transition name="fade">
<div v-if="saved" class="th-tip">✅ 设置已生效,包列表状态已重新计算</div>
<div v-else class="th-tip idle">修改后点击保存生效</div>
</Transition>
<button class="btn clean save-btn" :disabled="saving" @click="doSave">{{ saving ? '保存中…' : '💾 保存设置' }}</button>
</div>
<!-- 单包例外 -->
<div class="ex-head">
<span class="ex-title">🎯 单包例外</span>
<span class="ex-sub">为指定包覆盖全局阈值</span>
</div>
<div class="vtable ex-table">
<table>
<thead>
<tr>
<th>包名</th>
<th style="width: 130px">版本数阈值</th>
<th style="width: 130px">大小阈值</th>
<th style="width: 90px">命中</th>
<th style="width: 80px">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="e in form.exceptions" :key="e.pkgName">
<td><span class="pkg-cell">{{ e.pkgName }}</span></td>
<td><span class="num-cell">{{ e.versionThreshold }}</span></td>
<td><span class="num-cell">{{ e.sizeThreshold }} MB</span></td>
<td>
<span class="tag" :class="hitOf(e.pkgName) ? 'ok' : 'ign'">
{{ hitOf(e.pkgName) ? '✅ 命中' : '💤 未命中' }}
</span>
</td>
<td><button class="btn remove" @click="removeException(e.pkgName)">删除</button></td>
</tr>
</tbody>
</table>
<div v-if="!form.exceptions.length" class="empty-tip">暂无例外配置,可在下方添加</div>
</div>
<!-- 行内添加例外 -->
<div class="add-ex">
<input v-model="addForm.pkgName" class="ae-pkg" type="text" placeholder="包名,如 lodash 或 @scope/name" @keyup.enter="addException" />
<input v-model.number="addForm.versionThreshold" class="ae-num" type="number" min="1" max="100" placeholder="版本阈值" />
<input v-model.number="addForm.sizeThreshold" class="ae-num" type="number" min="1" placeholder="大小阈值(MB)" />
<button class="btn clean" @click="addException">+ 添加例外</button>
</div>
</div>
</template>
<style scoped>
.th-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-bottom: 14px; }
.th-card {
background: var(--card);
border: 1.5px solid var(--line);
border-radius: var(--r-lg);
padding: 20px 22px;
box-shadow: var(--shadow);
animation: fadeUp .5s both;
transition: .18s;
}
.th-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-hover); }
.th-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
.th-title { display: flex; align-items: center; gap: 10px; font-size: 15px; font-weight: 700; }
.th-title .ico { font-size: 19px; }
.th-tag { font-size: 11.5px; font-weight: 700; padding: 4px 11px; border-radius: 99px; background: var(--orange-soft); color: var(--orange); white-space: nowrap; }
.th-desc { font-size: 12px; color: var(--ink-dim); margin: 9px 0 16px; line-height: 1.75; }
.th-desc b { color: var(--red); }
.th-row { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.th-num {
display: flex;
align-items: center;
gap: 8px;
background: var(--card);
border: 1.5px solid var(--line);
border-radius: 14px;
padding: 6px;
transition: .16s;
}
.th-num:focus-within { border-color: var(--orange); box-shadow: 0 0 0 3px rgba(255, 140, 0, .12); }
.th-num button {
width: 34px;
height: 34px;
border-radius: 10px;
border: none;
cursor: pointer;
font-size: 17px;
font-weight: 700;
color: var(--orange);
background: var(--orange-soft);
transition: .15s;
}
.th-num button:hover { background: var(--orange); color: #fff; }
.th-num input {
width: 104px;
border: none;
outline: none;
text-align: center;
background: transparent;
font-family: var(--num);
font-size: 22px;
font-weight: 700;
color: var(--ink);
-moz-appearance: textfield;
}
.th-num input::-webkit-outer-spin-button,
.th-num input::-webkit-inner-spin-button { -webkit-appearance: none; }
.th-unit { font-size: 12.5px; color: var(--ink-faint); font-weight: 600; }
.th-actions { margin-left: auto; }
.th-reset { font-size: 12px; color: var(--ink-faint); cursor: pointer; text-decoration: underline dotted; }
.th-reset:hover { color: var(--orange); }
.save-row { display: flex; align-items: center; gap: 14px; margin-bottom: 24px; flex-wrap: wrap; }
.th-tip {
display: inline-flex;
align-items: center;
gap: 9px;
font-size: 12.5px;
font-weight: 600;
color: var(--green-deep);
background: var(--green-soft);
border: 1.5px dashed rgba(61, 219, 77, .4);
border-radius: 14px;
padding: 10px 16px;
}
.th-tip.idle { color: var(--ink-faint); background: transparent; border-color: var(--line); font-weight: 500; }
.save-btn { padding: 10px 22px; }
.ex-head { display: flex; align-items: baseline; gap: 10px; margin: 4px 2px 12px; }
.ex-title { font-size: 16px; font-weight: 700; }
.ex-sub { font-size: 12px; color: var(--ink-faint); }
.ex-table { margin-bottom: 14px; }
.pkg-cell { font-weight: 600; word-break: break-all; }
.empty-tip { padding: 30px 0; text-align: center; color: var(--ink-faint); font-size: 12.5px; }
.add-ex {
display: flex;
align-items: center;
gap: 10px;
background: var(--card);
border: 1.5px dashed color-mix(in srgb, var(--orange) 70%, transparent);
border-radius: var(--r-lg);
padding: 14px;
flex-wrap: wrap;
}
.add-ex input {
background: var(--card);
border: 1.5px solid var(--line);
border-radius: 12px;
padding: 10px 14px;
font-size: 13px;
color: var(--ink);
font-family: var(--sans);
outline: none;
transition: .16s;
}
.add-ex input::placeholder { color: var(--ink-faint); }
.add-ex input:focus { border-color: var(--orange); box-shadow: 0 0 0 3px rgba(255, 140, 0, .12); }
.ae-pkg { flex: 1; min-width: 200px; }
.ae-num { width: 130px; }
@media (max-width: 767px) {
.th-grid { grid-template-columns: 1fr; gap: 12px; }
.th-card { padding: 16px 16px; }
.th-num input { width: 72px; font-size: 19px; }
.add-ex { flex-direction: column; align-items: stretch; }
.add-ex .ae-pkg, .add-ex .ae-num { width: 100%; }
.ex-table { overflow-x: auto; }
.ex-table table { min-width: 560px; }
}
</style>
+31
View File
@@ -0,0 +1,31 @@
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
imports: ['vue', 'vue-router'],
dts: false,
}),
Components({
resolvers: [ElementPlusResolver()],
dts: false,
}),
],
server: {
port: 5173,
proxy: {
// 本地开发代理到后端 API
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
})