feat: Verdaccio 缓存可视化管理面板(前端 + 后端 + Docker)
- 前端 Vue3+Vite+Element Plus 按需引入,双主题(bright/dark)CSS 变量 - 三端响应式(桌面/平板/手机),登录/概览/检索/阈值/忽略/动态 6 页面 - 后端 Express + better-sqlite3,目录扫描 + demo 模式,清理原子写入 - Dockerfile 多阶段构建 + docker-compose(storage 只读挂载)
This commit is contained in:
@@ -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?
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+1136
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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> <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>
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"> </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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user