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,366 @@
<template>
<div class="legacy-login-root">
<header class="header">
<span class="header-title">数富AI</span>
</header>
<div class="login-box">
<h1 class="login-title">登录</h1>
<p v-if="errorMessage" class="error-msg">{{ errorMessage }}</p>
<form id="loginForm" @submit.prevent="submitLogin">
<div class="form-group">
<label for="username">用户名</label>
<input id="username" v-model="username" type="text" placeholder="请输入用户名" required autofocus />
</div>
<div class="form-group">
<label for="password">密码</label>
<div class="password-field">
<input
id="password"
v-model="password"
:type="passwordVisible ? 'text' : 'password'"
placeholder="请输入密码"
required
/>
<button
type="button"
class="password-toggle"
:class="{ 'is-visible': passwordVisible }"
:aria-pressed="passwordVisible"
:aria-label="passwordVisible ? '隐藏密码' : '显示密码'"
@click="togglePassword"
>
<svg class="icon-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
<svg class="icon-eye-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m2 2 20 20"></path>
<path d="M10.733 5.076a10.744 10.744 0 0 1 9.205 6.272 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-1.651 2.59"></path>
<path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"></path>
<path d="M17.479 17.499a10.75 10.75 0 0 1-5.479 1.501c-4.478 0-8.268-2.943-9.876-6.652a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 2.646-3.393"></path>
</svg>
</button>
</div>
</div>
<button type="submit" class="btn-login" id="btnLogin" :disabled="loggingIn">
{{ loggingIn ? '登录中...' : '登录' }}
</button>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const AUTH_TOKEN_KEY = 'aiimage_auth_token'
const DEVICE_ID_KEY = 'aiimage_device_id'
const username = ref('')
const password = ref('')
const passwordVisible = ref(false)
const loggingIn = ref(false)
const errorMessage = ref('')
function togglePassword() {
passwordVisible.value = !passwordVisible.value
}
function isDesktopClient() {
return Boolean(window.pywebview?.api) || window.location.pathname === '/login'
}
function clearAppPermissionCaches() {
try {
const keys: string[] = []
for (let i = 0; i < window.localStorage.length; i += 1) {
const key = window.localStorage.key(i)
if (key && key.indexOf('app_column_permissions:') === 0) keys.push(key)
}
keys.forEach((key) => window.localStorage.removeItem(key))
} catch {
/* 忽略 localStorage 异常 */
}
}
function makeBrowserDeviceId() {
let deviceId = ''
try {
deviceId = window.localStorage.getItem(DEVICE_ID_KEY) || ''
} catch {
deviceId = ''
}
if (!deviceId) {
deviceId = `web-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`
try {
window.localStorage.setItem(DEVICE_ID_KEY, deviceId)
} catch {
/* 忽略 */
}
}
return deviceId
}
async function fetchDeviceId(): Promise<string> {
if (window.pywebview?.api?.get_device_id) {
try {
const res = await window.pywebview.api.get_device_id()
if (res?.success && res.device_id) {
try {
window.localStorage.setItem(DEVICE_ID_KEY, res.device_id)
} catch {
/* 忽略 */
}
return res.device_id
}
} catch {
/* 回退浏览器生成 */
}
}
return makeBrowserDeviceId()
}
async function submitLogin() {
if (loggingIn.value) return
const account = username.value.trim()
if (!account || !password.value) {
errorMessage.value = '请输入账号和密码'
return
}
loggingIn.value = true
errorMessage.value = ''
try {
const deviceId = await fetchDeviceId()
if (!deviceId) {
errorMessage.value = '未获取到设备ID,请在桌面端打开'
return
}
const loginResp = await window.fetch('/newApi/login', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-Device-Id': deviceId,
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({ username: account, password: password.value, deviceId }),
})
const loginBody = await loginResp.json().catch(() => ({}))
if (!loginBody || loginBody.success !== true || !loginBody.data) {
errorMessage.value = (loginBody && (loginBody.message || loginBody.msg)) || '登录失败'
return
}
const data = loginBody.data || {}
if (data.token) {
try {
window.localStorage.setItem(AUTH_TOKEN_KEY, data.token)
} catch {
/* 忽略 */
}
}
if (data.userId) {
try {
window.localStorage.setItem('uid', String(data.userId))
} catch {
/* 忽略 */
}
}
if (data.username) {
try {
window.localStorage.setItem('username', String(data.username))
} catch {
/* 忽略 */
}
}
// 把 Java 签发的 JWT 同步到 Python 同源 cookie,供后续页面跳转携带
try {
await window.fetch('/api/auth/sync', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: data.token }),
})
} catch {
/* cookie 同步失败不阻塞跳转(页面仍可读 localStorage */
}
clearAppPermissionCaches()
window.location.href = isDesktopClient() ? '/home' : '/home.html'
} catch {
errorMessage.value = '网络异常,请稍后重试'
} finally {
loggingIn.value = false
}
}
onMounted(() => {
const params = new URLSearchParams(window.location.search || '')
if (params.get('logout') === '1' || params.get('switch') === '1') {
try {
window.localStorage.removeItem(AUTH_TOKEN_KEY)
} catch {
/* 忽略 */
}
}
})
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", sans-serif;
background: #d8e4ec;
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;
}
.login-box {
background: #fff;
border: 1px solid #b8d4e3;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
padding: 40px;
width: 100%;
max-width: 380px;
}
.login-title {
font-size: 24px;
font-weight: 600;
color: #333;
text-align: center;
margin-bottom: 30px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 14px;
color: #555;
margin-bottom: 8px;
}
.form-group input {
width: 100%;
padding: 12px 14px;
font-size: 14px;
border: 1px solid #b8d4e3;
border-radius: 8px;
outline: none;
transition: border-color 0.2s;
background: #fff;
}
.form-group input:focus {
border-color: #3498db;
}
.password-field {
position: relative;
}
.password-field input {
padding-right: 48px;
}
.password-toggle {
position: absolute;
top: 50%;
right: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
color: #71808d;
background: transparent;
border: 0;
border-radius: 6px;
cursor: pointer;
transform: translateY(-50%);
}
.password-toggle:hover {
color: #3498db;
background: #f1f7fb;
}
.password-toggle svg {
width: 18px;
height: 18px;
pointer-events: none;
}
.password-toggle .icon-eye-off {
display: none;
}
.password-toggle.is-visible .icon-eye {
display: none;
}
.password-toggle.is-visible .icon-eye-off {
display: block;
}
.error-msg {
color: #e74c3c;
font-size: 13px;
margin-bottom: 12px;
text-align: center;
}
.btn-login {
width: 100%;
padding: 14px;
font-size: 16px;
font-weight: 500;
color: #fff;
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.btn-login:hover:not(:disabled) {
opacity: 0.9;
}
.btn-login:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>