feat: 新增热门 npm 包清单,定时收录榜单并支持按清单拉取缓存
- 内置热门包池(248条/12品类) ∪ npm-search 榜单(14组关键词top300),按包名去重增量收录 - 榜单源优先私服 /-/v1/search,不可用时回退官方 registry,仅提示来源不阻塞 - 清单 CRUD + 手动/定时拉取(失败重试3次) + 进度轮询 + 失败包红色角标 - 清单全部完成即失效并联动停止定时拉取,写入新 pending 包自动恢复 - 前端:我的缓存页包清单抽屉、用户名下拉定时配置弹窗、mock 分支同步
This commit is contained in:
+130
-1
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user