Files
crawler-plugin/frontend-vue/src/pages/home/DesktopHomePage.vue
T
huangzd1997 94a0c287e0 feat(客户端更新): 登录页/首页更新面板显示更新包下载进度条
- 新增 shared/utils/update-progress.ts:进度事件解析/百分比钳制/文案格式化(纯函数)
- 新增 shared/components/UpdateProgressBar.vue:两页共用,失败态琥珀色并停在断点
  百分比、总大小未知走不确定态
- useVersionUpdate:先挂监听再发桥调用(避免丢首帧),监听 pywebview-update-progress
  (Python 侧 evaluate_js 推送,同 pywebview-download-progress 约定);失败保留上次进度
- 测试:tests/update-progress.test.ts(8 用例)

兼容:老客户端没有该事件源,进度条不出现,其余行为不变(优雅降级)。
注:本仓库 modules-withdraw / modules-image-video 有 3 个既存失败用例(隔离复现),
与本次改动无关;全仓库仅新增测试引用本次新增模块。
2026-09-11 01:59:42 +08:00

405 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="home-root">
<header class="header">
<span class="header-title">数富AI</span>
<div class="header-right">
<button
type="button"
class="header-update-btn"
title="检测更新"
@click="toggleUpdatePanel"
></button>
<button
type="button"
class="header-update-text"
@click="toggleUpdatePanel"
>更新版本</button>
<div class="header-update-panel" v-show="updatePanel">
<div class="header-update-title"><span></span> 软件更新</div>
<div class="update-block">
<span class="update-version-text">当前版本: {{ currentVersion || '未知' }}</span>
<button type="button" class="btn-check-update" :disabled="checking" @click="runCheck">
<span></span> {{ checking ? '检测中...' : '检测' }}
</button>
</div>
<div class="update-hint">{{ hint }}</div>
<UpdateProgressBar :progress="progress" />
<div v-if="hasUpdate || canDownload" style="margin-top: 4px;">
<button type="button" class="btn-download-update" :disabled="updating" @click="doUpdate">
{{ updating ? '更新中...' : '立即更新' }}
</button>
</div>
</div>
<span class="username-text">{{ username || '未登录' }}</span>
<router-link :to="logoutHref" class="logout-link">退出</router-link>
</div>
</header>
<div class="entrances">
<router-link
v-if="canShow.amazon"
:to="amazonHref"
class="entrance-btn"
:class="{ disabled: !amazonHref }"
data-column-key="brand"
>亚马逊</router-link>
<router-link
v-if="canShow.video"
:to="videoHref"
class="entrance-btn wb"
:class="{ disabled: !videoHref }"
data-column-key="wb"
>视频</router-link>
</div>
<div class="toast" :class="{ show: Boolean(toastText) }">{{ toastText }}</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { restoreLoginUser } from '@/shared/auth/ensure-auth'
import { getCurrentUserAppColumnRaw, readCachedAppColumnPermissions, type PermissionMenuItem } from '@/shared/api/permission'
import { useVersionUpdate } from '@/shared/composables/useVersionUpdate'
import UpdateProgressBar from '@/shared/components/UpdateProgressBar.vue'
import { resolvePageHref } from '@/shared/page-prefix'
const username = ref('')
const updatePanel = ref(false)
const toastText = ref('')
const { checking, updating, currentVersion, hasUpdate, canDownload, hint, checked, progress, runCheck, doUpdate } =
useVersionUpdate()
// 桌面端原 Flask /logout 已随瘦身下线:统一跳登录页并清本地 token/login?logout=1
const logoutHref = computed(() => `${resolvePageHref('/new_web_source/login.html')}?logout=1`)
// 桌面端首页仅保留一级入口:亚马逊 / 视频;图片已收进视频页(/image-video)二级菜单。
// 入口先乐观显示(接口返回后按权限隐藏),避免权限接口慢时首页入口空白"点不了"。
type PermissionState = { amazon: boolean; video: boolean }
const permissionState = ref<PermissionState>({ amazon: true, video: true })
const permissionUnavailable = ref(false)
const amazonHref = computed(() => resolvePageHref('/new_web_source/amazon-console.html'))
const videoHref = computed(() => resolvePageHref('/new_web_source/image-video.html'))
const canShow = computed(() =>
permissionUnavailable.value ? { amazon: true, video: true } : permissionState.value,
)
function getUid() {
const raw = typeof window === 'undefined' ? '' : window.localStorage.getItem('uid') || ''
const numeric = Number(raw.trim())
return Number.isFinite(numeric) && numeric > 0 ? String(numeric) : ''
}
function computePermissionState(items: PermissionMenuItem[]): PermissionState {
const allowedKeys: string[] = []
const allowedColumnKeys: string[] = []
const allowedRootKeys: string[] = []
let hasRootMetadata = false
const push = (list: string[], value: unknown) => {
const key = String(value || '').trim().toLowerCase()
if (key && list.indexOf(key) < 0) list.push(key)
}
;(items || []).forEach((item) => {
push(allowedColumnKeys, item.columnKey)
push(allowedColumnKeys, item.column_key)
const root = item.rootColumnKey || item.root_column_key
if (root) {
hasRootMetadata = true
push(allowedRootKeys, root)
}
;[item.columnKey, item.column_key, item.routePath, item.route_path].forEach((v) => push(allowedKeys, v))
})
const videoVisible = allowedKeys.indexOf('wb') >= 0
let amazonVisible =
allowedRootKeys.indexOf('brand_front_tools') >= 0 ||
allowedRootKeys.indexOf('brand_operation_tools') >= 0 ||
allowedRootKeys.indexOf('brand_logistics_tools') >= 0
if (!hasRootMetadata) {
amazonVisible = allowedColumnKeys.some((key) => key !== 'wb')
}
return { amazon: amazonVisible, video: videoVisible }
}
async function loadPermissions() {
const currentUid = getUid()
if (!currentUid) {
permissionUnavailable.value = true
return
}
// 先用本地缓存立即渲染入口(有缓存时接口失败也不让入口消失)
const cached = readCachedAppColumnPermissions(Number(currentUid))
if (cached) {
permissionState.value = computePermissionState(cached)
}
try {
// 经 shared/api/permission 层拉取原始权限菜单项(含本地缓存写入;接口失败走 catch)
const items = await getCurrentUserAppColumnRaw()
permissionState.value = computePermissionState(items)
permissionUnavailable.value = false
} catch {
// 无缓存才退回"显示全部入口";有缓存已按缓存显示,保持现状即可
if (!cached) {
permissionUnavailable.value = true
}
}
}
async function loadCurrentUser() {
try {
const localName = typeof window === 'undefined' ? '' : window.localStorage.getItem('username') || ''
if (localName) username.value = localName
// 经 shared/auth 层恢复登录快照(页面不得直连 /newApi,见 shared-api-allowlist 测试约定)
const restored = await restoreLoginUser()
if (restored?.username) {
username.value = restored.username
}
} catch {
/* 保留本地用户名 */
}
}
function toggleUpdatePanel() {
updatePanel.value = !updatePanel.value
// 首次展开面板即触发一次检测(版本逻辑在 shared/composables/useVersionUpdate
if (updatePanel.value && !checked.value && !checking.value) {
void runCheck()
}
}
onMounted(() => {
void loadCurrentUser()
void loadPermissions()
// 版本检测/更新:桌面与网页形态统一挂载后检测一次(逻辑见 useVersionUpdate
void runCheck()
})
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", sans-serif;
min-height: 100vh;
}
.home-root {
/* 背景只挂根容器,不再写全局 body 背景:页面各自的 body 规则会在 SPA 注入顺序上互相覆盖 */
min-height: 100vh;
background: linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5)),
image-set(url("/bg.webp") 1x, url("/bg.jpg") 1x) center/cover no-repeat #eef3f7;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
box-sizing: border-box;
}
.header {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
background: rgba(255, 255, 255, 0.5);
}
.header-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
position: relative;
}
.header-right a,
.header-right span {
font-size: 14px;
color: #555;
text-decoration: none;
}
.header-right a:hover {
color: #667eea;
}
.logout-link {
cursor: pointer;
}
.header-update-btn {
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: rgba(102, 126, 234, 0.12);
color: #4c5bd4;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
padding: 0;
}
.header-update-btn:hover {
background: rgba(102, 126, 234, 0.2);
}
.header-update-text {
padding: 5px 12px;
font-size: 13px;
color: #4c5bd4;
background: rgba(102, 126, 234, 0.12);
border: 1px solid rgba(102, 126, 234, 0.35);
border-radius: 14px;
cursor: pointer;
white-space: nowrap;
}
.header-update-text:hover {
background: rgba(102, 126, 234, 0.2);
}
.header-update-panel {
position: absolute;
top: 44px;
right: 0;
width: 280px;
padding: 14px 16px;
background: rgba(255, 255, 255, 0.98);
border-radius: 10px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
border: 1px solid rgba(0, 0, 0, 0.04);
z-index: 10;
}
.header-update-title {
font-size: 13px;
color: #333;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.update-block {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.update-version-text {
font-size: 13px;
color: #555;
}
.btn-check-update {
padding: 6px 12px;
font-size: 13px;
background: #667eea;
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
}
.btn-check-update:hover {
background: #5a6fd6;
}
.btn-download-update {
margin-top: 8px;
padding: 6px 12px;
font-size: 13px;
background: #28a745;
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
}
.btn-download-update:hover {
background: #218838;
}
.update-hint {
font-size: 12px;
color: #666;
margin-top: 8px;
min-height: 18px;
}
.entrances {
display: flex;
gap: 32px;
flex-wrap: wrap;
justify-content: center;
}
.entrance-btn {
width: 160px;
height: 100px;
background: #c5c1c1;
border: 4px solid #b8d4e3;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
color: #333;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
}
.entrance-btn:hover {
background: #f8fbfd;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.entrance-btn.disabled {
cursor: not-allowed;
opacity: 0.9;
color: #888;
}
.toast {
position: fixed;
bottom: 40px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.75);
color: #fff;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
z-index: 20;
}
.toast.show {
opacity: 1;
}
</style>