fix(mobile): 统一移动端汉堡菜单与PC端用户菜单选项
CI / Backend tests (push) Canceled after 0s
CI / Frontend build (push) Canceled after 0s

- SettingDrawer 新增「重新扫描」「包清单定时配置」,与 PC 端 AppTopbar pick() 保持一致
- 引入 useStore 读取 loading 状态,扫描中禁用重新扫描项
- 引入 PkgListConfigDialog,点击「包清单定时配置」弹出与 PC 端一致
- App.vue SettingDrawer 新增 @rescan=doRescan 事件监听
- 补充 .disabled 样式,扫描时置灰不可点
This commit is contained in:
yinjun.chen1
2026-09-21 23:03:39 +08:00
parent 765d9f5170
commit 72699ae422
3 changed files with 396 additions and 371 deletions
+1 -1
View File
@@ -101,7 +101,7 @@ const fmtScanBytes = computed(() => {
</router-view> </router-view>
<MobileTabbar v-if="!isLogin() && isMobile()" :kb="kbOpen" /> <MobileTabbar v-if="!isLogin() && isMobile()" :kb="kbOpen" />
<SettingDrawer v-model="settingsOpen" /> <SettingDrawer v-model="settingsOpen" @rescan="doRescan" />
</template> </template>
<style scoped> <style scoped>
+26 -1
View File
@@ -1,13 +1,18 @@
<script setup> <script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useTheme } from '../composables/useTheme' import { useTheme } from '../composables/useTheme'
import { useStore } from '../store'
import api from '../api' import api from '../api'
import { storage } from '../utils/storage' import { storage } from '../utils/storage'
import PkgListConfigDialog from './PkgListConfigDialog.vue'
const props = defineProps({ modelValue: Boolean }) const props = defineProps({ modelValue: Boolean })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue', 'rescan'])
const router = useRouter() const router = useRouter()
const { theme, toggle } = useTheme() const { theme, toggle } = useTheme()
const { state } = useStore()
const pkgConfigOpen = ref(false)
const user = storage.getJSON('vcm-user', { name: '管理员', email: 'admin@verdaccio.local' }) const user = storage.getJSON('vcm-user', { name: '管理员', email: 'admin@verdaccio.local' })
@@ -15,11 +20,22 @@ function close() {
emit('update:modelValue', false) emit('update:modelValue', false)
} }
function goRescan() {
if (state.loading) return
close()
emit('rescan')
}
function goThresholds() { function goThresholds() {
close() close()
router.push('/thresholds') router.push('/thresholds')
} }
function goPkgConfig() {
close()
pkgConfigOpen.value = true
}
async function logout() { async function logout() {
await api.logout() // 先销毁后端会话并清本地 token,再跳登录页 await api.logout() // 先销毁后端会话并清本地 token,再跳登录页
close() close()
@@ -46,6 +62,9 @@ async function logout() {
<span>{{ user.email }}</span> <span>{{ user.email }}</span>
</div> </div>
</div> </div>
<div class="m-drawer-item" :class="{ disabled: state.loading }" @click="goRescan">
<span style="font-family: var(--sans)">{{ state.loading ? '⏳ 扫描中…' : '🔄 重新扫描' }}</span>
</div>
<div class="m-drawer-item" @click="toggle"> <div class="m-drawer-item" @click="toggle">
<span style="font-family: var(--sans)">🌙 主题切换</span> <span style="font-family: var(--sans)">🌙 主题切换</span>
<span>{{ theme === 'dark' ? '暗色 ▸' : '亮色 ▸' }}</span> <span>{{ theme === 'dark' ? '暗色 ▸' : '亮色 ▸' }}</span>
@@ -54,6 +73,10 @@ async function logout() {
<span style="font-family: var(--sans)">⚙️ 预警设置</span> <span style="font-family: var(--sans)">⚙️ 预警设置</span>
<span>阈值 ▸</span> <span>阈值 ▸</span>
</div> </div>
<div class="m-drawer-item" @click="goPkgConfig">
<span style="font-family: var(--sans)">⏱️ 包清单定时配置</span>
<span>定时 ▸</span>
</div>
<div class="m-drawer-item"> <div class="m-drawer-item">
<span style="font-family: var(--sans)">📦 版本信息</span> <span style="font-family: var(--sans)">📦 版本信息</span>
<span>v1.0.0</span> <span>v1.0.0</span>
@@ -65,6 +88,7 @@ async function logout() {
</aside> </aside>
</Transition> </Transition>
</Teleport> </Teleport>
<PkgListConfigDialog v-model:visible="pkgConfigOpen" />
</template> </template>
<style scoped> <style scoped>
@@ -149,6 +173,7 @@ async function logout() {
transition: .15s; transition: .15s;
} }
.m-drawer-item:hover { background: var(--orange-soft); color: var(--orange); } .m-drawer-item:hover { background: var(--orange-soft); color: var(--orange); }
.m-drawer-item.disabled { opacity: .5; pointer-events: none; }
.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 { 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 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-item:hover span:first-child { color: var(--orange); }
+369 -369
View File
@@ -1,6 +1,6 @@
<script setup> <script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue' import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import PackageDrawer from '../components/PackageDrawer.vue' import PackageDrawer from '../components/PackageDrawer.vue'
import CleanDialog from '../components/CleanDialog.vue' import CleanDialog from '../components/CleanDialog.vue'
import PkgListDrawer from '../components/PkgListDrawer.vue' import PkgListDrawer from '../components/PkgListDrawer.vue'
@@ -9,36 +9,36 @@ import PkgLogo from '../components/PkgLogo.vue'
import { useStore, fmtSize } from '../store' import { useStore, fmtSize } from '../store'
import { storage } from '../utils/storage' import { storage } from '../utils/storage'
const { state, stats, refreshPackages, ignorePkg, unignorePkg, cleanPkg, deleteVersion, syncLatest, const { state, refreshPackages, ignorePkg, unignorePkg, deleteVersion, syncLatest,
refreshPkgList, refreshPkgListConfig, triggerPkgPull } = useStore() refreshPkgList, refreshPkgListConfig, triggerPkgPull } = useStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const keyword = ref('') const keyword = ref('')
const statusFilter = ref('all') // all | warn | ok | ignored | broken const statusFilter = ref('all') // all | warn | ok | ignored | broken
const sortMode = ref('default') // default | versions-desc | size-desc | date-desc | date-asc const sortMode = ref('default') // default | versions-desc | size-desc | date-desc | date-asc
const isMobile = ref(false) const isMobile = ref(false)
let debounceTimer = null let debounceTimer = null
// —— 今日更新筛选(从概览「今日更新包数」卡片跳转进入) —— // —— 今日更新筛选(从概览「今日更新包数」卡片跳转进入) ——
// 与后端 lastUpdated 同口径:北京时间 YYYY-MM-DD // 与后端 lastUpdated 同口径:北京时间 YYYY-MM-DD
const todayStr = new Intl.DateTimeFormat('en-CA', { const todayStr = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(new Date()) }).format(new Date())
const todayFilter = ref(false) const todayFilter = ref(false)
function clearTodayFilter() { function clearTodayFilter() {
todayFilter.value = false todayFilter.value = false
router.replace({ path: '/search' }) router.replace({ path: '/search' })
} }
// —— 响应式 placeholder:移动端精简,桌面端完整 —— // —— 响应式 placeholder:移动端精简,桌面端完整 ——
function checkMobile() { function checkMobile() {
isMobile.value = window.matchMedia('(max-width: 767px)').matches isMobile.value = window.matchMedia('(max-width: 767px)').matches
} }
const placeholderText = computed(() => const placeholderText = computed(() =>
isMobile.value ? '搜索包名…(回车搜索 · 实时过滤)' : '搜索包名…(回车搜索 · 实时过滤 · 防抖 300ms)', isMobile.value ? '搜索包名…(回车搜索 · 实时过滤)' : '搜索包名…(回车搜索 · 实时过滤 · 防抖 300ms)',
) )
// —— 搜索历史(localStorage 最近 5 条,提交时记录) —— // —— 搜索历史(localStorage 最近 5 条,提交时记录) ——
const HISTORY_KEY = 'vcm-search-history' const HISTORY_KEY = 'vcm-search-history'
const searchHistory = ref(storage.getJSON(HISTORY_KEY, [])) const searchHistory = ref(storage.getJSON(HISTORY_KEY, []))
@@ -60,31 +60,31 @@ function clearHistory() {
searchHistory.value = [] searchHistory.value = []
storage.remove(HISTORY_KEY) storage.remove(HISTORY_KEY)
} }
// —— 表格右上角:筛选/排序下拉 —— // —— 表格右上角:筛选/排序下拉 ——
const filterOpen = ref(false) const filterOpen = ref(false)
const sortOpen = ref(false) const sortOpen = ref(false)
function toggleFilter() { function toggleFilter() {
filterOpen.value = !filterOpen.value filterOpen.value = !filterOpen.value
sortOpen.value = false sortOpen.value = false
} }
function toggleSort() { function toggleSort() {
sortOpen.value = !sortOpen.value sortOpen.value = !sortOpen.value
filterOpen.value = false filterOpen.value = false
} }
function pickFilter(key) { function pickFilter(key) {
statusFilter.value = key statusFilter.value = key
filterOpen.value = false filterOpen.value = false
} }
function pickSort(mode) { function pickSort(mode) {
sortMode.value = mode sortMode.value = mode
sortOpen.value = false sortOpen.value = false
} }
function closeMenus() { function closeMenus() {
filterOpen.value = false filterOpen.value = false
sortOpen.value = false sortOpen.value = false
} }
onMounted(() => { onMounted(() => {
document.addEventListener('click', closeMenus) document.addEventListener('click', closeMenus)
checkMobile() checkMobile()
@@ -107,12 +107,12 @@ onUnmounted(() => {
window.removeEventListener('resize', checkMobile) window.removeEventListener('resize', checkMobile)
window.removeEventListener('resize', fitTableHeight) window.removeEventListener('resize', fitTableHeight)
}) })
watch(keyword, () => { watch(keyword, () => {
clearTimeout(debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => refreshPackages(keyword.value), 300) debounceTimer = setTimeout(() => refreshPackages(keyword.value), 300)
}) })
const filtered = computed(() => { const filtered = computed(() => {
let list = state.packages let list = state.packages
if (todayFilter.value) list = list.filter(p => p.lastUpdated === todayStr) if (todayFilter.value) list = list.filter(p => p.lastUpdated === todayStr)
@@ -188,37 +188,37 @@ watch(
// 否则 .vtable 无固定高度 → clientHeight=内容全高 → vEnd 扩大至全部 → 虚拟滚动退化为全量渲染 // 否则 .vtable 无固定高度 → clientHeight=内容全高 → vEnd 扩大至全部 → 虚拟滚动退化为全量渲染
watch(vTotal, () => nextTick(fitTableHeight)) watch(vTotal, () => nextTick(fitTableHeight))
watch(searchHistory, () => nextTick(fitTableHeight)) watch(searchHistory, () => nextTick(fitTableHeight))
const pills = [ const pills = [
{ key: 'all', label: '全部' }, { key: 'all', label: '全部' },
{ key: 'warn', label: '🔥 预警' }, { key: 'warn', label: '🔥 预警' },
{ key: 'ok', label: '✅ 正常' }, { key: 'ok', label: '✅ 正常' },
{ key: 'ignored', label: '💤 已忽略' }, { key: 'ignored', label: '💤 已忽略' },
{ key: 'broken', label: '⚠️ 异常' }, { key: 'broken', label: '⚠️ 异常' },
] ]
const drawerPkg = ref(null) const drawerPkg = ref(null)
const drawerVisible = ref(false) const drawerVisible = ref(false)
const cleanVisible = ref(false) const cleanVisible = ref(false)
const cleanPkgData = ref(null) const cleanPkgData = ref(null)
function openDrawer(pkg) { function openDrawer(pkg) {
// 版本明细由 PackageDrawer 打开后按需加载 // 版本明细由 PackageDrawer 打开后按需加载
drawerPkg.value = pkg drawerPkg.value = pkg
drawerVisible.value = true drawerVisible.value = true
} }
function openClean(pkg) { function openClean(pkg) {
cleanPkgData.value = pkg cleanPkgData.value = pkg
cleanVisible.value = true cleanVisible.value = true
} }
async function doIgnore(pkg) { async function doIgnore(pkg) {
await ignorePkg(pkg.name) await ignorePkg(pkg.name)
ElMessage.success(`已忽略 ${pkg.name}`) ElMessage.success(`已忽略 ${pkg.name}`)
} }
async function doUnignore(pkg) { async function doUnignore(pkg) {
await unignorePkg(pkg.name) await unignorePkg(pkg.name)
ElMessage.success(`已移出忽略 ${pkg.name}`) ElMessage.success(`已移出忽略 ${pkg.name}`)
} }
async function doDeleteVersion(pkg, version) { async function doDeleteVersion(pkg, version) {
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
@@ -362,38 +362,38 @@ function showFailedList() {
// 清单待拉取数量(显示在「包清单」入口角标上) // 清单待拉取数量(显示在「包清单」入口角标上)
const pendingCount = computed(() => state.pkgList.items.filter(i => i.status === 'pending').length) const pendingCount = computed(() => state.pkgList.items.filter(i => i.status === 'pending').length)
</script> </script>
<template> <template>
<div class="page-inner"> <div class="page-inner">
<div class="search-hero"> <div class="search-hero">
<div class="search-box"> <div class="search-box">
<span class="s-ico">🔍</span> <span class="s-ico">🔍</span>
<input <input
v-model="keyword" v-model="keyword"
type="text" type="text"
:placeholder="placeholderText" :placeholder="placeholderText"
@keyup.esc="keyword = ''" @keyup.esc="keyword = ''"
@keyup.enter="submitSearch()" @keyup.enter="submitSearch()"
/> />
<span v-if="keyword" class="s-clear" @click="keyword = ''">✕</span> <span v-if="keyword" class="s-clear" @click="keyword = ''">✕</span>
<button class="s-go" @click="submitSearch()">搜索</button> <button class="s-go" @click="submitSearch()">搜索</button>
</div> </div>
<!-- 最近搜索(最近 5 条,localStorage 持久化) --> <!-- 最近搜索(最近 5 条,localStorage 持久化) -->
<div v-if="searchHistory.length" class="history-row"> <div v-if="searchHistory.length" class="history-row">
<span class="h-label">🕘 最近搜索</span> <span class="h-label">🕘 最近搜索</span>
<span v-for="h in searchHistory" :key="h" class="h-chip" @click="useHistory(h)">{{ h }}</span> <span v-for="h in searchHistory" :key="h" class="h-chip" @click="useHistory(h)">{{ h }}</span>
<span class="h-clear" @click="clearHistory">清除</span> <span class="h-clear" @click="clearHistory">清除</span>
</div> </div>
</div> </div>
<!-- 表格工具栏:左侧结果数 + 右侧筛选/排序下拉 --> <!-- 表格工具栏:左侧结果数 + 右侧筛选/排序下拉 -->
<div class="toolbar"> <div class="toolbar">
<div class="tb-count"> <div class="tb-count">
共找到 <b>{{ filtered.length }}</b> 个包 共找到 <b>{{ filtered.length }}</b> 个包
<template v-if="keyword">(关键词「{{ keyword }}」)</template> <template v-if="keyword">(关键词「{{ keyword }}」)</template>
<template v-if="todayFilter">(<span class="today-chip">仅今日更新 {{ todayStr }} <i @click="clearTodayFilter" title="清除筛选">✕</i></span>)</template> <template v-if="todayFilter">(<span class="today-chip">仅今日更新 {{ todayStr }} <i @click="clearTodayFilter" title="清除筛选">✕</i></span>)</template>
</div> </div>
<div class="tb-actions"> <div class="tb-actions">
<!-- npm 包清单:查看/编辑/删除入口 --> <!-- npm 包清单:查看/编辑/删除入口 -->
<button <button
@@ -426,36 +426,36 @@ const pendingCount = computed(() => state.pkgList.items.filter(i => i.status ===
@click.stop="doSyncLatest" @click.stop="doSyncLatest"
>{{ state.syncProgress.running ? '⏳' : '📥' }}</button> >{{ state.syncProgress.running ? '⏳' : '📥' }}</button>
<div class="dd"> <div class="dd">
<button class="dd-btn" :class="{ on: filterOpen }" title="快捷筛选" @click.stop="toggleFilter">🎚️</button> <button class="dd-btn" :class="{ on: filterOpen }" title="快捷筛选" @click.stop="toggleFilter">🎚️</button>
<Transition name="dd"> <Transition name="dd">
<div v-if="filterOpen" class="dd-menu"> <div v-if="filterOpen" class="dd-menu">
<div class="dd-title">快捷筛选</div> <div class="dd-title">快捷筛选</div>
<div <div
v-for="p in pills" v-for="p in pills"
:key="p.key" :key="p.key"
class="dd-item" class="dd-item"
:class="{ on: statusFilter === p.key }" :class="{ on: statusFilter === p.key }"
@click="pickFilter(p.key)" @click="pickFilter(p.key)"
>{{ p.label }}<span v-if="statusFilter === p.key" class="dd-check">✓</span></div> >{{ p.label }}<span v-if="statusFilter === p.key" class="dd-check">✓</span></div>
</div> </div>
</Transition> </Transition>
</div> </div>
<div class="dd"> <div class="dd">
<button class="dd-btn" :class="{ on: sortOpen }" title="排序" @click.stop="toggleSort">↕️</button> <button class="dd-btn" :class="{ on: sortOpen }" title="排序" @click.stop="toggleSort">↕️</button>
<Transition name="dd"> <Transition name="dd">
<div v-if="sortOpen" class="dd-menu"> <div v-if="sortOpen" class="dd-menu">
<div class="dd-title">排序方式</div> <div class="dd-title">排序方式</div>
<div class="dd-item" :class="{ on: sortMode === 'default' }" @click="pickSort('default')">默认<span v-if="sortMode === 'default'" class="dd-check">✓</span></div> <div class="dd-item" :class="{ on: sortMode === 'default' }" @click="pickSort('default')">默认<span v-if="sortMode === 'default'" class="dd-check">✓</span></div>
<div class="dd-item" :class="{ on: sortMode === 'versions-desc' }" @click="pickSort('versions-desc')">版本数 ↓<span v-if="sortMode === 'versions-desc'" class="dd-check">✓</span></div> <div class="dd-item" :class="{ on: sortMode === 'versions-desc' }" @click="pickSort('versions-desc')">版本数 ↓<span v-if="sortMode === 'versions-desc'" class="dd-check">✓</span></div>
<div class="dd-item" :class="{ on: sortMode === 'size-desc' }" @click="pickSort('size-desc')">占用大小 ↓<span v-if="sortMode === 'size-desc'" class="dd-check">✓</span></div> <div class="dd-item" :class="{ on: sortMode === 'size-desc' }" @click="pickSort('size-desc')">占用大小 ↓<span v-if="sortMode === 'size-desc'" class="dd-check">✓</span></div>
<div class="dd-item" :class="{ on: sortMode === 'date-desc' }" @click="pickSort('date-desc')">最近更新 ↓<span v-if="sortMode === 'date-desc'" class="dd-check">✓</span></div> <div class="dd-item" :class="{ on: sortMode === 'date-desc' }" @click="pickSort('date-desc')">最近更新 ↓<span v-if="sortMode === 'date-desc'" class="dd-check">✓</span></div>
<div class="dd-item" :class="{ on: sortMode === 'date-asc' }" @click="pickSort('date-asc')">最近更新 ↑<span v-if="sortMode === 'date-asc'" class="dd-check">✓</span></div> <div class="dd-item" :class="{ on: sortMode === 'date-asc' }" @click="pickSort('date-asc')">最近更新 ↑<span v-if="sortMode === 'date-asc'" class="dd-check">✓</span></div>
</div> </div>
</Transition> </Transition>
</div> </div>
</div> </div>
</div> </div>
<div v-if="state.loading" class="loading-tip">⏳ 加载中…</div> <div v-if="state.loading" class="loading-tip">⏳ 加载中…</div>
<div v-else ref="vWrapEl" class="vtable" @scroll.passive="onVScroll"> <div v-else ref="vWrapEl" class="vtable" @scroll.passive="onVScroll">
<table> <table>
@@ -506,17 +506,17 @@ const pendingCount = computed(() => state.pkgList.items.filter(i => i.status ===
</table> </table>
<div v-if="!filtered.length" class="empty-tip">没有匹配的包</div> <div v-if="!filtered.length" class="empty-tip">没有匹配的包</div>
</div> </div>
</div> </div>
<PackageDrawer <PackageDrawer
v-model:visible="drawerVisible" v-model:visible="drawerVisible"
:pkg="drawerPkg" :pkg="drawerPkg"
:threshold="state.threshold.versionThreshold" :threshold="state.threshold.versionThreshold"
@clean="openClean" @clean="openClean"
@ignore="doIgnore" @ignore="doIgnore"
@unignore="doUnignore" @unignore="doUnignore"
@delete-version="doDeleteVersion" @delete-version="doDeleteVersion"
/> />
<CleanDialog <CleanDialog
:visible="cleanVisible" :visible="cleanVisible"
:pkg="cleanPkgData" :pkg="cleanPkgData"
@@ -526,146 +526,146 @@ const pendingCount = computed(() => state.pkgList.items.filter(i => i.status ===
/> />
<PkgListDrawer v-model:visible="pkgListOpen" /> <PkgListDrawer v-model:visible="pkgListOpen" />
</template> </template>
<style scoped> <style scoped>
.search-hero { padding: 18px 0 12px; display: flex; flex-direction: column; align-items: center; } .search-hero { padding: 18px 0 12px; display: flex; flex-direction: column; align-items: center; }
.search-box { .search-box {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
width: min(680px, 100%); width: min(680px, 100%);
height: 52px; height: 52px;
background: var(--card); background: var(--card);
border: 2px solid var(--line); border: 2px solid var(--line);
border-radius: 99px; border-radius: 99px;
padding: 0 10px 0 22px; padding: 0 10px 0 22px;
box-shadow: 0 14px 34px -14px rgba(180, 120, 60, .25); box-shadow: 0 14px 34px -14px rgba(180, 120, 60, .25);
transition: .2s; transition: .2s;
} }
.search-box:focus-within { .search-box:focus-within {
border-color: var(--orange); border-color: var(--orange);
box-shadow: 0 0 0 4px rgba(255, 140, 0, .14), 0 14px 34px -14px rgba(255, 140, 0, .3); 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); } .s-ico { font-size: 18px; color: var(--ink-faint); }
.search-box input { .search-box input {
flex: 1; flex: 1;
border: none; border: none;
outline: none; outline: none;
font-size: 14.5px; font-size: 14.5px;
font-family: var(--sans); font-family: var(--sans);
color: var(--ink); color: var(--ink);
background: transparent; background: transparent;
min-width: 0; min-width: 0;
} }
.search-box input::placeholder { color: var(--ink-faint); } .search-box input::placeholder { color: var(--ink-faint); }
.s-clear { .s-clear {
width: 26px; width: 26px;
height: 26px; height: 26px;
border-radius: 50%; border-radius: 50%;
display: grid; display: grid;
place-items: center; place-items: center;
background: var(--num-bg); background: var(--num-bg);
color: var(--ink-dim); color: var(--ink-dim);
font-size: 11px; font-size: 11px;
cursor: pointer; cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
} }
.s-clear:hover { background: var(--red-soft); color: var(--red); } .s-clear:hover { background: var(--red-soft); color: var(--red); }
.s-go { .s-go {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 7px; gap: 7px;
height: 38px; height: 38px;
padding: 0 22px; padding: 0 22px;
border: none; border: none;
border-radius: 99px; border-radius: 99px;
cursor: pointer; cursor: pointer;
background: var(--grad-main); background: var(--grad-main);
color: #fff; color: #fff;
font-size: 13.5px; font-size: 13.5px;
font-weight: 600; font-weight: 600;
box-shadow: 0 8px 18px -6px rgba(255, 140, 0, .6); box-shadow: 0 8px 18px -6px rgba(255, 140, 0, .6);
transition: .18s; transition: .18s;
flex-shrink: 0; flex-shrink: 0;
} }
.s-go:hover { transform: translateY(-1px); } .s-go:hover { transform: translateY(-1px); }
/* 最近搜索(搜索框下方,最近 5 条) */ /* 最近搜索(搜索框下方,最近 5 条) */
.history-row { .history-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
flex-wrap: wrap; flex-wrap: wrap;
margin-top: 14px; margin-top: 14px;
width: min(680px, 100%); width: min(680px, 100%);
} }
.h-label { font-size: 12px; color: var(--ink-faint); font-weight: 600; } .h-label { font-size: 12px; color: var(--ink-faint); font-weight: 600; }
.h-chip { .h-chip {
font-size: 12px; font-size: 12px;
color: var(--ink-dim); color: var(--ink-dim);
background: var(--card); background: var(--card);
border: 1.5px solid var(--line); border: 1.5px solid var(--line);
padding: 5px 13px; padding: 5px 13px;
border-radius: 99px; border-radius: 99px;
cursor: pointer; cursor: pointer;
transition: .15s; transition: .15s;
max-width: 220px; max-width: 220px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.h-chip:hover { border-color: color-mix(in srgb, var(--orange) 60%, transparent); color: var(--orange); } .h-chip:hover { border-color: color-mix(in srgb, var(--orange) 60%, transparent); color: var(--orange); }
.h-clear { font-size: 11.5px; color: var(--ink-faint); cursor: pointer; margin-left: 2px; } .h-clear { font-size: 11.5px; color: var(--ink-faint); cursor: pointer; margin-left: 2px; }
.h-clear:hover { color: var(--red); } .h-clear:hover { color: var(--red); }
/* 表格工具栏:左结果数 + 右筛选/排序下拉 */ /* 表格工具栏:左结果数 + 右筛选/排序下拉 */
.toolbar { .toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 10px;
margin: 20px 0 12px; margin: 20px 0 12px;
} }
.tb-count { .tb-count {
font-family: var(--num); font-family: var(--num);
font-size: 12.5px; font-size: 12.5px;
color: var(--ink-faint); color: var(--ink-faint);
font-weight: 600; font-weight: 600;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.tb-count b { color: var(--orange); } .tb-count b { color: var(--orange); }
.today-chip { color: var(--orange); font-weight: 600; } .today-chip { color: var(--orange); font-weight: 600; }
.today-chip i { .today-chip i {
display: inline-grid; display: inline-grid;
place-items: center; place-items: center;
width: 15px; width: 15px;
height: 15px; height: 15px;
border-radius: 50%; border-radius: 50%;
background: var(--orange-soft); background: var(--orange-soft);
color: var(--orange); color: var(--orange);
font-style: normal; font-style: normal;
font-size: 9px; font-size: 9px;
cursor: pointer; cursor: pointer;
margin-left: 4px; margin-left: 4px;
vertical-align: 1px; vertical-align: 1px;
} }
.today-chip i:hover { background: var(--orange); color: #fff; } .today-chip i:hover { background: var(--orange); color: #fff; }
.tb-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } .tb-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.dd { position: relative; } .dd { position: relative; }
.dd-btn { .dd-btn {
width: 36px; width: 36px;
height: 36px; height: 36px;
border-radius: 12px; border-radius: 12px;
display: grid; display: grid;
place-items: center; place-items: center;
font-size: 16px; font-size: 16px;
cursor: pointer; cursor: pointer;
background: var(--card); background: var(--card);
border: 1.5px solid var(--line); border: 1.5px solid var(--line);
transition: .15s; transition: .15s;
} }
.dd-btn:hover { border-color: color-mix(in srgb, var(--orange) 60%, transparent); } .dd-btn:hover { border-color: color-mix(in srgb, var(--orange) 60%, transparent); }
.dd-btn.on { background: var(--green); border-color: var(--green); box-shadow: 0 6px 14px -4px rgba(50, 205, 50, .5); } .dd-btn.on { background: var(--green); border-color: var(--green); box-shadow: 0 6px 14px -4px rgba(50, 205, 50, .5); }
.sync-btn { font-size: 17px; } .sync-btn { font-size: 17px; }
@@ -714,38 +714,38 @@ const pendingCount = computed(() => state.pkgList.items.filter(i => i.status ===
z-index: 2; z-index: 2;
} }
.fail-badge:hover { transform: scale(1.12); } .fail-badge:hover { transform: scale(1.12); }
.dd-menu { .dd-menu {
position: absolute; position: absolute;
top: calc(100% + 8px); top: calc(100% + 8px);
right: 0; right: 0;
min-width: 168px; min-width: 168px;
background: var(--card); background: var(--card);
border: 1.5px solid var(--line); border: 1.5px solid var(--line);
border-radius: 14px; border-radius: 14px;
box-shadow: 0 18px 44px -14px rgba(0, 0, 0, .5); box-shadow: 0 18px 44px -14px rgba(0, 0, 0, .5);
padding: 7px; padding: 7px;
z-index: 60; z-index: 60;
} }
.dd-title { font-size: 11px; color: var(--ink-faint); font-weight: 700; padding: 6px 10px 4px; letter-spacing: .3px; } .dd-title { font-size: 11px; color: var(--ink-faint); font-weight: 700; padding: 6px 10px 4px; letter-spacing: .3px; }
.dd-item { .dd-item {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 10px;
font-size: 12.5px; font-size: 12.5px;
font-weight: 600; font-weight: 600;
color: var(--ink-dim); color: var(--ink-dim);
padding: 8px 10px; padding: 8px 10px;
border-radius: 9px; border-radius: 9px;
cursor: pointer; cursor: pointer;
transition: .13s; transition: .13s;
} }
.dd-item:hover { background: var(--num-bg); color: var(--ink); } .dd-item:hover { background: var(--num-bg); color: var(--ink); }
.dd-item.on { color: var(--green-deep); background: var(--green-soft); } .dd-item.on { color: var(--green-deep); background: var(--green-soft); }
.dd-check { font-weight: 800; } .dd-check { font-weight: 800; }
.dd-enter-active { transition: transform .16s cubic-bezier(.2, .8, .25, 1), opacity .16s; } .dd-enter-active { transition: transform .16s cubic-bezier(.2, .8, .25, 1), opacity .16s; }
.dd-leave-active { transition: transform .12s ease-in, opacity .12s; } .dd-leave-active { transition: transform .12s ease-in, opacity .12s; }
.dd-enter-from, .dd-leave-to { transform: translateY(-6px) scale(.97); opacity: 0; transform-origin: top right; } .dd-enter-from, .dd-leave-to { transform: translateY(-6px) scale(.97); opacity: 0; transform-origin: top right; }
.pkg-name { display: inline-flex; align-items: center; gap: 9px; min-width: 0; } .pkg-name { display: inline-flex; align-items: center; gap: 9px; min-width: 0; }
.pkg-name .pkg-cell { min-width: 0; } .pkg-name .pkg-cell { min-width: 0; }
.pkg-cell { font-weight: 600; word-break: break-all; } .pkg-cell { font-weight: 600; word-break: break-all; }
@@ -763,33 +763,33 @@ const pendingCount = computed(() => state.pkgList.items.filter(i => i.status ===
.vtr-ph td { padding: 0; border-bottom: none; } .vtr-ph td { padding: 0; border-bottom: none; }
.vtr-ph { cursor: default; } .vtr-ph { cursor: default; }
.vtr-ph:hover { background: transparent; } .vtr-ph:hover { background: transparent; }
.pkg-desc { .pkg-desc {
display: block; display: block;
max-width: 230px; max-width: 230px;
font-size: 12px; font-size: 12px;
color: var(--ink-dim); color: var(--ink-dim);
font-weight: 500; font-weight: 500;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.num-cell.over { color: var(--red); } .num-cell.over { color: var(--red); }
/* 操作列:按钮横排一行,不换行 */ /* 操作列:按钮横排一行,不换行 */
.vtable td:last-child { white-space: nowrap; } .vtable td:last-child { white-space: nowrap; }
.vtable td:last-child .btn { padding: 6px 13px; font-size: 12px; } .vtable td:last-child .btn { padding: 6px 13px; font-size: 12px; }
.vtable td:last-child .btn + .btn { margin-left: 7px; } .vtable td:last-child .btn + .btn { margin-left: 7px; }
.empty-tip { padding: 40px 0; text-align: center; color: var(--ink-faint); font-size: 12.5px; } .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; } .loading-tip { text-align: center; padding: 40px 0; color: var(--ink-faint); font-size: 13px; }
@media (max-width: 767px) { @media (max-width: 767px) {
.search-box { height: 48px; } .search-box { height: 48px; }
.search-box .s-go { display: none; } .search-box .s-go { display: none; }
.history-row { width: 100%; } .history-row { width: 100%; }
.toolbar { margin: 16px 0 10px; } .toolbar { margin: 16px 0 10px; }
.vtable { overflow: auto; -webkit-overflow-scrolling: touch; } .vtable { overflow: auto; -webkit-overflow-scrolling: touch; }
.vtable table { min-width: 880px; width: auto; } .vtable table { min-width: 880px; width: auto; }
.vtable th:first-child, .vtable th:first-child,
.vtable td:first-child { min-width: 220px; } .vtable td:first-child { min-width: 220px; }
.pkg-cell { word-break: break-all; } .pkg-cell { word-break: break-all; }
} }
</style> </style>