From 9907e0a0ef5c79448b09a68a5c116d5d6ecb1587 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=8A=B9=E9=9C=B2=E8=8C=B6=E6=9F=92?= Date: Wed, 5 Aug 2026 09:23:30 +0800 Subject: [PATCH] =?UTF-8?q?release(image-toolbox):=20v2.4=20=E6=AD=A3?= =?UTF-8?q?=E5=BC=8F=E5=8F=91=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增: 左侧工具栏支持展开/收起 - 新增: 图形工具新增菱形图形 - 新增: 接入统一账号系统(邮箱验证码/uTools 一键登录、昵称头像同步) - 修复: 窗口大小缩放时编辑内容与原图错位 - 修复: 调色工具调整滤镜滑块后无法撤销 - 修复: 调色面板销毁时未清理 DOM 事件监听器导致内存泄漏 - 修复: 马赛克辅助图形出现在导出结果和图层列表中 - 优化: 图形工具配色预设栏支持左右滑动 - 优化: 滤镜预设批量设置性能 - 优化: 窗口缩放时编辑内容布局保持逻辑 --- .../Image-Toolbox/core/src/CanvasManager.js | 98 +++- .../Image-Toolbox/core/src/LayerManager.js | 7 +- plugins/Image-Toolbox/core/src/app/App.js | 23 + .../core/src/identity/IdentityClient.js | 315 +++++++++++++ .../core/src/modules/BaseModule.js | 5 +- .../core/src/modules/ColorModule.js | 1 + .../core/src/modules/MosaicModule.js | 12 + .../core/src/modules/ShapeModule.js | 36 +- .../Image-Toolbox/core/src/ui/AccountPage.js | 440 +++++++++++++++++- .../Image-Toolbox/core/src/ui/OptionsBar.js | 32 ++ plugins/Image-Toolbox/core/src/ui/Toolbar.js | 122 ++++- .../Image-Toolbox/core/src/updateRecords.js | 24 + plugins/Image-Toolbox/plugin.json | 2 +- plugins/Image-Toolbox/src/style.css | 430 ++++++++++++++++- 14 files changed, 1515 insertions(+), 32 deletions(-) create mode 100644 plugins/Image-Toolbox/core/src/identity/IdentityClient.js diff --git a/plugins/Image-Toolbox/core/src/CanvasManager.js b/plugins/Image-Toolbox/core/src/CanvasManager.js index f934e8673..3e0136111 100644 --- a/plugins/Image-Toolbox/core/src/CanvasManager.js +++ b/plugins/Image-Toolbox/core/src/CanvasManager.js @@ -450,19 +450,105 @@ class CanvasManager { if (newWidth <= 0 || newHeight <= 0) return; if (this.canvas.width !== newWidth || this.canvas.height !== newHeight) { - this.canvas.setWidth(newWidth); - this.canvas.setHeight(newHeight); - this.canvas.calcOffset(); - - // 如果已加载图片,重新适配 + // 调整画布尺寸时保留编辑内容的相对布局,避免覆盖层与原图错位 if (this.originalImage) { - this.fitToCanvas(); + this._resizePreservingLayout(newWidth, newHeight); + } else { + this.canvas.setWidth(newWidth); + this.canvas.setHeight(newHeight); + this.canvas.calcOffset(); } eventBus.emit('canvas:resized', { width: newWidth, height: newHeight }); } } + /** + * 调整画布尺寸时保留编辑内容的相对布局 + * 计算原图在新画布尺寸下的 fit 变换,将变换差值同步应用到所有覆盖层和裁剪路径, + * 使覆盖层(文字/图形/画笔/马赛克等)与原图的相对位置保持不变 + */ + _resizePreservingLayout(newWidth, newHeight) { + const img = this.originalImage; + const padding = 40; + + // 记录原图当前的变换(可能是 fit 状态,也可能是用户手动调整后的状态) + const oldLeft = img.left; + const oldTop = img.top; + const oldScaleX = img.scaleX; + const oldScaleY = img.scaleY; + + // 计算新画布下的 fit 变换 + const availableW = newWidth - padding * 2; + const availableH = newHeight - padding * 2; + const newScale = Math.min(availableW / img.width, availableH / img.height, 1); + const newLeft = (newWidth - img.width * newScale) / 2; + const newTop = (newHeight - img.height * newScale) / 2; + + // 计算缩放比例(避免除零) + const ratioX = oldScaleX ? newScale / oldScaleX : 1; + const ratioY = oldScaleY ? newScale / oldScaleY : 1; + + // 更新画布尺寸 + this.canvas.setWidth(newWidth); + this.canvas.setHeight(newHeight); + this.canvas.calcOffset(); + + // 将差值应用到所有覆盖层(非原图、非临时对象) + const overlays = this.canvas.getObjects().filter(obj => + obj !== img && + !obj.excludeFromHistory && + !obj.excludeFromLayer + ); + overlays.forEach(obj => { + const relX = obj.left - oldLeft; + const relY = obj.top - oldTop; + obj.set({ + left: newLeft + relX * ratioX, + top: newTop + relY * ratioY, + scaleX: obj.scaleX * ratioX, + scaleY: obj.scaleY * ratioY, + }); + obj.setCoords(); + }); + + // 同步调整画布级裁剪路径,保留裁剪效果与原图的相对位置 + this._transformClipPath(this.canvas.clipPath, oldLeft, oldTop, newLeft, newTop, ratioX, ratioY); + + // 应用新的 fit 变换到原图 + img.set({ + scaleX: newScale, + scaleY: newScale, + left: newLeft, + top: newTop, + }); + img.setCoords(); + + this.canvas.renderAll(); + + // 刷新动态马赛克(基于新的原图位置重新计算) + this.refreshDynamicMosaics({ render: true }); + } + + /** + * 递归调整 clipPath 的位置和缩放,使其跟随原图变换 + */ + _transformClipPath(clipPath, oldLeft, oldTop, newLeft, newTop, ratioX, ratioY) { + if (!clipPath) return; + const relX = (clipPath.left || 0) - oldLeft; + const relY = (clipPath.top || 0) - oldTop; + clipPath.set({ + left: newLeft + relX * ratioX, + top: newTop + relY * ratioY, + scaleX: (clipPath.scaleX == null ? 1 : clipPath.scaleX) * ratioX, + scaleY: (clipPath.scaleY == null ? 1 : clipPath.scaleY) * ratioY, + }); + clipPath.setCoords(); + if (clipPath.clipPath) { + this._transformClipPath(clipPath.clipPath, oldLeft, oldTop, newLeft, newTop, ratioX, ratioY); + } + } + _bindEvents() { if (!this.canvas) return; diff --git a/plugins/Image-Toolbox/core/src/LayerManager.js b/plugins/Image-Toolbox/core/src/LayerManager.js index 276d20702..63ac020e0 100644 --- a/plugins/Image-Toolbox/core/src/LayerManager.js +++ b/plugins/Image-Toolbox/core/src/LayerManager.js @@ -95,11 +95,16 @@ class LayerManager { } /** - * 根据 Fabric 对象获取图层元数据 + * 根据 Fabric 对象获取图层元数据(公开接口) + * + * 仅在已同步的 _layers 列表中查找。对于尚未被 syncLayers() 收录的对象 + * (如工具激活期间新增的临时对象),返回 null,由调用方决定回退策略。 + * * @param {fabric.Object} obj * @returns {object|null} */ getLayerByObject(obj) { + if (!obj) return null; return this._findMeta(obj); } diff --git a/plugins/Image-Toolbox/core/src/app/App.js b/plugins/Image-Toolbox/core/src/app/App.js index 1014933bb..c8a3909ea 100644 --- a/plugins/Image-Toolbox/core/src/app/App.js +++ b/plugins/Image-Toolbox/core/src/app/App.js @@ -24,6 +24,8 @@ import AccountPage, { EDITOR_BARS_LAYOUTS, EDITOR_SIDE_PANEL_POSITION_KEY, EDITOR_SIDE_PANEL_POSITIONS, + TOOLBAR_COLLAPSED_KEY, + TOOLBAR_COLLAPSED, } from '../ui/AccountPage.js'; import { initTheme } from '../utils/theme.js'; @@ -64,6 +66,7 @@ class App { initTheme(); this._applyEditorBarsLayout(this._getEditorBarsLayout()); this._applyEditorSidePanelPosition(this._getEditorSidePanelPosition()); + this._applyToolbarCollapsed(this._getToolbarCollapsed()); // 1. 初始化画布管理器 this.canvasManager = new CanvasManager('fabric-canvas'); @@ -279,6 +282,10 @@ class App { this._applyEditorSidePanelPosition(position); }); + eventBus.on('toolbar:collapsedChanged', (value) => { + this._applyToolbarCollapsed(value); + }); + // ═══ 快捷键 ═══ document.addEventListener('keydown', (e) => { // Ctrl+Z 撤销 @@ -528,6 +535,22 @@ class App { normalized === EDITOR_SIDE_PANEL_POSITIONS.LEFT ); } + + _getToolbarCollapsed() { + const saved = localStorage.getItem(TOOLBAR_COLLAPSED_KEY); + return Object.values(TOOLBAR_COLLAPSED).includes(saved) ? saved : TOOLBAR_COLLAPSED.COLLAPSED; + } + + _applyToolbarCollapsed(value) { + const normalized = Object.values(TOOLBAR_COLLAPSED).includes(value) + ? value + : TOOLBAR_COLLAPSED.COLLAPSED; + + document.getElementById('app')?.classList.toggle( + 'app--toolbar-expanded', + normalized === TOOLBAR_COLLAPSED.EXPANDED + ); + } } export default App; diff --git a/plugins/Image-Toolbox/core/src/identity/IdentityClient.js b/plugins/Image-Toolbox/core/src/identity/IdentityClient.js new file mode 100644 index 000000000..f2e87dae0 --- /dev/null +++ b/plugins/Image-Toolbox/core/src/identity/IdentityClient.js @@ -0,0 +1,315 @@ +/** + * IdentityClient — 轻量认证客户端 + * + * 负责: + * - Token 存储 / 检查 / 刷新(与 Teaven Identity 交互) + * - 用户档案获取 / 修改(与业务后端交互) + * + * 登录方式: + * - 邮箱验证码(requestEmailCode + loginWithEmailCode) + * - uTools signed-plugin(loginWithUTools) + */ + +const DEFAULT_IDENTITY_BASE = 'https://identity.moruteaven.com'; +const DEFAULT_API_BASE = 'https://api.image-toolbox.moruteaven.com'; +const DEFAULT_CLIENT_ID = 'image-toolbox'; +const TOKEN_KEY = 'image_toolbox_tokens'; + +class IdentityClient { + constructor(options = {}) { + this.identityBaseUrl = (options.identityBaseUrl || DEFAULT_IDENTITY_BASE).replace(/\/+$/, ''); + this.apiBaseUrl = (options.apiBaseUrl || DEFAULT_API_BASE).replace(/\/+$/, ''); + this.clientId = options.clientId || DEFAULT_CLIENT_ID; + this.tokenKey = options.tokenKey || TOKEN_KEY; + } + + // ═══════════════════════════════════════ + // Token 管理 + // ═══════════════════════════════════════ + + _getStoredTokens() { + try { + const raw = localStorage.getItem(this.tokenKey); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (parsed && typeof parsed.accessToken === 'string' && typeof parsed.refreshToken === 'string') { + return parsed; + } + } catch {} + return null; + } + + _setTokens(tokens) { + localStorage.setItem(this.tokenKey, JSON.stringify(tokens)); + } + + _clearTokens() { + localStorage.removeItem(this.tokenKey); + } + + isAuthenticated() { + const tokens = this._getStoredTokens(); + return !!(tokens && tokens.accessToken && tokens.accessTokenExpiresAt > Date.now()); + } + + _getAuthHeader() { + const tokens = this._getStoredTokens(); + if (!tokens?.accessToken) return null; + return `Bearer ${tokens.accessToken}`; + } + + // ═══════════════════════════════════════ + // Identity API(登录 / Token 刷新) + // ═══════════════════════════════════════ + + async _identityRequest(path, options = {}) { + const url = new URL(path, this.identityBaseUrl + '/'); + if (options.query) { + for (const [k, v] of Object.entries(options.query)) { + if (v !== undefined) url.searchParams.set(k, v); + } + } + + const headers = { Accept: 'application/json', ...options.headers }; + let body; + if (options.body) { + headers['Content-Type'] = 'application/json'; + body = JSON.stringify(options.body); + } + + const res = await fetch(url.toString(), { + method: options.method || 'GET', + headers, + body, + }); + + const text = await res.text(); + let data = null; + if (text) { + try { data = JSON.parse(text); } catch { data = text; } + } + + if (!res.ok) { + throw { code: data?.code || 'HTTP_ERROR', message: data?.message || `HTTP ${res.status}`, status: res.status }; + } + + // 统一响应壳 { code, message, data, timestamp } + if (data && typeof data.code === 'string') { + if (data.code !== 'OK') { + throw data; + } + return data.data; + } + return data; + } + + /** 请求邮箱验证码 */ + requestEmailCode(email, purpose = 'login') { + return this._identityRequest('/auth/email/redirect', { + query: { email, purpose }, + }); + } + + /** 邮箱验证码登录 */ + async loginWithEmailCode(email, code, purpose = 'login') { + const result = await this._identityRequest('/auth/login', { + method: 'POST', + body: { + provider: 'email', + payload: { email, code, purpose }, + clientId: this.clientId, + }, + }); + this._setTokens(result); + return result; + } + + /** uTools signed-plugin 登录 */ + async loginWithUTools(accessToken, deviceId) { + const result = await this._identityRequest('/auth/login', { + method: 'POST', + body: { + provider: 'utools', + payload: { accessToken }, + clientId: this.clientId, + deviceId, + }, + }); + this._setTokens(result); + return result; + } + + /** 刷新 Token */ + async refresh() { + const tokens = this._getStoredTokens(); + if (!tokens?.refreshToken) { + throw { code: 'REFRESH_TOKEN_MISSING', message: 'Refresh token is missing' }; + } + + try { + const result = await this._identityRequest('/auth/refresh', { + method: 'POST', + body: { refreshToken: tokens.refreshToken }, + }); + this._setTokens(result); + return result; + } catch (e) { + this._clearTokens(); + throw e; + } + } + + /** 注销 */ + async logout() { + try { + await this._identityRequest('/auth/logout', { + method: 'POST', + headers: { Authorization: this._getAuthHeader() }, + }); + } catch {} + this._clearTokens(); + } + + // ═══════════════════════════════════════ + // 业务后端 API(用户档案) + // ═══════════════════════════════════════ + + async _apiRequest(path, options = {}) { + const url = new URL(path, this.apiBaseUrl + '/'); + const headers = { Accept: 'application/json' }; + let body; + if (options.body) { + headers['Content-Type'] = 'application/json'; + body = JSON.stringify(options.body); + } + + const authHeader = this._getAuthHeader(); + if (authHeader) { + headers['Authorization'] = authHeader; + } + + const res = await fetch(url.toString(), { + method: options.method || 'GET', + headers, + body, + }); + + const text = await res.text(); + let data = null; + if (text) { + try { data = JSON.parse(text); } catch { data = text; } + } + + // 401 → 尝试刷新 + if (res.status === 401) { + try { + await this.refresh(); + // 重试一次 + const newAuth = this._getAuthHeader(); + if (newAuth) headers['Authorization'] = newAuth; + const retryRes = await fetch(url.toString(), { method: options.method || 'GET', headers, body }); + const retryText = await retryRes.text(); + let retryData = null; + if (retryText) { + try { retryData = JSON.parse(retryText); } catch { retryData = retryText; } + } + if (retryData && typeof retryData.code === 'string' && retryData.code === 'OK') { + return retryData.data; + } + throw retryData || { code: 'HTTP_ERROR', message: `HTTP ${retryRes.status}` }; + } catch { + this._clearTokens(); + throw { code: 'UNAUTHORIZED', message: 'Token expired, please login again' }; + } + } + + if (!res.ok) { + throw data || { code: 'HTTP_ERROR', message: `HTTP ${res.status}`, status: res.status }; + } + + if (data && typeof data.code === 'string') { + if (data.code !== 'OK') throw data; + return data.data; + } + return data; + } + + /** 将头像相对路径转为完整 URL */ + _resolveAvatarUrl(profile) { + if (!profile) return profile; + if (profile.avatar && profile.avatar.startsWith('/api/avatars/')) { + return { ...profile, avatar: this.apiBaseUrl + profile.avatar }; + } + return profile; + } + + /** 获取用户档案(首次访问自动创建) */ + async getProfile() { + const profile = await this._apiRequest('/api/me'); + return this._resolveAvatarUrl(profile); + } + + /** 更新昵称 */ + updateProfile(patch) { + return this._apiRequest('/api/me', { method: 'PATCH', body: patch }); + } + + /** 上传头像文件(multipart/form-data) */ + async uploadAvatar(file) { + const url = new URL('/api/me/avatar', this.apiBaseUrl + '/'); + const formData = new FormData(); + formData.append('file', file); + + const headers = {}; + const authHeader = this._getAuthHeader(); + if (authHeader) { + headers['Authorization'] = authHeader; + } + + const res = await fetch(url.toString(), { + method: 'POST', + headers, + body: formData, + }); + + const text = await res.text(); + let data = null; + if (text) { + try { data = JSON.parse(text); } catch { data = text; } + } + + // 401 → 尝试刷新 + if (res.status === 401) { + try { + await this.refresh(); + const newAuth = this._getAuthHeader(); + if (newAuth) headers['Authorization'] = newAuth; + const retryRes = await fetch(url.toString(), { method: 'POST', headers, body: formData }); + const retryText = await retryRes.text(); + let retryData = null; + if (retryText) { + try { retryData = JSON.parse(retryText); } catch { retryData = retryText; } + } + if (retryData && typeof retryData.code === 'string' && retryData.code === 'OK') { + return this._resolveAvatarUrl(retryData.data); + } + throw retryData || { code: 'HTTP_ERROR', message: `HTTP ${retryRes.status}` }; + } catch { + this._clearTokens(); + throw { code: 'UNAUTHORIZED', message: 'Token expired, please login again' }; + } + } + + if (!res.ok) { + throw data || { code: 'HTTP_ERROR', message: `HTTP ${res.status}`, status: res.status }; + } + + if (data && typeof data.code === 'string') { + if (data.code !== 'OK') throw data; + return this._resolveAvatarUrl(data.data); + } + return data; + } +} + +export default IdentityClient; diff --git a/plugins/Image-Toolbox/core/src/modules/BaseModule.js b/plugins/Image-Toolbox/core/src/modules/BaseModule.js index 7d554e28a..0b04f2e41 100644 --- a/plugins/Image-Toolbox/core/src/modules/BaseModule.js +++ b/plugins/Image-Toolbox/core/src/modules/BaseModule.js @@ -1,4 +1,4 @@ -/** +/** * 模块基类 — 所有功能模块的抽象基类 */ class BaseModule { @@ -94,6 +94,9 @@ class BaseModule { objects.forEach(obj => { if (obj.excludeFromLayer || obj.excludeFromHistory) return; + // 优先走 LayerManager 公开接口。getLayerByObject 仅在已同步的 _layers + // 列表中查找;当对象尚未被 syncLayers() 收录(如工具激活期间新增的临时 + // 对象)时返回 null,此时回退到对象自身的 _layerLocked 标记判断锁定状态。 const meta = layerManager?.getLayerByObject?.(obj) || null; const locked = meta ? meta.locked : obj._layerLocked === true; if (locked && !meta?.isBackground && obj !== this.canvasManager.originalImage && !obj._originalImage) return; diff --git a/plugins/Image-Toolbox/core/src/modules/ColorModule.js b/plugins/Image-Toolbox/core/src/modules/ColorModule.js index 24b99c10f..15d83c992 100644 --- a/plugins/Image-Toolbox/core/src/modules/ColorModule.js +++ b/plugins/Image-Toolbox/core/src/modules/ColorModule.js @@ -159,6 +159,7 @@ class ColorModule extends BaseModule { if (eventType === 'change') { this._filterDragSaving = false; + this.history?.saveState?.(); eventBus.emit('canvas:objectModified', targets[0]); } return false; // 不刷新属性面板(避免滑块失焦) diff --git a/plugins/Image-Toolbox/core/src/modules/MosaicModule.js b/plugins/Image-Toolbox/core/src/modules/MosaicModule.js index 6edd670cd..1041787c3 100644 --- a/plugins/Image-Toolbox/core/src/modules/MosaicModule.js +++ b/plugins/Image-Toolbox/core/src/modules/MosaicModule.js @@ -246,6 +246,10 @@ class MosaicModule extends BaseModule { strokeDashArray: [4, 3], selectable: false, evented: false, + excludeFromExport: true, + excludeFromLayer: true, + excludeFromProperty: true, + excludeFromHistory: true, }); this.canvasManager.canvas.add(this._selectionRect); } @@ -310,6 +314,10 @@ class MosaicModule extends BaseModule { selectable: false, evented: false, objectCaching: false, + excludeFromExport: true, + excludeFromLayer: true, + excludeFromProperty: true, + excludeFromHistory: true, }); this.canvasManager.canvas.add(this._lassoPreview); this.canvasManager.canvas.renderAll(); @@ -445,6 +453,10 @@ class MosaicModule extends BaseModule { selectable: false, evented: false, objectCaching: false, + excludeFromExport: true, + excludeFromLayer: true, + excludeFromProperty: true, + excludeFromHistory: true, }); this.canvasManager.canvas.add(this._brushPreview); } else { diff --git a/plugins/Image-Toolbox/core/src/modules/ShapeModule.js b/plugins/Image-Toolbox/core/src/modules/ShapeModule.js index a858a44a0..6386c4836 100644 --- a/plugins/Image-Toolbox/core/src/modules/ShapeModule.js +++ b/plugins/Image-Toolbox/core/src/modules/ShapeModule.js @@ -3,7 +3,7 @@ import eventBus from '../EventBus.js'; import { clamp, escapeAttr, normalizeColor } from '../utils/helpers.js'; /** - * 图形绘制模块 - 支持矩形、椭圆、星星、心形、梯形、直线、箭头等多种图形 + * 图形绘制模块 - 支持矩形、椭圆、星星、心形、梯形、平行四边形、菱形、直线、箭头等多种图形 */ class ShapeModule extends BaseModule { static SHAPE_OPTIONS = [ @@ -14,6 +14,7 @@ class ShapeModule extends BaseModule { { type: 'heart', preset: 'shape-type-heart', label: '心形', icon: '' }, { type: 'trapezoid', preset: 'shape-type-trapezoid', label: '梯形', icon: '' }, { type: 'parallelogram', preset: 'shape-type-parallelogram', label: '平行四边形', icon: '' }, + { type: 'diamond', preset: 'shape-type-diamond', label: '菱形', icon: '' }, { type: 'line', preset: 'shape-type-line', label: '直线', icon: '' }, { type: 'arrow', preset: 'shape-type-arrow', label: '箭头', icon: '' }, { type: 'double-arrow', preset: 'shape-type-double-arrow', label: '双箭头', icon: '' }, @@ -94,7 +95,7 @@ class ShapeModule extends BaseModule { } setShapeType(type) { - if (['rect', 'triangle', 'circle', 'star', 'heart', 'trapezoid', 'parallelogram', 'line', 'arrow', 'double-arrow'].includes(type)) { + if (['rect', 'triangle', 'circle', 'star', 'heart', 'trapezoid', 'parallelogram', 'diamond', 'line', 'arrow', 'double-arrow'].includes(type)) { this.options.shapeType = type; } } @@ -142,6 +143,7 @@ class ShapeModule extends BaseModule { 'shape-type-heart': { shapeType: 'heart' }, 'shape-type-trapezoid': { shapeType: 'trapezoid' }, 'shape-type-parallelogram': { shapeType: 'parallelogram' }, + 'shape-type-diamond': { shapeType: 'diamond' }, 'shape-type-line': { shapeType: 'line' }, 'shape-type-arrow': { shapeType: 'arrow' }, 'shape-type-double-arrow': { shapeType: 'double-arrow' }, @@ -183,8 +185,10 @@ class ShapeModule extends BaseModule { -
- ${colorPresets} +
+
+ ${colorPresets} +
@@ -264,7 +268,7 @@ class ShapeModule extends BaseModule { ${this.options.strokeWidth}px
-
拖拽鼠标绘制图形,支持矩形、三角形、椭圆、星星、心形等。
+
拖拽鼠标绘制图形,支持矩形、三角形、椭圆、星星、心形、菱形等。
`; } @@ -431,6 +435,9 @@ class ShapeModule extends BaseModule { case 'parallelogram': return this._createParallelogram(left, top, width, height, commonProps); + case 'diamond': + return this._createDiamond(left, top, width, height, commonProps); + case 'line': return this._createLine(startPoint, endPoint, commonProps); @@ -553,6 +560,25 @@ class ShapeModule extends BaseModule { }); } + _createDiamond(left, top, width, height, props) { + const centerX = left + width / 2; + const centerY = top + height / 2; + const points = [ + { x: 0, y: -height / 2 }, + { x: width / 2, y: 0 }, + { x: 0, y: height / 2 }, + { x: -width / 2, y: 0 }, + ]; + + return new fabric.Polygon(points, { + ...props, + left: centerX, + top: centerY, + originX: 'center', + originY: 'center', + }); + } + _createLine(startPoint, endPoint, props) { return new fabric.Line([startPoint.x, startPoint.y, endPoint.x, endPoint.y], { ...props, diff --git a/plugins/Image-Toolbox/core/src/ui/AccountPage.js b/plugins/Image-Toolbox/core/src/ui/AccountPage.js index 51befec69..e33993c80 100644 --- a/plugins/Image-Toolbox/core/src/ui/AccountPage.js +++ b/plugins/Image-Toolbox/core/src/ui/AccountPage.js @@ -3,6 +3,7 @@ import { SIDE_PANEL_LAYOUT_KEY, SIDE_PANEL_LAYOUTS } from './SidePanelTabs.js'; import { THEME_CHOICES, applyThemeChoice, getThemeChoice } from '../utils/theme.js'; import { updateCategories, updateRecords, PLATFORMS } from '../updateRecords.js'; import { escapeHTML, escapeAttr } from '../utils/helpers.js'; +import IdentityClient from '../identity/IdentityClient.js'; /** * 获取当前平台标识 @@ -45,9 +46,23 @@ export const TOOLBAR_LABELS_VISIBLE = { OFF: 'off', }; +export const TOOLBAR_COLLAPSED_KEY = 'image-toolbox-toolbar-collapsed'; +export const TOOLBAR_COLLAPSED = { + EXPANDED: 'expanded', + COLLAPSED: 'collapsed', +}; + +export const TOOLBAR_TOGGLE_VISIBLE_KEY = 'image-toolbox-toolbar-toggle-visible'; +export const TOOLBAR_TOGGLE_VISIBLE = { + ON: 'on', + OFF: 'off', +}; + const VALID_EDITOR_BARS_LAYOUTS = new Set(Object.values(EDITOR_BARS_LAYOUTS)); const VALID_EDITOR_SIDE_PANEL_POSITIONS = new Set(Object.values(EDITOR_SIDE_PANEL_POSITIONS)); const VALID_TOOLBAR_LABELS_VISIBLE = new Set(Object.values(TOOLBAR_LABELS_VISIBLE)); +const VALID_TOOLBAR_COLLAPSED = new Set(Object.values(TOOLBAR_COLLAPSED)); +const VALID_TOOLBAR_TOGGLE_VISIBLE = new Set(Object.values(TOOLBAR_TOGGLE_VISIBLE)); /** * Account page UI component. @@ -62,6 +77,10 @@ class AccountPage { this._activeSection = 'mine'; this._user = this._getHostUser(); this._eventBusUnsubscribers = []; + this._identity = new IdentityClient(); + this._profile = null; + this._profileLoading = false; + this._nicknameEditing = false; this._render(); this._bindEvents(); @@ -72,6 +91,10 @@ class AccountPage { this._render(); this._editorEl?.classList.add('hidden'); this._el?.classList.remove('hidden'); + // 打开时尝试加载档案 + if (this._identity.isAuthenticated() && !this._profile) { + this._loadProfile(); + } } close() { @@ -134,6 +157,7 @@ class AccountPage { const navItem = this._closest(e.target, '[data-section]'); if (navItem) { this._activeSection = navItem.getAttribute('data-section'); + this._nicknameEditing = false; this._render(); return; } @@ -183,6 +207,82 @@ class AccountPage { if (toolbarLabels) { this._setToolbarLabelsVisible(toolbarLabels); this._render(); + return; + } + + const toolbarCollapsed = this._closest(e.target, '[data-toolbar-collapsed]')?.getAttribute('data-toolbar-collapsed'); + if (toolbarCollapsed) { + this._setToolbarCollapsed(toolbarCollapsed); + this._render(); + return; + } + + const toolbarToggleVisible = this._closest(e.target, '[data-toolbar-toggle-visible]')?.getAttribute('data-toolbar-toggle-visible'); + if (toolbarToggleVisible) { + this._setToolbarToggleVisible(toolbarToggleVisible); + this._render(); + return; + } + + // ── 账户区域操作 ── + const accountAction = this._closest(e.target, '[data-action]')?.getAttribute('data-action'); + if (accountAction === 'login') { + this._openLoginModal(); + return; + } + if (accountAction === 'logout') { + this._handleLogout(); + return; + } + if (accountAction === 'edit-nickname') { + this._nicknameEditing = true; + this._render(); + this._el?.querySelector('[data-nickname-input]')?.focus(); + return; + } + if (accountAction === 'cancel-nickname') { + this._nicknameEditing = false; + this._render(); + return; + } + if (accountAction === 'save-nickname') { + const input = this._el?.querySelector('[data-nickname-input]'); + if (input) this._handleNicknameSave(input.value); + return; + } + if (accountAction === 'upload-avatar') { + this._el?.querySelector('[data-avatar-input]')?.click(); + return; + } + + // ── 登录弹窗操作 ── + const modalAction = this._closest(e.target, '[data-modal-action]')?.getAttribute('data-modal-action'); + if (modalAction === 'close-login') { + this._closeLoginModal(); + return; + } + if (modalAction === 'utools-login') { + this._handleUToolsLogin(); + return; + } + if (modalAction === 'send-code') { + const emailInput = document.getElementById('login-email-input'); + if (emailInput) this._handleSendCode(emailInput.value); + return; + } + if (modalAction === 'email-login') { + const emailInput = document.getElementById('login-email-input'); + const codeInput = document.getElementById('login-code-input'); + if (emailInput && codeInput) this._handleEmailLogin(emailInput.value, codeInput.value); + return; + } + }); + + // 头像文件选择 + this._el.addEventListener('change', (e) => { + const fileInput = e.target.closest('[data-avatar-input]'); + if (fileInput && fileInput.files?.[0]) { + this._handleAvatarUpload(fileInput.files[0]); } }); @@ -222,21 +322,76 @@ class AccountPage { return this._renderMine(); } + // ═══════════════════════════════════════ + // 我的区域(登录 / 昵称头像编辑) + // ═══════════════════════════════════════ + _renderMine() { - const user = this._getUserView(); - const hostName = this._getHostName(); - return ` -