6d46506726
安全 - /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 个测试全绿
409 lines
11 KiB
Vue
409 lines
11 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>
|
||
<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>
|