feat(web): 桌面端前端 Web 独立部署适配——浏览器降级桥+登录态引导+页面动态修正
- 新增 web-fallback.ts:无 pywebview 时以浏览器能力降级(文件选择/上传/模板下载/配置), 其余桌面能力(enqueue_json/vc_*/folder 选择等)保持 undefined 交由页面既有请在本地客户端中打开提示 - 新增 ensure-auth.ts:无 uid 时经 /newApi/check_login 恢复登录态,无 token/失效跳登录页; 21 个入口 main.ts 挂载前先引导(桌面端已注入 uid 时零开销) - pywebview.ts:getPywebviewApi() 在纯浏览器环境返回降级桥;新增 isDesktopRuntime 标志 (首页更新面板/退出链接据此区分桌面与 Web);hasPywebview 语义修正为真实桥判定 - 登录页跳转、首页登出链接、品牌检测页 hasBridge 判定(select_brand_folder 存在性)适配 Web
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { ensureAuth } from '@/shared/auth/ensure-auth'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import '@/styles/main.css'
|
||||
import AmazonConsolePage from '@/pages/amazon/AmazonConsolePage.vue'
|
||||
|
||||
createApp(AmazonConsolePage).use(ElementPlus).mount('#app')
|
||||
ensureAuth().then((ok) => {
|
||||
if (ok) {
|
||||
createApp(AmazonConsolePage).use(ElementPlus).mount('#app')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { ensureAuth } from '@/shared/auth/ensure-auth'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import '@/styles/main.css'
|
||||
import BrandAppearancePatentTab from '@/pages/brand/components/BrandAppearancePatentTab.vue'
|
||||
|
||||
createApp(BrandAppearancePatentTab).use(ElementPlus).mount('#app')
|
||||
ensureAuth().then((ok) => {
|
||||
if (ok) {
|
||||
createApp(BrandAppearancePatentTab).use(ElementPlus).mount('#app')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import { ensureAuth } from '@/shared/auth/ensure-auth'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -9,4 +10,8 @@ import BrandBrandTab from '@/pages/brand/components/BrandBrandTab.vue'
|
||||
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
createApp(BrandBrandTab).use(ElementPlus, { locale: zhCn }).mount('#app')
|
||||
ensureAuth().then((ok) => {
|
||||
if (ok) {
|
||||
createApp(BrandBrandTab).use(ElementPlus, { locale: zhCn }).mount('#app')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { ensureAuth } from '@/shared/auth/ensure-auth'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import '@/styles/main.css'
|
||||
import BrandCollectDataTab from '@/pages/brand/components/BrandCollectDataTab.vue'
|
||||
|
||||
createApp(BrandCollectDataTab).use(ElementPlus).mount('#app')
|
||||
ensureAuth().then((ok) => {
|
||||
if (ok) {
|
||||
createApp(BrandCollectDataTab).use(ElementPlus).mount('#app')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { ensureAuth } from '@/shared/auth/ensure-auth'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import '@/styles/main.css'
|
||||
import BrandConvertTab from '@/pages/brand/components/BrandConvertTab.vue'
|
||||
|
||||
createApp(BrandConvertTab).use(ElementPlus).mount('#app')
|
||||
ensureAuth().then((ok) => {
|
||||
if (ok) {
|
||||
createApp(BrandConvertTab).use(ElementPlus).mount('#app')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { ensureAuth } from '@/shared/auth/ensure-auth'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import '@/styles/main.css'
|
||||
import BrandDedupeTab from '@/pages/brand/components/BrandDedupeTab.vue'
|
||||
|
||||
createApp(BrandDedupeTab).use(ElementPlus).mount('#app')
|
||||
ensureAuth().then((ok) => {
|
||||
if (ok) {
|
||||
createApp(BrandDedupeTab).use(ElementPlus).mount('#app')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { ensureAuth } from '@/shared/auth/ensure-auth'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import '@/styles/main.css'
|
||||
import BrandDeleteBrandTab from '@/pages/brand/components/BrandDeleteBrandTab.vue'
|
||||
|
||||
createApp(BrandDeleteBrandTab).use(ElementPlus).mount('#app')
|
||||
ensureAuth().then((ok) => {
|
||||
if (ok) {
|
||||
createApp(BrandDeleteBrandTab).use(ElementPlus).mount('#app')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { createApp } from 'vue'
|
||||
import { ensureAuth } from '@/shared/auth/ensure-auth'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import 'element-plus/dist/index.css'
|
||||
import '@/styles/main.css'
|
||||
import DesktopHomePage from '@/pages/home/DesktopHomePage.vue'
|
||||
|
||||
createApp(DesktopHomePage).use(ElementPlus, { locale: zhCn }).mount('#app')
|
||||
ensureAuth().then((ok) => {
|
||||
if (ok) {
|
||||
createApp(DesktopHomePage).use(ElementPlus, { locale: zhCn }).mount('#app')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createApp } from 'vue'
|
||||
import { ensureAuth } from '@/shared/auth/ensure-auth'
|
||||
import ImageWorkbenchPage from '@/pages/image/ImageWorkbenchPage.vue'
|
||||
|
||||
ensureAuth().then((ok) => {
|
||||
if (ok) {
|
||||
createApp(ImageWorkbenchPage).mount('#app')
|
||||
}
|
||||
})
|
||||
@@ -1,7 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { ensureAuth } from '@/shared/auth/ensure-auth'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import '@/styles/main.css'
|
||||
import ImageVideoPage from '@/pages/image-video/ImageVideoPage.vue'
|
||||
|
||||
createApp(ImageVideoPage).use(ElementPlus).mount('#app')
|
||||
ensureAuth().then((ok) => {
|
||||
if (ok) {
|
||||
createApp(ImageVideoPage).use(ElementPlus).mount('#app')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -148,7 +148,8 @@ const runMode = ref<'immediate' | 'queue'>('immediate')
|
||||
const submitting = ref(false)
|
||||
const tasks = ref<BrandTaskItem[]>([])
|
||||
|
||||
const hasBridge = computed(() => Boolean(getPywebviewApi()))
|
||||
// 品牌检测由桌面客户端本地执行:以"文件夹选择"桥(Web 降级桥不提供)判定桌面运行时,浏览器下仅可预览
|
||||
const hasBridge = computed(() => Boolean(getPywebviewApi()?.select_brand_folder))
|
||||
const hasUid = computed(() => {
|
||||
const raw = typeof window === 'undefined' ? '' : window.localStorage.getItem('uid') || ''
|
||||
const numeric = Number(raw.trim())
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<span class="header-title">数富AI</span>
|
||||
<div class="header-right">
|
||||
<button
|
||||
v-if="isDesktopRuntime"
|
||||
type="button"
|
||||
class="header-update-btn"
|
||||
title="检测更新"
|
||||
@@ -25,7 +26,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<span class="username-text">{{ username || '未登录' }}</span>
|
||||
<a href="/logout" class="logout-link">退出</a>
|
||||
<a :href="logoutHref" class="logout-link">退出</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -78,6 +79,7 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { requestGetJson } from '@/shared/api/http'
|
||||
import { buildJavaUrl } from '@/shared/api/url'
|
||||
import { isDesktopRuntime } from '@/shared/bridges/pywebview'
|
||||
import { resolvePageHref } from '@/shared/page-prefix'
|
||||
|
||||
const username = ref('')
|
||||
@@ -88,6 +90,11 @@ const updateReady = ref(false)
|
||||
const updateFileUrl = ref('')
|
||||
const toastText = ref('')
|
||||
|
||||
// 桌面端走 Flask /logout(清 cookie 302 登录页);Web 独立部署 / dev 跳登录页并清本地 token(login.html?logout=1)
|
||||
const logoutHref = computed(() =>
|
||||
isDesktopRuntime.value ? '/logout' : `${resolvePageHref('/new_web_source/login.html')}?logout=1`,
|
||||
)
|
||||
|
||||
type PermissionState = { amazon: boolean; video: boolean; image: boolean }
|
||||
const permissionState = ref<PermissionState>({ amazon: false, video: false, image: false })
|
||||
const permissionUnavailable = ref(false)
|
||||
@@ -95,7 +102,7 @@ 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 imageHref = computed(() => resolvePageHref('/new_web_source/image.html'))
|
||||
|
||||
const canShow = computed(() =>
|
||||
permissionUnavailable.value ? { amazon: true, video: true, image: true } : permissionState.value,
|
||||
@@ -172,7 +179,11 @@ 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 token = typeof window === 'undefined' ? '' : window.localStorage.getItem('aiimage_auth_token') || ''
|
||||
const resp = await window.fetch('/newApi/check_login', {
|
||||
credentials: 'include',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
const body = await resp.json().catch(() => ({}))
|
||||
if (body?.success && body.data) {
|
||||
if (body.data.username) username.value = body.data.username
|
||||
@@ -257,7 +268,10 @@ async function doUpdate() {
|
||||
onMounted(() => {
|
||||
void loadCurrentUser()
|
||||
void loadPermissions()
|
||||
void fetchVersion().catch(() => undefined)
|
||||
// 版本检测/更新由桌面客户端 Flask 提供,Web 版不发起
|
||||
if (isDesktopRuntime.value) {
|
||||
void fetchVersion().catch(() => undefined)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div v-if="currentView === 'menu'" class="image-video-page">
|
||||
<header class="page-header">
|
||||
<span class="header-title">数富AI</span>
|
||||
<a class="back-link" href="/home">返回首页</a>
|
||||
<a class="back-link" :href="goHome()">返回首页</a>
|
||||
</header>
|
||||
|
||||
<main class="entrances" :class="{ 'is-loading': permissionsLoading }" aria-label="视频与图片入口">
|
||||
@@ -21,9 +21,12 @@
|
||||
<button v-if="hasMenuPermission('mix-video')" type="button" class="entrance-btn" @click="showSoon('混剪')">
|
||||
混剪
|
||||
</button>
|
||||
<a v-if="hasMenuPermission('image')" class="entrance-btn" href="/image">
|
||||
<a v-if="hasMenuPermission('image') && isDesktop" class="entrance-btn" href="/new_web_source/image.html">
|
||||
图片
|
||||
</a>
|
||||
<button v-else-if="hasMenuPermission('image')" type="button" class="entrance-btn" @click="showDesktopOnly('图片工作台')">
|
||||
图片
|
||||
</button>
|
||||
</main>
|
||||
|
||||
<div class="toast" :class="{ show: Boolean(statusText), error: statusType === 'error' }">
|
||||
@@ -75,10 +78,22 @@ import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
|
||||
import DeliveryVideoWorkspace from '@/pages/image-video/components/DeliveryVideoWorkspace.vue'
|
||||
import { getCurrentUserAppColumnKeys } from '@/shared/api/permission'
|
||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||
import { isDesktopClientPage } from '@/shared/page-prefix'
|
||||
import DownloadProgressPanel from '@/shared/components/DownloadProgressPanel.vue'
|
||||
|
||||
type ViewMode = 'menu' | 'delivery'
|
||||
|
||||
const isDesktop = isDesktopClientPage()
|
||||
|
||||
function goHome() {
|
||||
// 桌面端首页是 Flask /home(重定向到 Vue home);dev 下是 MPA 的 /home.html
|
||||
return isDesktop ? '/home' : '/home.html'
|
||||
}
|
||||
|
||||
function showDesktopOnly(name: string) {
|
||||
showStatus(`${name}为桌面客户端功能,请在本地客户端中打开`, 'error')
|
||||
}
|
||||
|
||||
const currentView = ref<ViewMode>('menu')
|
||||
const allowedColumnKeys = ref(new Set<string>())
|
||||
const permissionsLoading = ref(true)
|
||||
@@ -184,7 +199,7 @@ onBeforeUnmount(() => {
|
||||
async function launchDesktop() {
|
||||
const api = getPywebviewApi()
|
||||
if (!api?.launch_yaoayanui) {
|
||||
showStatus('当前环境不支持拉起桌面端', 'error')
|
||||
showStatus('数字人为桌面客户端程序:请在本机客户端中打开后再点击', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -246,7 +261,7 @@ async function launchDesktop() {
|
||||
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,
|
||||
url('/bg.jpg') center/cover no-repeat,
|
||||
#eef3f7;
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { resolvePageHref } from '@/shared/page-prefix'
|
||||
|
||||
const AUTH_TOKEN_KEY = 'aiimage_auth_token'
|
||||
const DEVICE_ID_KEY = 'aiimage_device_id'
|
||||
@@ -185,7 +186,8 @@ async function submitLogin() {
|
||||
/* cookie 同步失败不阻塞跳转(页面仍可读 localStorage) */
|
||||
}
|
||||
clearAppPermissionCaches()
|
||||
window.location.href = isDesktopClient() ? '/home' : '/home.html'
|
||||
// 桌面端走 Flask /home;Web 独立部署(路径含 /new_web_source)与 dev 走 resolvePageHref 归一
|
||||
window.location.href = isDesktopClient() ? '/home' : resolvePageHref('/new_web_source/home.html')
|
||||
} catch {
|
||||
errorMessage.value = '网络异常,请稍后重试'
|
||||
} finally {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import { ensureAuth } from '@/shared/auth/ensure-auth'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -9,4 +10,8 @@ import BrandPatrolDeleteTab from '@/pages/brand/components/BrandPatrolDeleteTab.
|
||||
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
createApp(BrandPatrolDeleteTab).use(ElementPlus, { locale: zhCn }).mount('#app')
|
||||
ensureAuth().then((ok) => {
|
||||
if (ok) {
|
||||
createApp(BrandPatrolDeleteTab).use(ElementPlus, { locale: zhCn }).mount('#app')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { ensureAuth } from '@/shared/auth/ensure-auth'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import '@/styles/main.css'
|
||||
import BrandPriceTrackTab from '@/pages/brand/components/BrandPriceTrackTab.vue'
|
||||
|
||||
createApp(BrandPriceTrackTab).use(ElementPlus).mount('#app')
|
||||
ensureAuth().then((ok) => {
|
||||
if (ok) {
|
||||
createApp(BrandPriceTrackTab).use(ElementPlus).mount('#app')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { ensureAuth } from '@/shared/auth/ensure-auth'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import '@/styles/main.css'
|
||||
import BrandProductRiskTab from '@/pages/brand/components/BrandProductRiskTab.vue'
|
||||
|
||||
createApp(BrandProductRiskTab).use(ElementPlus).mount('#app')
|
||||
ensureAuth().then((ok) => {
|
||||
if (ok) {
|
||||
createApp(BrandProductRiskTab).use(ElementPlus).mount('#app')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { ensureAuth } from '@/shared/auth/ensure-auth'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import '@/styles/main.css'
|
||||
import BrandPublishTab from '@/pages/brand/components/BrandPublishTab.vue'
|
||||
|
||||
createApp(BrandPublishTab).use(ElementPlus).mount('#app')
|
||||
ensureAuth().then((ok) => {
|
||||
if (ok) {
|
||||
createApp(BrandPublishTab).use(ElementPlus).mount('#app')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import { ensureAuth } from '@/shared/auth/ensure-auth'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -9,5 +10,9 @@ import BrandQueryAsinTab from '@/pages/brand/components/BrandQueryAsinTab.vue'
|
||||
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
createApp(BrandQueryAsinTab).use(ElementPlus, { locale: zhCn }).mount('#app')
|
||||
ensureAuth().then((ok) => {
|
||||
if (ok) {
|
||||
createApp(BrandQueryAsinTab).use(ElementPlus, { locale: zhCn }).mount('#app')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 页面登录态引导
|
||||
*
|
||||
* 桌面客户端由 Flask 在页面 <head> 注入 localStorage.uid;前端 Web 独立部署后没有注入,
|
||||
* 直接访问子页面(书签/刷新)时 uid 缺失会导致用户信息取用失败。
|
||||
* 页面入口先执行 ensureAuth 再挂载:
|
||||
* - localStorage.uid 已存在(桌面注入 / 登录页已写入)→ 直接放行,零额外请求;
|
||||
* - 无 uid 但有 JWT → 调 /newApi/check_login 恢复 uid/username;
|
||||
* - 无 token 或校验失败 → 清本地登录态并跳转登录页。
|
||||
*/
|
||||
|
||||
import { get } from '@/shared/api/http'
|
||||
import { resolvePageHref } from '@/shared/page-prefix'
|
||||
|
||||
const AUTH_TOKEN_KEY = 'aiimage_auth_token'
|
||||
const UID_KEY = 'uid'
|
||||
|
||||
interface CheckLoginData {
|
||||
userId?: number | string
|
||||
username?: string
|
||||
}
|
||||
|
||||
function redirectToLogin() {
|
||||
window.location.replace(resolvePageHref('/new_web_source/login.html'))
|
||||
}
|
||||
|
||||
/** 确保当前页面具备登录态;返回是否可继续挂载应用 */
|
||||
export async function ensureAuth(): Promise<boolean> {
|
||||
if (typeof window === 'undefined') return true
|
||||
try {
|
||||
const rawUid = window.localStorage.getItem(UID_KEY) || ''
|
||||
if (rawUid && Number(rawUid) > 0) {
|
||||
return true
|
||||
}
|
||||
const token = window.localStorage.getItem(AUTH_TOKEN_KEY) || ''
|
||||
if (!token) {
|
||||
redirectToLogin()
|
||||
return false
|
||||
}
|
||||
const response = await get<{ success: boolean; data?: CheckLoginData }>('/newApi/check_login')
|
||||
if (response?.success !== true || !response.data?.userId) {
|
||||
throw new Error('登录已失效')
|
||||
}
|
||||
window.localStorage.setItem(UID_KEY, String(response.data.userId))
|
||||
if (response.data.username) {
|
||||
window.localStorage.setItem('username', String(response.data.username))
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
// token 失效:清掉本地登录态后回登录页
|
||||
try {
|
||||
window.localStorage.removeItem(AUTH_TOKEN_KEY)
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
redirectToLogin()
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { webFallbackApi } from './web-fallback';
|
||||
|
||||
export interface UploadedJavaFile {
|
||||
fileKey: string;
|
||||
originalFilename: string;
|
||||
@@ -110,6 +114,14 @@ export interface PywebviewApi {
|
||||
path?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
/** 工具台首页「立即下载」:本地查找教程压缩包并弹窗复制(对齐主程序 _on_dl_click) */
|
||||
save_workbench_zip?: () => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
size_mb?: number;
|
||||
not_found?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
launch_yaoayanui?: () => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
@@ -185,8 +197,14 @@ declare global {
|
||||
let cachedPywebviewApi: PywebviewApi | undefined;
|
||||
let pywebviewReadyBound = false;
|
||||
|
||||
/** 当前是否运行在桌面客户端(pywebview)运行时;Web 独立部署下恒为 false */
|
||||
export const isDesktopRuntime = ref(false);
|
||||
|
||||
function syncPywebviewApi() {
|
||||
cachedPywebviewApi = window.pywebview?.api;
|
||||
if (cachedPywebviewApi) {
|
||||
isDesktopRuntime.value = true;
|
||||
}
|
||||
return cachedPywebviewApi;
|
||||
}
|
||||
|
||||
@@ -206,9 +224,15 @@ function bindPywebviewReady() {
|
||||
bindPywebviewReady();
|
||||
|
||||
export function getPywebviewApi() {
|
||||
return syncPywebviewApi() || cachedPywebviewApi;
|
||||
const real = syncPywebviewApi() || cachedPywebviewApi;
|
||||
if (real) return real;
|
||||
// 纯浏览器环境(Web 独立部署 / dev 预览):返回浏览器降级桥,
|
||||
// 未在降级桥中实现的方法为 undefined,页面代码走既有的"请在本地客户端中打开"分支
|
||||
if (typeof window !== 'undefined') return webFallbackApi;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 是否运行在真实 pywebview 桌面桥环境(Web 降级桥不算桌面运行时) */
|
||||
export function hasPywebview() {
|
||||
return Boolean(getPywebviewApi());
|
||||
return Boolean(syncPywebviewApi() || cachedPywebviewApi);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Web 浏览器降级桥
|
||||
*
|
||||
* 桌面客户端(pywebview)环境下 window.pywebview.api 提供文件对话框、任务队列、本地存档等能力;
|
||||
* 前端 Web 独立部署后页面运行在纯浏览器中,没有该桥。
|
||||
* 本模块用浏览器原生能力模拟其中"可全链路完成"的方法(文件选择 → 上传 Java / 模板下载 / 配置存取),
|
||||
* 其余方法保持 undefined,让页面代码走既有的"请在本地客户端中打开"降级分支。
|
||||
*/
|
||||
|
||||
import type { DesktopConfig, DesktopConfigUpdate, PywebviewApi } from './pywebview'
|
||||
import { extractErrorMessage, http } from '@/shared/api/http'
|
||||
|
||||
/** 虚拟路径 → 浏览器 File 对象(虚拟路径仅存活于当前页面会话,刷新即失效) */
|
||||
const webFileMap = new Map<string, File>()
|
||||
let webFileSeq = 0
|
||||
|
||||
const CONFIG_STORAGE_KEY = 'web_desktop_config'
|
||||
|
||||
function readStoredConfig(): DesktopConfig {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(CONFIG_STORAGE_KEY) || ''
|
||||
return raw ? (JSON.parse(raw) as DesktopConfig) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredConfig(data: DesktopConfigUpdate): DesktopConfig {
|
||||
const merged: DesktopConfig = { ...readStoredConfig(), ...data }
|
||||
try {
|
||||
window.localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(merged))
|
||||
} catch {
|
||||
/* 本地存储异常时忽略:配置读取仍是空对象 */
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/** 打开浏览器文件选择框;用户取消时返回空数组(不阻塞后续选择) */
|
||||
function pickBrowserFiles(accept = '', multiple = false): Promise<File[]> {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = accept
|
||||
input.multiple = multiple
|
||||
input.style.display = 'none'
|
||||
document.body.appendChild(input)
|
||||
const cleanup = () => {
|
||||
input.remove()
|
||||
}
|
||||
input.addEventListener('cancel', () => {
|
||||
// 现代浏览器支持 file input 的 cancel 事件;旧浏览器取消时 Promise 保持挂起(下次选择会覆盖 UI,无副作用)
|
||||
cleanup()
|
||||
resolve([])
|
||||
})
|
||||
input.onchange = () => {
|
||||
const files = Array.from(input.files || [])
|
||||
cleanup()
|
||||
resolve(files)
|
||||
}
|
||||
input.click()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 把浏览器 File 注册为虚拟路径(形如 web-file:<序号>/<文件名>)。
|
||||
* 虚拟路径满足页面既有的文件名规整逻辑(baseName 按 / 切分、.xlsx 后缀校验)。
|
||||
*/
|
||||
function registerWebFiles(files: File[]): string[] {
|
||||
return files.map((file) => {
|
||||
const id = `web-file:${++webFileSeq}/${file.name}`
|
||||
webFileMap.set(id, file)
|
||||
return id
|
||||
})
|
||||
}
|
||||
|
||||
/** 扩展名过滤('xlsx,xls,csv')转 input accept('.xlsx,.xls,.csv') */
|
||||
function buildAccept(extensions?: string): string {
|
||||
return (extensions || '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.map((ext) => (ext.startsWith('.') ? ext : `.${ext}`))
|
||||
.join(',')
|
||||
}
|
||||
|
||||
/** 触发浏览器下载(a[download]) */
|
||||
function triggerAnchorDownload(url: string, filename: string) {
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
link.style.display = 'none'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
}
|
||||
|
||||
/** 模板文件下载:先 HEAD 预检(含 content-type 防 SPA/vite 回退 HTML),文件不存在时返回失败而非静默 404 */
|
||||
async function downloadTemplateFile(path: string, filename: string) {
|
||||
try {
|
||||
const resp = await window.fetch(path, { method: 'HEAD' })
|
||||
const contentType = resp.headers.get('content-type') || ''
|
||||
if (!resp.ok || contentType.includes('text/html')) {
|
||||
return { success: false, error: `模板文件不存在(HTTP ${resp.status})` }
|
||||
}
|
||||
} catch {
|
||||
return { success: false, error: '模板文件不可访问' }
|
||||
}
|
||||
triggerAnchorDownload(path, filename)
|
||||
return { success: true, path: filename }
|
||||
}
|
||||
|
||||
/** 浏览器环境下的桥降级实现:只提供浏览器可全链路完成的方法 */
|
||||
export const webFallbackApi: PywebviewApi = {
|
||||
/** 选择文件(Excel/CSV 等),返回虚拟路径列表 */
|
||||
async select_files(options) {
|
||||
const accept = buildAccept(options?.filters?.[0]?.extensions)
|
||||
const files = await pickBrowserFiles(accept, options?.multiple !== false)
|
||||
if (!files.length) return null
|
||||
return { paths: registerWebFiles(files) }
|
||||
},
|
||||
|
||||
/** 选择品牌 xlsx 文件(多选) */
|
||||
async select_brand_xlsx_files() {
|
||||
const files = await pickBrowserFiles('.xlsx', true)
|
||||
return registerWebFiles(files)
|
||||
},
|
||||
|
||||
/** 虚拟路径 → 浏览器 File → 上传 Java 临时目录;返回结构与桌面桥一致(透传 Java ApiResponse<UploadFileVo>) */
|
||||
async upload_file_to_java(filePath, relativePath) {
|
||||
const file = typeof filePath === 'string' ? webFileMap.get(filePath) : undefined
|
||||
if (!file) {
|
||||
return { success: false, error: '文件不存在,请重新选择' }
|
||||
}
|
||||
const form = new FormData()
|
||||
form.append('file', file, file.name)
|
||||
if (relativePath) {
|
||||
form.append('relativePath', relativePath)
|
||||
}
|
||||
try {
|
||||
// http 实例对 /newApi 前缀自动附带 Bearer;FormData 的 Content-Type 交由浏览器自动生成(含 boundary)
|
||||
const response = await http.post('/newApi/api/files/upload', form, { timeout: 120000 })
|
||||
return response.data
|
||||
} catch (error) {
|
||||
return { success: false, error: extractErrorMessage(error) }
|
||||
}
|
||||
},
|
||||
|
||||
/** 品牌文档模板(xlsx)直接浏览器下载 */
|
||||
async save_template_xlsx() {
|
||||
const name = '品牌文档格式_模板.xlsx'
|
||||
return downloadTemplateFile(`/static/${encodeURIComponent(name)}`, name)
|
||||
},
|
||||
|
||||
/** 文件夹方式上传模板(zip)直接浏览器下载 */
|
||||
async save_template_zip() {
|
||||
const name = '模板2-以文件夹方式上传.zip'
|
||||
return downloadTemplateFile(`/static/${encodeURIComponent(name)}`, name)
|
||||
},
|
||||
|
||||
/** 代理等桌面配置降级到 localStorage(Web 环境与桌面端配置相互独立) */
|
||||
async read_config() {
|
||||
return readStoredConfig()
|
||||
},
|
||||
|
||||
async save_config(data) {
|
||||
return writeStoredConfig(data)
|
||||
},
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import { ensureAuth } from '@/shared/auth/ensure-auth'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -9,4 +10,8 @@ import BrandShopDataCrawlTab from '@/pages/brand/components/BrandShopDataCrawlTa
|
||||
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
createApp(BrandShopDataCrawlTab).use(ElementPlus, { locale: zhCn }).mount('#app')
|
||||
ensureAuth().then((ok) => {
|
||||
if (ok) {
|
||||
createApp(BrandShopDataCrawlTab).use(ElementPlus, { locale: zhCn }).mount('#app')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import { ensureAuth } from '@/shared/auth/ensure-auth'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -9,4 +10,8 @@ import BrandShopMatchTab from '@/pages/brand/components/BrandShopMatchTab.vue'
|
||||
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
createApp(BrandShopMatchTab).use(ElementPlus, { locale: zhCn }).mount('#app')
|
||||
ensureAuth().then((ok) => {
|
||||
if (ok) {
|
||||
createApp(BrandShopMatchTab).use(ElementPlus, { locale: zhCn }).mount('#app')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { ensureAuth } from '@/shared/auth/ensure-auth'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import '@/styles/main.css'
|
||||
import BrandSimilarAsinTab from '@/pages/brand/components/BrandSimilarAsinTab.vue'
|
||||
|
||||
createApp(BrandSimilarAsinTab).use(ElementPlus).mount('#app')
|
||||
ensureAuth().then((ok) => {
|
||||
if (ok) {
|
||||
createApp(BrandSimilarAsinTab).use(ElementPlus).mount('#app')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { ensureAuth } from '@/shared/auth/ensure-auth'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import '@/styles/main.css'
|
||||
import BrandSplitTab from '@/pages/brand/components/BrandSplitTab.vue'
|
||||
|
||||
createApp(BrandSplitTab).use(ElementPlus).mount('#app')
|
||||
ensureAuth().then((ok) => {
|
||||
if (ok) {
|
||||
createApp(BrandSplitTab).use(ElementPlus).mount('#app')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import { ensureAuth } from '@/shared/auth/ensure-auth'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -9,4 +10,8 @@ import BrandVariantTab from '@/pages/brand/components/BrandVariantTab.vue'
|
||||
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
createApp(BrandVariantTab).use(ElementPlus, { locale: zhCn }).mount('#app')
|
||||
ensureAuth().then((ok) => {
|
||||
if (ok) {
|
||||
createApp(BrandVariantTab).use(ElementPlus, { locale: zhCn }).mount('#app')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import { ensureAuth } from '@/shared/auth/ensure-auth'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -9,6 +10,10 @@ import BrandWithdrawTab from '@/pages/brand/components/BrandWithdrawTab.vue'
|
||||
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
createApp(BrandWithdrawTab).use(ElementPlus, { locale: zhCn }).mount('#app')
|
||||
ensureAuth().then((ok) => {
|
||||
if (ok) {
|
||||
createApp(BrandWithdrawTab).use(ElementPlus, { locale: zhCn }).mount('#app')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user