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:
2026-09-07 16:35:19 +08:00
parent c5982dd900
commit 30b8c910e8
28 changed files with 424 additions and 32 deletions
@@ -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
}
}
+26 -2
View File
@@ -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 前缀自动附带 BearerFormData 的 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)
},
}