94a0c287e0
- 新增 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 个既存失败用例(隔离复现), 与本次改动无关;全仓库仅新增测试引用本次新增模块。
405 lines
10 KiB
Vue
405 lines
10 KiB
Vue
<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>
|