refactor: 统一客户端窗口打开接口并支持标签页名称复用

- 合并 openChildWindow 和 openWebTabWindow 为统一的 openWindow 接口
  - 新增 webTabNameMap 映射,支持按名称查找和复用已存在的标签页
  - 标签页增加 name、titleFixed 元数据支持
  - 窗口间转移标签时同步更新名称映射
  - 重构前端 actions,统一使用 openWindow 方法,通过 mode 参数区分窗口/标签模式
  - 更新所有调用点使用新的统一接口
This commit is contained in:
kuaifan 2026-01-10 02:08:36 +00:00
parent fc74e0d952
commit 16d5ffd4f9
13 changed files with 172 additions and 62 deletions

134
electron/electron.js vendored
View File

@ -75,13 +75,16 @@ let mainWindow = null,
preloadWindow = null, preloadWindow = null,
mediaWindow = null; mediaWindow = null;
// 窗口数组和状态 // 独立子窗口管理
let childWindow = []; let childWindow = [];
// 多窗口 Tab 管理 // 多窗口 Tab 管理
// Map<windowId, {window, views: [{id, view}], activeTabId}> // Map<windowId, {window, views: [{id, view, name, favicon}], activeTabId}>
let webTabWindows = new Map(); let webTabWindows = new Map();
let webTabWindowIdCounter = 1; let webTabWindowIdCounter = 1;
// 标签名称到标签位置的映射,用于复用已存在的标签
// Map<name, {windowId, tabId}>
let webTabNameMap = new Map();
// 窗口配置和状态 // 窗口配置和状态
let mediaType = null, let mediaType = null,
@ -484,6 +487,7 @@ function createChildWindow(args) {
if (!utils.isJson(args)) { if (!utils.isJson(args)) {
args = {path: args, config: {}} args = {path: args, config: {}}
} }
args.path = args.path || args.url;
const name = args.name || "auto_" + utils.randomString(6); const name = args.name || "auto_" + utils.randomString(6);
const wind = childWindow.find(item => item.name == name); const wind = childWindow.find(item => item.name == name);
@ -740,7 +744,7 @@ function createMediaWindow(args, type = 'image') {
/** /**
* 创建内置浏览器窗口支持多窗口 * 创建内置浏览器窗口支持多窗口
* @param args {url, windowId, position, afterId, ...} * @param args {url, windowId, position, afterId, insertIndex, name, force, userAgent, title, titleFixed, webPreferences, ...}
* @returns {number} 窗口ID * @returns {number} 窗口ID
*/ */
function createWebTabWindow(args) { function createWebTabWindow(args) {
@ -752,6 +756,34 @@ function createWebTabWindow(args) {
args = {url: args} args = {url: args}
} }
// 如果有 name先查找是否已存在同名标签
if (args.name) {
const existing = webTabNameMap.get(args.name);
if (existing) {
const existingWindowData = webTabWindows.get(existing.windowId);
if (existingWindowData && existingWindowData.window && !existingWindowData.window.isDestroyed()) {
const viewItem = existingWindowData.views.find(v => v.id === existing.tabId);
if (viewItem && viewItem.view && !viewItem.view.webContents.isDestroyed()) {
// 激活已存在的标签
if (existingWindowData.window.isMinimized()) {
existingWindowData.window.restore();
}
existingWindowData.window.focus();
existingWindowData.window.show();
activateWebTabInWindow(existing.windowId, existing.tabId);
// force=true 时重新加载
if (args.force === true && args.url) {
viewItem.view.webContents.loadURL(args.url).catch(_ => {});
}
return existing.windowId;
}
}
// 标签已失效,清理映射
webTabNameMap.delete(args.name);
}
}
// 确定目标窗口ID // 确定目标窗口ID
let windowId = args.windowId; let windowId = args.windowId;
let windowData = windowId ? webTabWindows.get(windowId) : null; let windowData = windowId ? webTabWindows.get(windowId) : null;
@ -805,18 +837,28 @@ function createWebTabWindow(args) {
insertIndex = Math.max(0, Math.min(args.insertIndex, windowData.views.length)); insertIndex = Math.max(0, Math.min(args.insertIndex, windowData.views.length));
} }
// 插入到指定位置 // 插入到指定位置,包含 name 信息
windowData.views.splice(insertIndex, 0, { windowData.views.splice(insertIndex, 0, {
id: browserView.webContents.id, id: browserView.webContents.id,
view: browserView view: browserView,
name: args.name || null
}); });
// 如果有 name注册到映射
if (args.name) {
webTabNameMap.set(args.name, {
windowId: windowId,
tabId: browserView.webContents.id
});
}
utils.onDispatchEvent(webTabWindow.webContents, { utils.onDispatchEvent(webTabWindow.webContents, {
event: 'create', event: 'create',
id: browserView.webContents.id, id: browserView.webContents.id,
url: args.url, url: args.url,
afterId: args.afterId, afterId: args.afterId,
windowId: windowId, windowId: windowId,
title: args.title,
}).then(_ => { }); }).then(_ => { });
activateWebTabInWindow(windowId, browserView.webContents.id); activateWebTabInWindow(windowId, browserView.webContents.id);
@ -907,7 +949,11 @@ function createWebTabWindowInstance(windowId, position) {
webTabWindow.on('closed', () => { webTabWindow.on('closed', () => {
const windowData = webTabWindows.get(windowId); const windowData = webTabWindows.get(windowId);
if (windowData) { if (windowData) {
windowData.views.forEach(({view}) => { windowData.views.forEach(({view, name}) => {
// 清理 name 映射
if (name) {
webTabNameMap.delete(name);
}
try { try {
view.webContents.close(); view.webContents.close();
} catch (e) { } catch (e) {
@ -990,10 +1036,24 @@ function createWebTabView(windowId, args) {
height: (webTabWindow.getContentBounds().height || 800) - webTabHeight, height: (webTabWindow.getContentBounds().height || 800) - webTabHeight,
}); });
// 保存所属窗口ID // 保存所属窗口ID和元数据
browserView.webTabWindowId = windowId; browserView.webTabWindowId = windowId;
browserView.tabName = args.name || null;
browserView.titleFixed = args.titleFixed || false;
// 设置自定义 UserAgent
if (args.userAgent) {
const originalUA = browserView.webContents.getUserAgent();
browserView.webContents.setUserAgent(
originalUA + " SubTaskWindow/" + process.platform + "/" + os.arch() + "/1.0 " + args.userAgent
);
}
browserView.webContents.on('destroyed', () => { browserView.webContents.on('destroyed', () => {
// 清理 name 映射
if (browserView.tabName) {
webTabNameMap.delete(browserView.tabName);
}
closeWebTabInWindow(windowId, browserView.webContents.id); closeWebTabInWindow(windowId, browserView.webContents.id);
}); });
browserView.webContents.setWindowOpenHandler(({url}) => { browserView.webContents.setWindowOpenHandler(({url}) => {
@ -1004,7 +1064,11 @@ function createWebTabView(windowId, args) {
} }
return {action: 'deny'}; return {action: 'deny'};
}); });
browserView.webContents.on('page-title-updated', (event, title) => { browserView.webContents.on('page-title-updated', (_, title) => {
// titleFixed 时不更新标题
if (browserView.titleFixed) {
return;
}
// 使用动态窗口ID支持标签在窗口间转移 // 使用动态窗口ID支持标签在窗口间转移
const currentWindowId = browserView.webTabWindowId; const currentWindowId = browserView.webTabWindowId;
const wd = webTabWindows.get(currentWindowId); const wd = webTabWindows.get(currentWindowId);
@ -1293,6 +1357,12 @@ function closeWebTabInWindow(windowId, id) {
webTabWindow.hide(); webTabWindow.hide();
} }
webTabWindow.contentView.removeChildView(item.view); webTabWindow.contentView.removeChildView(item.view);
// 清理 name 映射
if (item.name) {
webTabNameMap.delete(item.name);
}
try { try {
item.view.webContents.close(); item.view.webContents.close();
} catch (e) { } catch (e) {
@ -1335,6 +1405,7 @@ function detachWebTab(windowId, tabId, screenX, screenY) {
const tabItem = sourceWindowData.views[tabIndex]; const tabItem = sourceWindowData.views[tabIndex];
const view = tabItem.view; const view = tabItem.view;
const favicon = tabItem.favicon || ''; const favicon = tabItem.favicon || '';
const tabName = tabItem.name || null;
const sourceWindow = sourceWindowData.window; const sourceWindow = sourceWindowData.window;
// 从源窗口移除视图 // 从源窗口移除视图
@ -1369,6 +1440,7 @@ function detachWebTab(windowId, tabId, screenX, screenY) {
window: newWindow, window: newWindow,
views: [{ views: [{
id: tabId, id: tabId,
name: tabName,
view, view,
favicon favicon
}], }],
@ -1379,6 +1451,14 @@ function detachWebTab(windowId, tabId, screenX, screenY) {
// 更新视图所属窗口 // 更新视图所属窗口
view.webTabWindowId = newWindowId; view.webTabWindowId = newWindowId;
// 更新 name 映射中的 windowId
if (tabName) {
webTabNameMap.set(tabName, {
windowId: newWindowId,
tabId: tabId
});
}
// 添加视图到新窗口 // 添加视图到新窗口
newWindow.contentView.addChildView(view); newWindow.contentView.addChildView(view);
view.setBounds({ view.setBounds({
@ -1448,6 +1528,7 @@ function attachWebTab(sourceWindowId, tabId, targetWindowId, insertIndex) {
const tabItem = sourceWindowData.views[tabIndex]; const tabItem = sourceWindowData.views[tabIndex];
const view = tabItem.view; const view = tabItem.view;
const favicon = tabItem.favicon || ''; const favicon = tabItem.favicon || '';
const tabName = tabItem.name || null;
const sourceWindow = sourceWindowData.window; const sourceWindow = sourceWindowData.window;
const targetWindow = targetWindowData.window; const targetWindow = targetWindowData.window;
@ -1464,14 +1545,23 @@ function attachWebTab(sourceWindowId, tabId, targetWindowId, insertIndex) {
// 更新视图所属窗口 // 更新视图所属窗口
view.webTabWindowId = targetWindowId; view.webTabWindowId = targetWindowId;
// 更新 name 映射中的 windowId
if (tabName) {
webTabNameMap.set(tabName, {
windowId: targetWindowId,
tabId: tabId
});
}
// 确定插入位置 // 确定插入位置
const actualInsertIndex = typeof insertIndex === 'number' const actualInsertIndex = typeof insertIndex === 'number'
? Math.max(0, Math.min(insertIndex, targetWindowData.views.length)) ? Math.max(0, Math.min(insertIndex, targetWindowData.views.length))
: targetWindowData.views.length; : targetWindowData.views.length;
// 添加到目标窗口 // 添加到目标窗口,保留 name 信息
targetWindowData.views.splice(actualInsertIndex, 0, { targetWindowData.views.splice(actualInsertIndex, 0, {
id: tabId, id: tabId,
name: tabName,
view, view,
favicon favicon
}); });
@ -1688,15 +1778,6 @@ ipcMain.on('windowQuit', (event) => {
app.quit(); app.quit();
}) })
/**
* 创建路由窗口
* @param args {path, ?}
*/
ipcMain.on('openChildWindow', (event, args) => {
createChildWindow(args)
event.returnValue = "ok"
})
/** /**
* 显示预加载窗口用于调试 * 显示预加载窗口用于调试
*/ */
@ -1747,11 +1828,22 @@ ipcMain.on('openMediaViewer', (event, args) => {
}); });
/** /**
* 内置浏览器 - 打开创建 * 统一窗口打开接口
* @param args {url, ?} * @param args {url, name, mode, force, config, userAgent, webPreferences, ...}
* - url: 要打开的地址
* - name: 窗口/标签名称
* - mode: 'tab' | 'window'
* - 'window': 独立窗口模式
* - 'tab': 标签页模式默认
*/ */
ipcMain.on('openWebTabWindow', (event, args) => { ipcMain.on('openWindow', (event, args) => {
if (args.mode === 'window') {
// 独立窗口模式
createChildWindow(args)
} else {
// 标签页模式
createWebTabWindow(args) createWebTabWindow(args)
}
event.returnValue = "ok" event.returnValue = "ok"
}) })

View File

@ -608,7 +608,7 @@ export default {
return false; return false;
} }
// 使 // 使
this.$store.dispatch("openWebTabWindow", url) this.$store.dispatch("openWindow", url)
return true return true
} }
this.$Electron.listener('browserWindowBlur', _ => { this.$Electron.listener('browserWindowBlur', _ => {

View File

@ -275,10 +275,10 @@ export default {
params.path = params.url params.path = params.url
delete params.url delete params.url
} }
this.$store.dispatch('openChildWindow', params); this.$store.dispatch('openWindow', params);
}, },
openTabWindow: (url) => { openTabWindow: (url) => {
this.$store.dispatch('openWebTabWindow', url); this.$store.dispatch('openWindow', {path: url});
}, },
openAppPage: (params) => { openAppPage: (params) => {
if (!$A.isJson(params)) { if (!$A.isJson(params)) {
@ -481,7 +481,7 @@ export default {
await $A.IDBSet("cacheMicroApps", $A.cloneJSON(apps)); await $A.IDBSet("cacheMicroApps", $A.cloneJSON(apps));
if (this.$Electron) { if (this.$Electron) {
await this.$store.dispatch('openChildWindow', { await this.$store.dispatch('openWindow', {
name: `single-apps-${$A.randomString(6)}`, name: `single-apps-${$A.randomString(6)}`,
path: path, path: path,
force: false, force: false,
@ -513,7 +513,7 @@ export default {
*/ */
async externalWindow(config) { async externalWindow(config) {
if (this.$Electron) { if (this.$Electron) {
await this.$store.dispatch('openChildWindow', { await this.$store.dispatch('openWindow', {
name: `external-apps-${$A.randomString(6)}`, name: `external-apps-${$A.randomString(6)}`,
path: config.url, path: config.url,
force: false, force: false,

View File

@ -3781,7 +3781,7 @@ export default {
const path = `/single/file/msg/${data.id}`; const path = `/single/file/msg/${data.id}`;
const title = data.type === 'longtext' ? this.$L('消息详情') : (`${msg.name} (${$A.bytesToSize(msg.size)})`); const title = data.type === 'longtext' ? this.$L('消息详情') : (`${msg.name} (${$A.bytesToSize(msg.size)})`);
if (this.$Electron) { if (this.$Electron) {
this.$store.dispatch('openChildWindow', { this.$store.dispatch('openWindow', {
name: `file-msg-${data.id}`, name: `file-msg-${data.id}`,
path: path, path: path,
userAgent: "/hideenOfficeTitle/", userAgent: "/hideenOfficeTitle/",

View File

@ -181,7 +181,7 @@ export default {
const title = $A.getFileName(this.file) + ` [${row.created_at}]`; const title = $A.getFileName(this.file) + ` [${row.created_at}]`;
const path = `/single/file/${this.fileId}?history_id=${row.id}&history_title=${title}`; const path = `/single/file/${this.fileId}?history_id=${row.id}&history_title=${title}`;
if (this.$Electron) { if (this.$Electron) {
this.$store.dispatch('openChildWindow', { this.$store.dispatch('openWindow', {
name: `file-${this.fileId}-${row.id}`, name: `file-${this.fileId}-${row.id}`,
path: path, path: path,
userAgent: "/hideenOfficeTitle/", userAgent: "/hideenOfficeTitle/",

View File

@ -411,9 +411,10 @@ export default {
video: this.addData.tracks.includes("video") ? 1 : 0, video: this.addData.tracks.includes("video") ? 1 : 0,
token: this.userToken, token: this.userToken,
}); });
this.$store.dispatch('openChildWindow', { this.$store.dispatch('openWindow', {
name: `meeting-window`, name: `meeting-window`,
path: meetingPath, path: meetingPath,
mode: 'window',
force: false, force: false,
config config
}); });

View File

@ -219,7 +219,7 @@ export default {
const path = `/${url}` const path = `/${url}`
if (this.$Electron) { if (this.$Electron) {
e.preventDefault() e.preventDefault()
this.$store.dispatch('openChildWindow', { this.$store.dispatch('openWindow', {
name: `project-log-${id}`, name: `project-log-${id}`,
path: path, path: path,
force: false, force: false,

View File

@ -111,7 +111,7 @@ export default {
width: Math.min(window.screen.availWidth, 1440), width: Math.min(window.screen.availWidth, 1440),
height: Math.min(window.screen.availHeight, 900), height: Math.min(window.screen.availHeight, 900),
} }
this.$store.dispatch('openChildWindow', { this.$store.dispatch('openWindow', {
name: `report-detail-${row.id}`, name: `report-detail-${row.id}`,
path: `/single/report/detail/${row.id}`, path: `/single/report/detail/${row.id}`,
force: false, force: false,
@ -134,7 +134,7 @@ export default {
width: Math.min(window.screen.availWidth, 1440), width: Math.min(window.screen.availWidth, 1440),
height: Math.min(window.screen.availHeight, 900), height: Math.min(window.screen.availHeight, 900),
} }
this.$store.dispatch('openChildWindow', { this.$store.dispatch('openWindow', {
name: `report-edit-${id}`, name: `report-edit-${id}`,
path: `/single/report/edit/${id}`, path: `/single/report/edit/${id}`,
force: false, force: false,

View File

@ -169,7 +169,7 @@ export default {
const title = (this.taskName || `ID: ${this.taskId}`) + ` [${row.created_at}]`; const title = (this.taskName || `ID: ${this.taskId}`) + ` [${row.created_at}]`;
const path = `/single/task/content/${this.taskId}?history_id=${row.id}&history_title=${title}`; const path = `/single/task/content/${this.taskId}?history_id=${row.id}&history_title=${title}`;
if (this.$Electron) { if (this.$Electron) {
this.$store.dispatch('openChildWindow', { this.$store.dispatch('openWindow', {
name: `task-content-${this.taskId}-${row.id}`, name: `task-content-${this.taskId}-${row.id}`,
path: path, path: path,
force: false, force: false,

View File

@ -1905,9 +1905,10 @@ export default {
config.minWidth = 800; config.minWidth = 800;
config.minHeight = 600; config.minHeight = 600;
} }
this.$store.dispatch('openChildWindow', { this.$store.dispatch('openWindow', {
name: `task-${this.taskDetail.id}`, name: `task-${this.taskDetail.id}`,
path: `/single/task/${this.taskDetail.id}?navActive=${this.navActive}`, path: `/single/task/${this.taskDetail.id}?navActive=${this.navActive}`,
mode: 'window',
force: false, force: false,
config config
}); });
@ -1967,7 +1968,7 @@ export default {
} }
const path = `/single/file/task/${file.id}`; const path = `/single/file/task/${file.id}`;
if (this.$Electron) { if (this.$Electron) {
this.$store.dispatch('openChildWindow', { this.$store.dispatch('openWindow', {
name: `file-task-${file.id}`, name: `file-task-${file.id}`,
path: path, path: path,
userAgent: "/hideenOfficeTitle/", userAgent: "/hideenOfficeTitle/",

View File

@ -1520,7 +1520,7 @@ export default {
openFileSingle(item) { openFileSingle(item) {
const path = `/single/file/${item.id}`; const path = `/single/file/${item.id}`;
if (this.$Electron) { if (this.$Electron) {
this.$store.dispatch('openChildWindow', { this.$store.dispatch('openWindow', {
name: `file-${item.id}`, name: `file-${item.id}`,
path: path, path: path,
userAgent: "/hideenOfficeTitle/", userAgent: "/hideenOfficeTitle/",

View File

@ -1383,28 +1383,43 @@ export default {
}, },
/** /**
* 打开窗口客户端 * 打开窗口客户端
* @param dispatch * @param dispatch
* @param params * @param params {path, name, mode, force, config, userAgent, webPreferences}
* - path: 要打开的地址或直接传 URL 字符串
* - name: 窗口/标签名称
* - mode: 'tab' | 'window'默认 'tab'
* - force: 是否强制刷新
* - config: 窗口配置独立窗口模式有效
* - userAgent: 自定义 UserAgent
* - webPreferences: 网页偏好设置
*/ */
async openChildWindow({dispatch}, params) { async openWindow({dispatch}, params) {
params.path = await dispatch("userUrl", params.path) // 兼容直接传入 URL 字符串的情况
$A.Electron.sendMessage('openChildWindow', params) if (typeof params === 'string') {
}, params = { path: params }
/**
* 打开新标签窗口客户端
* @param dispatch
* @param url
*/
async openWebTabWindow({dispatch}, url) {
const params = {url}
if ($A.getDomain(url) == $A.getDomain($A.mainUrl())) {
params.url = await dispatch("userUrl", url)
} else {
params.webPreferences = {contextIsolation: false}
} }
$A.Electron.sendMessage('openWebTabWindow', params)
// 外站 URL 自动移除 preload 脚本(通过 contextIsolation: false
const pathDomain = $A.getDomain(params.path)
const isExternal = pathDomain && pathDomain !== $A.getDomain($A.mainUrl())
if (isExternal) {
params.webPreferences = Object.assign({contextIsolation: false}, params.webPreferences)
} else {
params.path = await dispatch("userUrl", params.path)
}
$A.Electron.sendMessage('openWindow', {
url: params.path,
name: params.name,
mode: params.mode,
force: params.force,
config: params.config,
userAgent: params.userAgent,
title: params.config?.title,
titleFixed: params.config?.titleFixed,
webPreferences: params.webPreferences,
})
}, },
/** *****************************************************************************************/ /** *****************************************************************************************/
@ -3575,9 +3590,10 @@ export default {
return return
} }
const dialogData = state.cacheDialogs.find(({id}) => id === dialogId) || {} const dialogData = state.cacheDialogs.find(({id}) => id === dialogId) || {}
dispatch('openChildWindow', { dispatch('openWindow', {
name: `dialog-${dialogId}`, name: `dialog-${dialogId}`,
path: `/single/dialog/${dialogId}`, path: `/single/dialog/${dialogId}`,
mode: 'window',
force: false, force: false,
config: { config: {
title: dialogData.name, title: dialogData.name,

View File

@ -65,7 +65,7 @@ export function openFileInClient(vm, item, options = {}) {
}, options.windowConfig || {}); }, options.windowConfig || {});
if (vm.$Electron) { if (vm.$Electron) {
vm.$store.dispatch('openChildWindow', { vm.$store.dispatch('openWindow', {
name: windowName, name: windowName,
path, path,
userAgent: "/hideenOfficeTitle/", userAgent: "/hideenOfficeTitle/",