feat(frontend-vue): 亚马逊运营工具台与新功能页接入——新版工具台(amazon-console+tool-catalog)、品牌检测/ASIN变体采集/查询ASIN 三个工具页 Vue 化并对接既有后端与桌面桥接;桌面首页/登录页 Vue 化(沿用旧视觉);补充 vc_*/get_device_id/save_file_from_url 桥类型、dev 代理;品牌默认勾选排名+FBM;修正 query-asin 标题

含未提交新版改造累积:Brand*Tab 套入工具台壳(AmazonToolPageShell/AmazonTopBar)与目录/权限联动。
This commit is contained in:
2026-09-07 11:06:05 +08:00
parent f6c44d1b92
commit d2301db292
43 changed files with 4513 additions and 614 deletions
@@ -0,0 +1,471 @@
<template>
<div class="home-root" :class="{ 'nav-permissions-loading': !permissionReady }">
<header class="header">
<span class="header-title">数富AI</span>
<div class="header-right">
<button
type="button"
class="header-update-btn"
title="检测更新"
@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">当前版本: {{ versionText }}</span>
<button type="button" class="btn-check-update" @click="checkUpdate">
<span></span> 检测
</button>
</div>
<div class="update-hint">{{ updateHint }}</div>
<div style="margin-top: 4px;">
<button type="button" class="btn-download-update" v-if="updateReady" @click="doUpdate">
立即更新
</button>
</div>
</div>
<span class="username-text">{{ username || '未登录' }}</span>
<a href="/logout" class="logout-link">退出</a>
</div>
</header>
<div class="entrances">
<a
v-if="canShow.amazon"
:href="amazonHref || undefined"
class="entrance-btn"
:class="{ disabled: !amazonHref }"
data-column-key="brand"
>亚马逊</a>
<a
v-else-if="showAmazonDisabled"
class="entrance-btn disabled"
data-column-key="brand"
>亚马逊</a>
<a
v-if="canShow.video"
:href="videoHref || undefined"
class="entrance-btn wb"
:class="{ disabled: !videoHref }"
data-column-key="wb"
>视频</a>
<a
v-else-if="showVideoDisabled"
class="entrance-btn wb disabled"
data-column-key="wb"
>视频</a>
<a
v-if="canShow.image"
:href="imageHref || undefined"
class="entrance-btn image"
:class="{ disabled: !imageHref }"
data-column-key="image"
>图片</a>
<a
v-else-if="showImageDisabled"
class="entrance-btn image disabled"
data-column-key="image"
>图片</a>
</div>
<div class="toast" :class="{ show: Boolean(toastText) }">{{ toastText }}</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { requestGetJson } from '@/shared/api/http'
import { buildJavaUrl } from '@/shared/api/url'
import { resolvePageHref } from '@/shared/page-prefix'
const username = ref('')
const versionText = ref('—')
const updatePanel = ref(false)
const updateHint = ref('')
const updateReady = ref(false)
const updateFileUrl = ref('')
const toastText = ref('')
type PermissionState = { amazon: boolean; video: boolean; image: boolean }
const permissionState = ref<PermissionState>({ amazon: false, video: false, image: false })
const permissionUnavailable = ref(false)
const permissionReady = ref(false)
const amazonHref = computed(() => resolvePageHref('/new_web_source/amazon-console.html'))
const videoHref = computed(() => resolvePageHref('/new_web_source/image-video.html'))
const imageHref = computed(() => resolvePageHref('/image'))
const canShow = computed(() =>
permissionUnavailable.value ? { amazon: true, video: true, image: true } : permissionState.value,
)
// dev 环境没有 /image 等旧路径资源时,给出“仅桌面”灰卡而不是消失
const showAmazonDisabled = computed(() => !permissionUnavailable.value && permissionState.value.amazon && !amazonHref.value)
const showVideoDisabled = computed(() => !permissionUnavailable.value && permissionState.value.video && !videoHref.value)
const showImageDisabled = computed(() => !permissionUnavailable.value && permissionState.value.image && !imageHref.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: Array<Record<string, unknown>>): 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 || 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
const imageVisible = allowedKeys.indexOf('image') >= 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' && key !== 'image')
}
return { amazon: amazonVisible, video: videoVisible, image: imageVisible }
}
async function loadPermissions() {
const currentUid = getUid()
if (!currentUid) {
permissionUnavailable.value = true
permissionReady.value = true
return
}
try {
const resp = await requestGetJson<{ success: boolean; data?: Array<Record<string, unknown>> }>(
buildJavaUrl(`/api/admin/permission-users/${currentUid}/column-permissions?menuType=app`),
)
if (resp?.success && Array.isArray(resp.data)) {
permissionState.value = computePermissionState(resp.data)
try {
window.localStorage.setItem(`app_column_permissions:${currentUid}`, JSON.stringify(resp.data))
} catch {
/* 忽略 */
}
} else {
permissionUnavailable.value = true
}
} catch {
permissionUnavailable.value = true
}
permissionReady.value = true
}
async function loadCurrentUser() {
try {
const localName = typeof window === 'undefined' ? '' : window.localStorage.getItem('username') || ''
if (localName) username.value = localName
const resp = await window.fetch('/newApi/check_login', { credentials: 'include' })
const body = await resp.json().catch(() => ({}))
if (body?.success && body.data) {
if (body.data.username) username.value = body.data.username
if (body.data.userId) {
try {
window.localStorage.setItem('uid', String(body.data.userId))
} catch {
/* 忽略 */
}
}
}
} catch {
/* 保留本地用户名 */
}
}
function toggleUpdatePanel() {
updatePanel.value = !updatePanel.value
}
function showTip(message: string) {
toastText.value = message
window.setTimeout(() => {
if (toastText.value === message) toastText.value = ''
}, 2600)
}
async function fetchVersion() {
try {
const resp = await window.fetch('/api/version', { credentials: 'same-origin' })
const data = await resp.json().catch(() => ({}))
const current = String((data && data.version) || '').replace(/^v/i, '')
if (current) versionText.value = current || '—'
return data || {}
} catch {
return {}
}
}
async function checkUpdate() {
updateHint.value = '正在检测更新...'
updateReady.value = false
try {
const data = await fetchVersion()
if (data.has_update && data.latest_version) {
updateHint.value =
`发现新版本 v${String(data.latest_version).replace(/^v/i, '')}` +
(data.desc ? `${data.desc}` : '')
if (data.file_url) {
updateReady.value = true
updateFileUrl.value = data.file_url
} else {
updateHint.value = '暂无下载地址,请关注官方渠道。'
}
} else {
updateHint.value = '已是最新版本'
}
} catch {
updateHint.value = '检测更新失败'
}
}
async function doUpdate() {
if (!updateFileUrl.value) return
if (!window.confirm('有更新,是否现在更新?\n更新将下载安装包并重启程序。')) return
updateHint.value = '正在下载并准备更新,程序将自动退出...'
updateReady.value = false
try {
const resp = await window.fetch('/api/update/do', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_url: updateFileUrl.value }),
})
const result = await resp.json().catch(() => ({}))
updateHint.value = result.success ? '更新已启动,程序即将退出...' : (result.error || '更新启动失败')
} catch {
updateHint.value = '请求更新失败,请重试'
}
}
onMounted(() => {
void loadCurrentUser()
void loadPermissions()
void fetchVersion().catch(() => undefined)
})
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", sans-serif;
background: linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5)),
url("/static/bg.jpg") center/cover no-repeat;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
}
.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-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;
}
.home-root.nav-permissions-loading .entrance-btn {
visibility: hidden;
}
.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>