Files
crawler-plugin/frontend-vue/src/pages/home/DesktopHomePage.vue
T
huangzd1997 6d46506726 fix: 全维度审查修复(安全/正确性/性能/稳定性/客户端/前端)
安全
- /api/ziniao/** 五个匿名接口加管理员鉴权(此前可匿名换取任意员工店铺登录令牌)
- 删除 Flask 遗留后门:默认密码建超管 + 每次启动写生产 users 表(服务端与客户端各一份)
- 进度/详情接口归属过滤:新增 TaskProgressOwnershipSupport,11 模块 progress/light 与
  /tasks/batch 接入,DTO 补 userId,前端 13 个查询封装补传(未传时后端不过滤,兼容旧端)
- 代理提取链接(含账密)不再明文入日志(新增 common/util/SecretMasking)
- 全局异常兜底不再回传原始异常信息;内部令牌比较改常量时间
- 登录加失败计数与锁定(10 次锁 15 分钟);品牌源文件下载加 SSRF 防护
- AdminApiGuardFilter 覆盖前缀从 2 扩到 15(开关默认 false,行为不变,为收紧做准备)
- 生产关闭 springdoc/knife4j(/doc.html 匿名可读全部接口定义)

正确性
- 40901/40902 拆分:锁竞争不再被伪装成 success=true(此前客户端停止重试、分片静默丢失)
- 假成功收敛:集采明细批量写失败改为抛出、去重 worker 异常标失败、4 个 worker 改判
  success 字段、publish 空 ASIN 行参与批次 flush、巡店删除全失败带 error 上报
- 客户端心跳 discard 移入 finally(7 模块,失败路径不再留僵尸 RUNNING 任务)
- 状态机条件更新:跟价停止循环、集采 activate/fail、imagevideo 归档回填、店铺匹配提交

性能
- 前端入口包 JS 1.05MB→204KB、CSS 355KB→10.7KB(Element Plus 改按需 + el-config-provider)
- 载荷引用计数按指针里的 taskId 收敛(原 JSON 列 IN 全表扫且逐行调用)
- 店铺明细多值批量 INSERT;快照 upsert 预载缓存;结果文件列改单条 UPDATE
- 新增迁移 V120(补 3 个缺失索引)/V121(删 4 个被覆盖的冗余索引)/V122(URL 前缀索引)

稳定性
- 新增 common/util/ThreadPools 有界线程池替换 5 处无界队列(防堆积 OOM)
- Redis 锁释放改 Lua 原子校验(原裸 delete 会误删他人已过期的锁)
- imagevideo 加死节点接管;锁续期失败重试;调度池 4→16;openStream 全部加超时
- 事务内远程对象删除移到提交后;启动恢复锁按实例命名

客户端
- 不再 taskkill /f /im chrome.exe(改为按调试端口精准回收,不杀用户自己的浏览器)
- 密码检测不再无条件杀紫鸟进程;品牌检测加全局互斥(代理池不再互相覆盖)
- base_dir 统一到 exe 目录(原被 os.getcwd() 覆盖,日志/缓存会分裂两个目录)
- 缓存加定时清理;图片下载加超时;mkstemp 句柄托管

测试
- 同步更新受影响的契约测试(构造器签名/条件更新/方法改名/新增接口方法等)
- 修复 FaultInjectionTest 等 3 处 mock 未 stub 流式 read 导致的读循环 OOM
- mvn test 2795 个测试全绿
2026-09-14 04:15:36 +08:00

409 lines
11 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>
<UpdateLogList :entries="changelog" />
<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>
<NotificationBell theme="light" />
<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 UpdateLogList from '@/shared/components/UpdateLogList.vue'
import NotificationBell from '@/shared/components/NotificationBell.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, changelog, 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>