feat: 新增热门 npm 包清单,定时收录榜单并支持按清单拉取缓存
CI / Backend tests (push) Canceled after 0s
CI / Frontend build (push) Canceled after 0s

- 内置热门包池(248条/12品类) ∪ npm-search 榜单(14组关键词top300),按包名去重增量收录
- 榜单源优先私服 /-/v1/search,不可用时回退官方 registry,仅提示来源不阻塞
- 清单 CRUD + 手动/定时拉取(失败重试3次) + 进度轮询 + 失败包红色角标
- 清单全部完成即失效并联动停止定时拉取,写入新 pending 包自动恢复
- 前端:我的缓存页包清单抽屉、用户名下拉定时配置弹窗、mock 分支同步
This commit is contained in:
yinjun.chen1
2026-09-21 15:47:22 +08:00
parent a00a58af30
commit bb7d45efcf
11 changed files with 2001 additions and 7 deletions
+130 -1
View File
@@ -347,7 +347,7 @@ async login(username, password, remember = true) {
const r = await authFetch(`/api/packages/${encodeURIComponent(pkgName)}/versions/${encodeURIComponent(version)}`, {
method: 'DELETE',
})
return r.json()
return r.json()
},
async getLogs() {
@@ -358,6 +358,135 @@ async login(username, password, remember = true) {
const r = await authFetch('/api/logs')
return r.json()
},
// —— npm 包清单(热门包缓存扩充) ——
/** 读取清单:items(含 status/auto)+ active(是否有待拉取包) */
async getPkgList() {
if (USE_MOCK) {
await delay(80)
const items = clone(mock.pkglistItems)
return { ok: true, items, active: items.some(i => i.status === 'pending') }
}
const r = await authFetch('/api/pkglist')
return r.json()
},
/** 新增清单条目(pkgName 必填;version 空 = 最新版) */
async addPkgItem(payload) {
if (USE_MOCK) {
await delay()
const { pkgName = '', version = '', category = '' } = payload
const name = String(pkgName).trim()
if (!name) return { ok: false, message: '包名不能为空' }
if (mock.pkglistItems.some(i => i.pkgName === name)) return { ok: false, message: `「${name}」已在清单中` }
mock.pkglistItems.push({
id: Date.now(), pkgName: name, version: String(version || '').trim(),
category: String(category || '').trim() || '其他', status: 'pending', auto: 0,
createdAt: new Date().toISOString().slice(0, 10),
})
return { ok: true, items: clone(mock.pkglistItems), active: true }
}
const r = await authFetch('/api/pkglist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
return r.json()
},
/** 编辑清单条目(包名/版本号/分类) */
async updatePkgItem(id, payload) {
if (USE_MOCK) {
await delay()
const it = mock.pkglistItems.find(i => i.id === Number(id))
if (!it) return { ok: false, message: '清单条目不存在' }
const { pkgName = '', version = '', category = '' } = payload
const name = String(pkgName).trim()
if (!name) return { ok: false, message: '包名不能为空' }
const dup = mock.pkglistItems.some(i => i.id !== Number(id) && i.pkgName === name)
if (dup) return { ok: false, message: `「${name}」已在清单中` }
it.pkgName = name
it.version = String(version || '').trim()
it.category = String(category || '').trim() || '其他'
return { ok: true, items: clone(mock.pkglistItems), active: true }
}
const r = await authFetch(`/api/pkglist/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
return r.json()
},
/** 删除清单条目 */
async deletePkgItem(id) {
if (USE_MOCK) {
await delay()
const idx = mock.pkglistItems.findIndex(i => i.id === Number(id))
if (idx === -1) return { ok: false, message: '清单条目不存在' }
mock.pkglistItems.splice(idx, 1)
return { ok: true, items: clone(mock.pkglistItems), active: mock.pkglistItems.some(i => i.status === 'pending') }
}
const r = await authFetch(`/api/pkglist/${id}`, { method: 'DELETE' })
return r.json()
},
/** 按清单手动拉取(同步启动后台任务,立即返回;清单失效时返回 active=false) */
async pullPkgList() {
if (USE_MOCK) {
await delay(200)
const active = mock.pkglistItems.some(i => i.status === 'pending')
if (!active) return { ok: true, active: false, running: false, message: '当前无可用npm包' }
return { ok: true, demo: false, running: true, active: true, pendingCount: mock.pkglistItems.filter(i => i.status === 'pending').length }
}
const r = await authFetch('/api/pkglist/pull', { method: 'POST' })
return r.json()
},
/** 查询按清单拉取进度(含重试轮次 round 与失败明细 failedList) */
async getPkgPullProgress() {
if (USE_MOCK) {
await delay(60)
const pending = mock.pkglistItems.filter(i => i.status === 'pending')
const done = pending.length === 0
return {
running: false, done,
total: mock.pkglistItems.length, processed: mock.pkglistItems.length,
success: mock.pkglistItems.length - pending.length, failed: 0,
failedList: [], round: 3, current: null, source: 'manual', error: null,
startedAt: null, finishedAt: new Date().toISOString(),
}
}
const r = await authFetch('/api/pkglist/progress')
return r.json()
},
/** 读取清单定时更新/定时拉取配置 */
async getPkgListConfig() {
if (USE_MOCK) {
await delay(80)
return { ok: true, config: clone(mock.pkglistConfig) }
}
const r = await authFetch('/api/pkglist/config')
return r.json()
},
/** 保存清单定时更新/定时拉取配置 */
async savePkgListConfig(payload) {
if (USE_MOCK) {
await delay()
mock.pkglistConfig.update = clone(payload.update || mock.pkglistConfig.update)
mock.pkglistConfig.pull = clone(payload.pull || mock.pkglistConfig.pull)
return { ok: true, config: clone(mock.pkglistConfig) }
}
const r = await authFetch('/api/pkglist/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
return r.json()
},
}
export default api
+9 -3
View File
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useTheme } from '../composables/useTheme'
import { useStore } from '../store'
import api from '../api'
import PkgListConfigDialog from './PkgListConfigDialog.vue'
const route = useRoute()
const router = useRouter()
@@ -13,6 +14,7 @@ const { state } = useStore()
const emit = defineEmits(['rescan'])
const menuOpen = ref(false)
const pkgConfigOpen = ref(false)
const navs = [
{ path: '/', label: '缓存概览', emo: '🏠' },
@@ -29,12 +31,13 @@ function go(path) {
async function pick(action) {
if (action === 'theme') {
// 主题切换即时生效,保持菜单打开以便反复对比
toggle()
toggle()
return
}
menuOpen.value = false
if (action === 'rescan') emit('rescan')
else if (action === 'thresholds') router.push('/thresholds')
else if (action === 'pkgConfig') pkgConfigOpen.value = true
else if (action === 'logout') {
await api.logout() // 先销毁后端会话并清本地 token,再跳登录页(否则守卫会因 token 仍在而重定向回首页)
router.push('/login')
@@ -80,17 +83,20 @@ onBeforeUnmount(() => document.removeEventListener('click', onDocClick))
<div class="avatar-wrap">
<div class="avatar" title="用户菜单" @click="menuOpen = !menuOpen">👤</div>
<transition name="pop">
<div v-if="menuOpen" class="avatar-menu">
<div v-if="menuOpen" class="avatar-menu">
<button class="am-item" :disabled="state.loading" @click="pick('rescan')">{{ state.loading ? '⏳ 扫描中…' : '🔄 重新扫描' }}</button>
<button class="am-item" @click="pick('theme')">{{ theme === 'dark' ? '🌙 主题切换 · 暗色' : '☀️ 主题切换 · 亮色' }}</button>
<button class="am-item" @click="pick('thresholds')">⚙️ 预警设置</button>
<button class="am-item" @click="pick('pkgConfig')">⏱️ 包清单定时配置</button>
<div class="am-info">📦 版本信息 <span>v1.0.0</span></div>
<button class="am-item danger" @click="pick('logout')">🚪 退出登录</button>
</div>
</transition>
</div>
</div>
</div>
</header>
<PkgListConfigDialog v-model:visible="pkgConfigOpen" />
</template>
<style scoped>
@@ -0,0 +1,257 @@
<script setup>
/**
* npm 包清单定时配置弹窗:
* - 定时更新:按 cron 定时向清单注入最新热门包 / 刷新版本
* - 定时拉取:按 cron 定时执行按清单拉取(清单失效后自动停止,直到有新包写入)
* 配置持久化在后端 settings KV,右上角用户名下拉菜单进入
*/
import { ref, watch } from 'vue'
import { useStore } from '../store'
const props = defineProps({
visible: Boolean,
})
const emit = defineEmits(['update:visible'])
const { state, refreshPkgListConfig, savePkgListConfig } = useStore()
const form = ref({ update: { enabled: false, cron: '0 3 * * *' }, pull: { enabled: false, cron: '0 6 * * *' } })
const saving = ref(false)
const cronError = ref('')
const CRON_PATTERN = /^(\S+ ){4}\S+$/
function validateCron(key) {
const c = (form.value[key].cron || '').trim()
if (!c) return `${key === 'update' ? '定时更新' : '定时拉取'}:cron 表达式不能为空`
if (!CRON_PATTERN.test(c)) return `${key === 'update' ? '定时更新' : '定时拉取'}:cron 表达式格式应为 5 段(分 时 日 月 周),如 0 3 * * *`
return ''
}
// 打开时从 store 载入最新配置
watch(() => props.visible, async (v) => {
if (v) {
const r = await refreshPkgListConfig()
if (r && r.ok) {
form.value = {
update: { enabled: !!r.config.update.enabled, cron: r.config.update.cron || '0 3 * * *' },
pull: { enabled: !!r.config.pull.enabled, cron: r.config.pull.cron || '0 6 * * *' },
}
}
cronError.value = ''
}
})
function close() {
emit('update:visible', false)
}
async function save() {
const errU = validateCron('update')
const errP = validateCron('pull')
cronError.value = errU || errP
if (cronError.value) return
saving.value = true
try {
const r = await savePkgListConfig({
update: { enabled: form.value.update.enabled, cron: form.value.update.cron.trim() },
pull: { enabled: form.value.pull.enabled, cron: form.value.pull.cron.trim() },
})
if (r && r.ok) {
ElMessage.success('定时配置已保存')
close()
} else {
ElMessage.warning((r && r.message) || '保存失败')
}
} finally {
saving.value = false
}
}
</script>
<template>
<Transition name="pld">
<div v-if="visible" class="cfg-mask" @click.self="close">
<div class="cfg-panel">
<div class="cfg-head">
<div>
<div class="cfg-title">⏱️ 包清单定时配置</div>
<div class="cfg-sub">cron 格式:分 时 日 月 周 · 如 <code>0 3 * * *</code> = 每天 03:00</div>
</div>
<button class="cfg-x" title="关闭" @click="close">✕</button>
</div>
<div class="cfg-block">
<div class="cfg-item">
<label class="cfg-switch">
<input v-model="form.update.enabled" type="checkbox" />
<span class="cfg-track"><i></i></span>
<b>定时更新清单</b>
</label>
<p class="cfg-desc">按周期向清单汇集最新热门 npm 包、刷新版本号(存量缓存之外的 vue / react / nodejs 生态包)</p>
<div class="cfg-cron">
<input v-model="form.update.cron" type="text" placeholder="0 3 * * *" :disabled="!form.update.enabled" />
<span class="cfg-cron-label">cron</span>
</div>
</div>
<div class="cfg-sep"></div>
<div class="cfg-item">
<label class="cfg-switch">
<input v-model="form.pull.enabled" type="checkbox" />
<span class="cfg-track"><i></i></span>
<b>定时拉取</b>
</label>
<p class="cfg-desc">按周期使用私服镜像源拉取清单内 npm 包;清单全部缓存完成后自动停止,直到有新包写入</p>
<div class="cfg-cron">
<input v-model="form.pull.cron" type="text" placeholder="0 6 * * *" :disabled="!form.pull.enabled" />
<span class="cfg-cron-label">cron</span>
</div>
</div>
</div>
<div v-if="cronError" class="cfg-error">⚠️ {{ cronError }}</div>
<div class="cfg-ops">
<button class="cfg-btn" @click="close">取消</button>
<button class="cfg-btn primary" :disabled="saving" @click="save">{{ saving ? '保存中…' : '保存配置' }}</button>
</div>
</div>
</div>
</Transition>
</template>
<style scoped>
.cfg-mask {
position: fixed;
inset: 0;
z-index: 460;
background: rgba(10, 8, 6, .4);
backdrop-filter: blur(3px);
display: flex;
align-items: center;
justify-content: center;
}
.cfg-panel {
width: min(520px, 92vw);
background: var(--card);
border: 1px solid var(--line);
border-radius: 18px;
box-shadow: 0 30px 70px -20px rgba(0, 0, 0, .5);
padding: 22px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.cfg-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.cfg-title { font-size: 16px; font-weight: 800; }
.cfg-sub { font-size: 12px; color: var(--ink-faint); margin-top: 4px; }
.cfg-sub code {
font-family: var(--num);
background: var(--num-bg);
padding: 2px 6px;
border-radius: 6px;
color: var(--orange);
}
.cfg-x {
width: 32px;
height: 32px;
border-radius: 10px;
border: 1.5px solid var(--line);
background: var(--card);
color: var(--ink-dim);
cursor: pointer;
font-size: 13px;
flex-shrink: 0;
}
.cfg-x:hover { border-color: var(--red); color: var(--red); }
.cfg-block {
display: flex;
flex-direction: column;
gap: 14px;
background: var(--panel-soft);
border: 1px solid var(--line);
border-radius: 14px;
padding: 16px;
}
.cfg-item { display: flex; flex-direction: column; gap: 9px; }
.cfg-sep { height: 1px; background: var(--line); }
.cfg-switch {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
user-select: none;
}
.cfg-switch input { display: none; }
.cfg-track {
width: 40px;
height: 22px;
border-radius: 99px;
background: var(--num-bg);
border: 1px solid var(--line);
position: relative;
transition: .18s;
flex-shrink: 0;
}
.cfg-track i {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
box-shadow: 0 2px 6px rgba(0, 0, 0, .25);
transition: .18s;
}
.cfg-switch input:checked + .cfg-track { background: var(--green); border-color: var(--green); }
.cfg-switch input:checked + .cfg-track i { left: 20px; }
.cfg-switch b { font-size: 13.5px; font-weight: 700; }
.cfg-desc { font-size: 12px; color: var(--ink-dim); line-height: 1.6; }
.cfg-cron { display: flex; align-items: center; gap: 8px; }
.cfg-cron input {
flex: 1;
padding: 9px 13px;
border-radius: 10px;
border: 1.5px solid var(--line);
background: var(--card);
color: var(--ink);
font-size: 13px;
font-family: var(--num);
outline: none;
transition: .15s;
max-width: 220px;
}
.cfg-cron input:focus { border-color: var(--orange); }
.cfg-cron input:disabled { opacity: .45; cursor: not-allowed; }
.cfg-cron-label { font-size: 11px; color: var(--ink-faint); font-weight: 700; }
.cfg-error {
padding: 9px 13px;
border-radius: 10px;
background: var(--red-soft);
color: var(--red);
font-size: 12px;
font-weight: 600;
}
.cfg-ops { display: flex; justify-content: flex-end; gap: 8px; }
.cfg-btn {
padding: 9px 18px;
border-radius: 10px;
border: 1.5px solid var(--line);
background: var(--card);
color: var(--ink-dim);
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
transition: .15s;
}
.cfg-btn:hover { border-color: var(--orange); color: var(--orange); }
.cfg-btn.primary { background: var(--grad-main); border-color: transparent; color: #fff; box-shadow: 0 8px 18px -6px rgba(255, 140, 0, .55); }
.cfg-btn.primary:hover { transform: translateY(-1px); }
.cfg-btn:disabled { opacity: .55; cursor: not-allowed; }
.pld-enter-active, .pld-leave-active { transition: opacity .18s; }
.pld-enter-from, .pld-leave-to { opacity: 0; }
</style>
+336
View File
@@ -0,0 +1,336 @@
<script setup>
/**
* npm 包清单抽屉:查看 / 新增 / 编辑 / 删除清单条目
* 数据源:store.state.pkgList(items + active)
* 失效联动:全部条目拉取成功后 active=false,展示"当前无可用npm包"提示
*/
import { ref, computed, watch } from 'vue'
import { useStore } from '../store'
const props = defineProps({
visible: Boolean,
})
const emit = defineEmits(['update:visible', 'pulled'])
const { state, refreshPkgList, addPkgItem, updatePkgItem, deletePkgItem } = useStore()
// —— 新增 / 编辑弹窗 ——
const formOpen = ref(false)
const editingId = ref(null) // null = 新增
const form = ref({ pkgName: '', version: '', category: '' })
function openAdd() {
editingId.value = null
form.value = { pkgName: '', version: '', category: '' }
formOpen.value = true
}
function openEdit(item) {
editingId.value = item.id
form.value = { pkgName: item.pkgName, version: item.version || '', category: item.category || '' }
formOpen.value = true
}
function closeForm() {
formOpen.value = false
}
// 清单是否已失效(条目都在且全部拉取完成)
const inactive = computed(() => state.pkgList.items.length > 0 && !state.pkgList.active)
// 待拉取条目数
const pendingCount = computed(() => state.pkgList.items.filter(i => i.status === 'pending').length)
async function submitForm() {
const payload = {
pkgName: form.value.pkgName.trim(),
version: form.value.version.trim(),
category: form.value.category.trim(),
}
if (!payload.pkgName) {
ElMessage.warning('包名不能为空')
return
}
const r = editingId.value == null
? await addPkgItem(payload)
: await updatePkgItem(editingId.value, payload)
if (!r || !r.ok) {
ElMessage.warning((r && r.message) || '保存失败')
return
}
ElMessage.success(editingId.value == null ? '已添加到清单' : '已保存修改')
closeForm()
}
async function doDelete(item) {
try {
await ElMessageBox.confirm(
`确定将「${item.pkgName}」移出清单吗?若尚未完成拉取,该包将不再被缓存。`,
'移出清单',
{ type: 'warning', confirmButtonText: '移出', cancelButtonText: '取消' },
)
} catch {
return
}
const r = await deletePkgItem(item.id)
if (r && r.ok) {
ElMessage.success('已移出清单')
} else {
ElMessage.warning((r && r.message) || '删除失败')
}
}
function close() {
emit('update:visible', false)
}
// 打开时刷新一次清单,保证最新状态
watch(() => props.visible, (v) => {
if (v) refreshPkgList()
})
</script>
<template>
<Transition name="pld">
<div v-if="visible" class="pld-mask" @click.self="close">
<div class="pld-panel">
<div class="pld-head">
<div>
<div class="pld-title">📦 npm 包清单</div>
<div class="pld-sub">存量缓存之外的精选热门包 · 共 {{ state.pkgList.items.length }} 条<span v-if="pendingCount"> · 待拉取 {{ pendingCount }}</span></div>
</div>
<button class="pld-x" title="关闭" @click="close">✕</button>
</div>
<!-- 清单失效提示 -->
<div v-if="inactive" class="pld-inactive">
✅ 清单内 npm 包已全部缓存完成,当前无可用npm包;新增或修改条目后即可恢复拉取
</div>
<div class="pld-toolbar">
<button class="pld-btn primary" @click="openAdd">+ 新增包</button>
<button class="pld-btn" title="刷新清单" @click="refreshPkgList">🔄 刷新</button>
</div>
<div class="pld-table">
<table>
<thead>
<tr>
<th>包名</th>
<th style="width: 96px">版本</th>
<th style="width: 108px">分类</th>
<th style="width: 64px">来源</th>
<th style="width: 78px">状态</th>
<th style="width: 96px">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="it in state.pkgList.items" :key="it.id">
<td class="pld-name" :title="it.pkgName">{{ it.pkgName }}</td>
<td><span class="pld-ver" :class="{ latest: !it.version }">{{ it.version || '最新版' }}</span></td>
<td><span class="pld-cat">{{ it.category || '—' }}</span></td>
<td><span class="pld-src" :class="{ auto: !!it.auto }">{{ it.auto ? '内置' : '手工' }}</span></td>
<td>
<span class="pld-status" :class="it.status === 'done' ? 'done' : 'pending'">
{{ it.status === 'done' ? '✅ 已完成' : '⏳ 待拉取' }}
</span>
</td>
<td class="pld-ops">
<button class="pld-mini" @click="openEdit(it)">编辑</button>
<button class="pld-mini danger" @click="doDelete(it)">删除</button>
</td>
</tr>
</tbody>
</table>
<div v-if="!state.pkgList.items.length" class="pld-empty">
清单为空,点击「+ 新增包」从内置热门包池加入,或手工填写包名
</div>
</div>
</div>
</div>
</Transition>
<!-- 新增 / 编辑弹窗 -->
<Transition name="pld">
<div v-if="formOpen" class="pld-mask pld-form-mask" @click.self="closeForm">
<div class="pld-form">
<div class="pld-form-title">{{ editingId == null ? '新增 npm 包' : '编辑 npm 包' }}</div>
<div class="pld-field">
<label>包名 <i>*</i></label>
<input v-model="form.pkgName" type="text" placeholder="如:monaco-editor / @antv/g6" @keyup.enter="submitForm" />
</div>
<div class="pld-field">
<label>版本号 <i>选填</i></label>
<input v-model="form.version" type="text" placeholder="不填则默认拉取最新版" @keyup.enter="submitForm" />
</div>
<div class="pld-field">
<label>分类 <i>选填</i></label>
<input v-model="form.category" type="text" placeholder="如:可视化图表 / 工具函数" @keyup.enter="submitForm" />
</div>
<div class="pld-form-ops">
<button class="pld-btn" @click="closeForm">取消</button>
<button class="pld-btn primary" @click="submitForm">保存</button>
</div>
</div>
</div>
</Transition>
</template>
<style scoped>
.pld-mask {
position: fixed;
inset: 0;
z-index: 400;
background: rgba(10, 8, 6, .4);
backdrop-filter: blur(3px);
}
.pld-panel {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: min(680px, 94vw);
background: var(--card);
border-left: 1px solid var(--line);
box-shadow: -24px 0 60px -18px rgba(0, 0, 0, .4);
display: flex;
flex-direction: column;
padding: 20px 22px;
gap: 14px;
}
.pld-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.pld-title { font-size: 16.5px; font-weight: 800; }
.pld-sub { font-size: 12px; color: var(--ink-faint); margin-top: 3px; font-family: var(--num); }
.pld-x {
width: 32px;
height: 32px;
border-radius: 10px;
border: 1.5px solid var(--line);
background: var(--card);
color: var(--ink-dim);
cursor: pointer;
font-size: 13px;
flex-shrink: 0;
}
.pld-x:hover { border-color: var(--red); color: var(--red); }
.pld-inactive {
padding: 11px 14px;
border-radius: 12px;
background: var(--green-soft);
color: var(--green-deep);
font-size: 12.5px;
font-weight: 600;
line-height: 1.6;
}
.pld-toolbar {
display: flex;
align-items: center;
gap: 8px;
}
.pld-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: 10px;
border: 1.5px solid var(--line);
background: var(--card);
color: var(--ink-dim);
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
transition: .15s;
}
.pld-btn:hover { border-color: color-mix(in srgb, var(--orange) 60%, transparent); color: var(--orange); }
.pld-btn.primary { background: var(--grad-main); border-color: transparent; color: #fff; box-shadow: 0 8px 18px -6px rgba(255, 140, 0, .55); }
.pld-btn.primary:hover { transform: translateY(-1px); }
.pld-table {
flex: 1;
overflow: auto;
border: 1.5px solid var(--line);
border-radius: 14px;
}
.pld-table table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.pld-table th {
position: sticky;
top: 0;
background: var(--panel-soft);
color: var(--ink-faint);
font-size: 11.5px;
text-align: left;
padding: 10px 12px;
border-bottom: 1px solid var(--line);
z-index: 1;
}
.pld-table td { padding: 9px 12px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
.pld-name { font-weight: 700; word-break: break-all; max-width: 200px; }
.pld-ver { font-family: var(--num); color: var(--ink-dim); }
.pld-ver.latest { color: var(--green-deep); font-weight: 600; }
.pld-cat {
display: inline-block;
padding: 2px 8px;
border-radius: 99px;
background: var(--num-bg);
color: var(--ink-dim);
font-size: 11px;
font-weight: 600;
}
.pld-src { font-size: 11px; color: var(--ink-faint); }
.pld-src.auto { color: var(--orange); font-weight: 600; }
.pld-status { font-size: 11.5px; font-weight: 600; }
.pld-status.done { color: var(--green-deep); }
.pld-status.pending { color: var(--orange); }
.pld-ops { white-space: nowrap; }
.pld-mini {
padding: 4px 10px;
border-radius: 8px;
border: 1px solid var(--line);
background: var(--card);
color: var(--ink-dim);
font-size: 11.5px;
font-weight: 600;
cursor: pointer;
margin-right: 6px;
transition: .14s;
}
.pld-mini:hover { border-color: var(--orange); color: var(--orange); }
.pld-mini.danger:hover { border-color: var(--red); color: var(--red); }
.pld-empty { padding: 44px 0; text-align: center; color: var(--ink-faint); font-size: 12.5px; }
/* —— 新增/编辑表单 —— */
.pld-form-mask { z-index: 450; display: flex; align-items: center; justify-content: center; }
.pld-form {
width: min(420px, 92vw);
background: var(--card);
border: 1px solid var(--line);
border-radius: 18px;
box-shadow: 0 30px 70px -20px rgba(0, 0, 0, .5);
padding: 22px 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
.pld-form-title { font-size: 15px; font-weight: 800; }
.pld-field label { display: block; font-size: 12px; font-weight: 700; color: var(--ink-dim); margin-bottom: 6px; }
.pld-field label i { font-style: normal; color: var(--ink-faint); font-weight: 500; }
.pld-field input {
width: 100%;
box-sizing: border-box;
padding: 10px 13px;
border-radius: 11px;
border: 1.5px solid var(--line);
background: var(--panel-soft);
color: var(--ink);
font-size: 13px;
font-family: var(--sans);
outline: none;
transition: .15s;
}
.pld-field input:focus { border-color: var(--orange); background: var(--card); }
.pld-form-ops { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
.pld-enter-active, .pld-leave-active { transition: opacity .18s; }
.pld-enter-from, .pld-leave-to { opacity: 0; }
</style>
+16 -1
View File
@@ -150,6 +150,21 @@ const logs = [
{ id: 1, action: 'clean', pkgName: 'axios', deletedVersions: '0.19.0', deletedCount: 1, freedSpace: '1.1 MB', created_at: dateStr(2) + ' 11:05:47' },
]
// —— npm 包清单(热门包缓存扩充,mock 分支使用) ——
const pkglistItems = [
{ id: 1, pkgName: 'monaco-editor', version: '', category: '代码编辑器', status: 'done', auto: 1, createdAt: dateStr(2) },
{ id: 2, pkgName: '@monaco-editor/react', version: '', category: '代码编辑器', status: 'done', auto: 1, createdAt: dateStr(2) },
{ id: 3, pkgName: 'codemirror', version: '', category: '代码编辑器', status: 'pending', auto: 1, createdAt: dateStr(2) },
{ id: 4, pkgName: '@codemirror/lang-json', version: '', category: 'JSON 编辑器', status: 'pending', auto: 1, createdAt: dateStr(2) },
{ id: 5, pkgName: 'echarts', version: '', category: '可视化图表', status: 'pending', auto: 1, createdAt: dateStr(2) },
{ id: 6, pkgName: '@antv/g6', version: '5.0.0', category: '流程图', status: 'pending', auto: 1, createdAt: dateStr(2) },
{ id: 7, pkgName: 'xlsx', version: '', category: '工具函数', status: 'pending', auto: 0, createdAt: dateStr(1) },
]
const pkglistConfig = {
update: { enabled: false, cron: '0 3 * * *' },
pull: { enabled: false, cron: '0 6 * * *' },
}
// 状态统计(与 overview 对齐)
function summary() {
const valid = packages.filter(p => p.status !== 'broken')
@@ -173,4 +188,4 @@ function summary() {
}
}
export { packages, top30, logs, summary, fmtSize }
export { packages, top30, logs, summary, fmtSize, pkglistItems, pkglistConfig }
+141
View File
@@ -24,6 +24,17 @@ const state = reactive({
running: false, done: false, total: 0, processed: 0,
success: 0, skipped: 0, failed: 0, failedList: [], error: null,
},
// npm 包清单(热门包缓存扩充):items + active(是否存在待拉取包)
pkgList: { items: [], active: false },
// 按清单拉取进度(含重试轮次 round 与失败明细 failedList,供角标展示)
pkgPullProgress: {
running: false, done: false, total: 0, processed: 0,
success: 0, failed: 0, failedList: [], round: 0,
current: null, source: 'manual', error: null,
startedAt: null, finishedAt: null,
},
// 清单定时更新 / 定时拉取配置
pkgListConfig: { update: { enabled: false, cron: '' }, pull: { enabled: false, cron: '' } },
})
// —— 派生统计 ——
@@ -258,6 +269,128 @@ async function deleteVersion(name, version) {
return res
}
// —— npm 包清单(热门包缓存扩充) ——
async function refreshPkgList() {
const r = await api.getPkgList()
if (r && r.ok) {
state.pkgList.items = r.items || []
state.pkgList.active = !!r.active
}
return r
}
/** 新增/编辑/删除清单条目后刷新清单与配置(新包写入会使定时拉取恢复) */
async function addPkgItem(payload) {
const r = await api.addPkgItem(payload)
if (r && r.ok) {
state.pkgList.items = r.items || []
state.pkgList.active = !!r.active
}
return r
}
async function updatePkgItem(id, payload) {
const r = await api.updatePkgItem(id, payload)
if (r && r.ok) {
state.pkgList.items = r.items || []
state.pkgList.active = !!r.active
}
return r
}
async function deletePkgItem(id) {
const r = await api.deletePkgItem(id)
if (r && r.ok) {
state.pkgList.items = r.items || []
state.pkgList.active = !!r.active
}
return r
}
function resetPkgPullProgress(res) {
state.pkgPullProgress.running = true
state.pkgPullProgress.done = false
state.pkgPullProgress.total = res.total || 0
state.pkgPullProgress.processed = 0
state.pkgPullProgress.success = 0
state.pkgPullProgress.failed = 0
state.pkgPullProgress.failedList = []
state.pkgPullProgress.round = 0
state.pkgPullProgress.current = null
state.pkgPullProgress.error = null
state.pkgPullProgress.startedAt = res.startedAt || null
}
/** 按清单手动拉取:触发后台任务并轮询进度;清单失效时提示无可用包 */
async function triggerPkgPull() {
try {
const res = await api.pullPkgList()
if (!res || !res.ok) return res || { ok: false }
if (res.demo) return res
if (res.active === false) {
state.pkgPullProgress.running = false
state.pkgPullProgress.done = true
return res // 当前无可用 npm 包
}
resetPkgPullProgress(res)
pollPkgPullProgress()
return res
} catch (e) {
ElMessage.error(e?.message || '发起拉取失败,请稍后重试')
return { ok: false }
}
}
let pkgPullTimer = null
/** 轮询按清单拉取进度;结束后刷新清单(状态置 done)与操作日志 */
function pollPkgPullProgress() {
clearTimeout(pkgPullTimer)
const tick = async () => {
let p
try {
p = await api.getPkgPullProgress()
} catch {
state.pkgPullProgress.running = false
state.pkgPullProgress.error = '拉取进度查询失败'
return
}
state.pkgPullProgress.running = !!p.running
state.pkgPullProgress.done = !!p.done
state.pkgPullProgress.total = p.total || 0
state.pkgPullProgress.processed = p.processed || 0
state.pkgPullProgress.success = p.success || 0
state.pkgPullProgress.failed = p.failed || 0
state.pkgPullProgress.failedList = p.failedList || []
state.pkgPullProgress.round = p.round || 0
state.pkgPullProgress.current = p.current || null
state.pkgPullProgress.source = p.source || state.pkgPullProgress.source
state.pkgPullProgress.error = p.error || null
if (p.startedAt) state.pkgPullProgress.startedAt = p.startedAt
if (p.finishedAt) state.pkgPullProgress.finishedAt = p.finishedAt
if (p.running) {
pkgPullTimer = setTimeout(tick, 800)
} else {
clearTimeout(pkgPullTimer)
await Promise.all([refreshPkgList(), refreshLogs()])
}
}
tick()
}
async function refreshPkgListConfig() {
const r = await api.getPkgListConfig()
if (r && r.ok) state.pkgListConfig = r.config
return r
}
async function savePkgListConfig(payload) {
const r = await api.savePkgListConfig(payload)
if (r && r.ok) state.pkgListConfig = r.config
return r
}
export function useStore() {
return {
state,
@@ -274,5 +407,13 @@ scanNow,
unignorePkg,
cleanPkg,
deleteVersion,
refreshPkgList,
addPkgItem,
updatePkgItem,
deletePkgItem,
triggerPkgPull,
pollPkgPullProgress,
refreshPkgListConfig,
savePkgListConfig,
}
}
+147 -2
View File
@@ -3,12 +3,14 @@ import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import PackageDrawer from '../components/PackageDrawer.vue'
import CleanDialog from '../components/CleanDialog.vue'
import PkgListDrawer from '../components/PkgListDrawer.vue'
import StatusTag from '../components/StatusTag.vue'
import PkgLogo from '../components/PkgLogo.vue'
import { useStore, fmtSize } from '../store'
import { storage } from '../utils/storage'
const { state, stats, refreshPackages, ignorePkg, unignorePkg, cleanPkg, deleteVersion, syncLatest } = useStore()
const { state, stats, refreshPackages, ignorePkg, unignorePkg, cleanPkg, deleteVersion, syncLatest,
refreshPkgList, refreshPkgListConfig, triggerPkgPull } = useStore()
const route = useRoute()
const router = useRouter()
@@ -92,6 +94,8 @@ onMounted(() => {
statusFilter.value = route.query.status
}
if (route.query.updatedToday === '1') todayFilter.value = true
refreshPkgList()
refreshPkgListConfig()
})
onUnmounted(() => {
document.removeEventListener('click', closeMenus)
@@ -221,6 +225,82 @@ watch(
}
},
)
// —— npm 包清单:查看/编辑入口 + 按清单手动拉取(后台执行,失败自动重试 3 次) ——
const pkgListOpen = ref(false)
const pullLoading = ref(null)
let retryNotified = false
function closePullLoading() {
if (pullLoading.value) {
pullLoading.value.close()
pullLoading.value = null
}
}
async function doPkgPull() {
const pp = state.pkgPullProgress
if (pp.running) return
const res = await triggerPkgPull()
if (!res || !res.ok) return
if (res.demo) {
ElMessage.info(res.message || '演示模式:不支持拉取')
return
}
if (res.active === false) {
ElMessage.info('当前无可用npm包')
await refreshPkgList()
return
}
retryNotified = false
closePullLoading()
pullLoading.value = ElMessage({ type: 'loading', message: '正在拉取npm包…', duration: 0 })
// 任务已进入后台执行,进度由 store 轮询更新(running … done),不阻塞当前操作
}
// 重试轮次变化(round ≥ 2 表示第一次重试开始)→ 轻量提示
watch(
() => state.pkgPullProgress.round,
(round) => {
if (round >= 2 && !retryNotified) {
retryNotified = true
closePullLoading()
pullLoading.value = ElMessage({ type: 'loading', message: '正在重新拉取npm包…', duration: 0 })
}
},
)
// 拉取结束(running → false)→ 结束轻量提示;全部成功则清单失效
watch(
() => state.pkgPullProgress.running,
async (running, prev) => {
if (prev === true && running === false) {
closePullLoading()
const pp = state.pkgPullProgress
if (pp.failed === 0 && pp.total > 0) {
ElMessage.success(`npm 包拉取完成,共缓存 ${pp.success} 个,清单已全部覆盖`)
} else if (pp.failed > 0) {
ElMessage.warning(`拉取完成:成功 ${pp.success} 个,失败 ${pp.failed} 个(未缓存)`)
}
await refreshPkgList()
}
},
)
// 失败包角标:展示累计失败数量,点击弹出明细列表
function showFailedList() {
const pp = state.pkgPullProgress
if (!pp.failedList.length) return
const detail = pp.failedList.map(f => `· ${f.name || f}${f.reason ? `:${f.reason}` : ''}`)
ElMessageBox.alert(detail.join('\n'), `拉取失败 ${pp.failed} 个包(连续重试 3 次后仍未成功)`, {
confirmButtonText: '知道了',
type: 'warning',
customStyle: { maxHeight: '60vh', overflow: 'auto' },
}).catch(() => {})
}
// 清单待拉取数量(显示在「包清单」入口角标上)
const pendingCount = computed(() => state.pkgList.items.filter(i => i.status === 'pending').length)
</script>
<template>
@@ -255,6 +335,30 @@ watch(
<template v-if="todayFilter">(<span class="today-chip">仅今日更新 {{ todayStr }} <i @click="clearTodayFilter" title="清除筛选">✕</i></span>)</template>
</div>
<div class="tb-actions">
<!-- npm 包清单:查看/编辑/删除入口 -->
<button
class="dd-btn pkglist-btn"
:class="{ on: pkgListOpen }"
title="npm 包清单(查看/编辑/删除)"
@click.stop="pkgListOpen = true"
>📦<span v-if="pendingCount > 0" class="pkg-badge">{{ pendingCount }}</span></button>
<!-- 按清单手动拉取(后台执行;失败自动重试 3 次,重试后仍失败以角标展示) -->
<div class="dd pull-wrap">
<button
class="dd-btn pull-btn"
:class="{ on: state.pkgPullProgress.running }"
:title="state.pkgPullProgress.running
? `正在按清单拉取… ${state.pkgPullProgress.processed}/${state.pkgPullProgress.total}(第 ${state.pkgPullProgress.round} 轮)`
: '按 npm 包清单拉取(后台执行)'"
@click.stop="doPkgPull"
>{{ state.pkgPullProgress.running ? '⏳' : '⬇️' }}</button>
<span
v-if="state.pkgPullProgress.failed > 0"
class="fail-badge"
title="点击查看拉取失败明细"
@click.stop="showFailedList"
>{{ state.pkgPullProgress.failed }}</span>
</div>
<button
class="dd-btn sync-btn"
:class="{ on: state.syncProgress.running }"
@@ -347,13 +451,14 @@ watch(
@unignore="doUnignore"
@delete-version="doDeleteVersion"
/>
<CleanDialog
<CleanDialog
:visible="cleanVisible"
:pkg="cleanPkgData"
:threshold="state.threshold.versionThreshold"
@close="cleanVisible = false"
@confirmed="onCleanConfirmed"
/>
<PkgListDrawer v-model:visible="pkgListOpen" />
</template>
<style scoped>
@@ -503,6 +608,46 @@ watch(
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* npm 包清单入口角标 + 拉取按钮(含失败角标) */
.pkglist-btn { position: relative; font-size: 16px; }
.pkg-badge {
position: absolute;
top: -6px;
right: -6px;
min-width: 17px;
height: 17px;
padding: 0 4px;
border-radius: 99px;
background: var(--orange);
color: #fff;
font-size: 10px;
font-weight: 800;
display: grid;
place-items: center;
box-shadow: 0 4px 10px -2px rgba(255, 140, 0, .55);
}
.pull-wrap { position: relative; }
.pull-btn { font-size: 17px; }
.pull-btn.on { animation: sync-spin 1.1s linear infinite; }
.fail-badge {
position: absolute;
top: -7px;
right: -7px;
min-width: 18px;
height: 18px;
padding: 0 4px;
border-radius: 99px;
background: var(--red);
color: #fff;
font-size: 10.5px;
font-weight: 800;
display: grid;
place-items: center;
cursor: pointer;
box-shadow: 0 4px 10px -2px rgba(255, 60, 60, .55);
z-index: 2;
}
.fail-badge:hover { transform: scale(1.12); }
.dd-menu {
position: absolute;
top: calc(100% + 8px);