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}
+
@@ -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 `
-
-
- ${this._renderAvatar('account-page__avatar account-page__avatar--large')}
+ let identityCard = '';
+ if (this._profileLoading) {
+ identityCard = `
+
-
-
${this._escapeHTML(hostName)} 账号
-
${this._escapeHTML(user.name)}
-
${this._escapeHTML(user.status)}
+ `;
+ } else if (!this._identity.isAuthenticated()) {
+ identityCard = `
+
+
+
我的账号
+
未登录
+
登录后可以同步昵称和头像,使用素材网盘等更多功能。
+
+
-
- `;
+ `;
+ } else {
+ const profile = this._profile || {};
+ const nickname = profile.nickname || '未设置';
+ const avatar = profile.avatar;
+ const initial = this._getInitial(nickname);
+ const uid = profile.id || '—';
+
+ let nicknameRow = '';
+ if (this._nicknameEditing) {
+ nicknameRow = `
+
+
+
+
+
+ `;
+ } else {
+ nicknameRow = `
+
+ ${this._escapeHTML(nickname)}
+
+
+ `;
+ }
+
+ identityCard = `
+
+
+ ${avatar
+ ? `
})
`
+ : `
${this._escapeHTML(initial)}
`
+ }
+
更换
+
+
+
我的账号
+ ${nicknameRow}
+
UID:${this._escapeHTML(uid)}
+
昵称和头像将同步到所有已登录的设备。
+
+
+
+
+ `;
+ }
+
+ return identityCard;
}
_renderSettings() {
@@ -245,6 +400,8 @@ class AccountPage {
const editorBarsLayout = this._getEditorBarsLayout();
const editorSidePanelPosition = this._getEditorSidePanelPosition();
const toolbarLabels = this._getToolbarLabelsVisible();
+ const toolbarCollapsed = this._getToolbarCollapsed();
+ const toolbarToggleVisible = this._getToolbarToggleVisible();
return `
外观
@@ -296,6 +453,26 @@ class AccountPage {
+
+
+
编辑器
+
侧栏展开/收起
+
展开后侧栏图标和文字并排显示,并在头像旁展示昵称;收起后仅显示图标和简短文字,更节省空间。
+
+
+
+
+
+
+
+
编辑器
+
侧栏展开/收起按钮
+
选择是否在侧栏顶部显示展开/收起切换按钮。关闭后仍可在设置中切换侧栏状态。
+
+
+
+
+
`;
}
@@ -545,6 +722,20 @@ class AccountPage {
eventBus.emit('toolbar:labelsVisibilityChanged', value);
}
+ _setToolbarCollapsed(value) {
+ if (!VALID_TOOLBAR_COLLAPSED.has(value)) return;
+
+ localStorage.setItem(TOOLBAR_COLLAPSED_KEY, value);
+ eventBus.emit('toolbar:collapsedChanged', value);
+ }
+
+ _setToolbarToggleVisible(value) {
+ if (!VALID_TOOLBAR_TOGGLE_VISIBLE.has(value)) return;
+
+ localStorage.setItem(TOOLBAR_TOGGLE_VISIBLE_KEY, value);
+ eventBus.emit('toolbar:toggleVisibleChanged', value);
+ }
+
_getSidePanelLayout() {
const saved = localStorage.getItem(SIDE_PANEL_LAYOUT_KEY);
return Object.values(SIDE_PANEL_LAYOUTS).includes(saved) ? saved : SIDE_PANEL_LAYOUTS.TABS;
@@ -565,6 +756,16 @@ class AccountPage {
return VALID_TOOLBAR_LABELS_VISIBLE.has(saved) ? saved : TOOLBAR_LABELS_VISIBLE.ON;
}
+ _getToolbarCollapsed() {
+ const saved = localStorage.getItem(TOOLBAR_COLLAPSED_KEY);
+ return VALID_TOOLBAR_COLLAPSED.has(saved) ? saved : TOOLBAR_COLLAPSED.COLLAPSED;
+ }
+
+ _getToolbarToggleVisible() {
+ const saved = localStorage.getItem(TOOLBAR_TOGGLE_VISIBLE_KEY);
+ return VALID_TOOLBAR_TOGGLE_VISIBLE.has(saved) ? saved : TOOLBAR_TOGGLE_VISIBLE.ON;
+ }
+
_getHostUser() {
try {
const result = this._host?.user?.getCurrentUser?.() || this._host?.getHostUser?.() || null;
@@ -601,6 +802,221 @@ class AccountPage {
return text ? text.slice(0, 1).toUpperCase() : 'U';
}
+ _formatTime(ts) {
+ if (!ts) return '—';
+ const d = new Date(typeof ts === 'number' ? ts : Date.parse(ts));
+ if (isNaN(d.getTime())) return '—';
+ return d.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
+ }
+
+ // ═══════════════════════════════════════
+ // 账户异步操作
+ // ═══════════════════════════════════════
+
+ async _loadProfile() {
+ this._profileLoading = true;
+ this._render();
+ try {
+ this._profile = await this._identity.getProfile();
+ } catch (e) {
+ console.warn('[AccountPage] 加载用户档案失败:', e);
+ this._profile = null;
+ }
+ this._profileLoading = false;
+ this._render();
+ // 通知侧栏等外部组件同步刷新头像
+ eventBus.emit('account:profileChanged', this._profile);
+ }
+
+ async _handleNicknameSave(nickname) {
+ const trimmed = String(nickname || '').trim();
+ if (!trimmed) {
+ eventBus.emit('toast:show', { message: '昵称不能为空', type: 'error' });
+ return;
+ }
+ if (trimmed.length > 32) {
+ eventBus.emit('toast:show', { message: '昵称最多 32 字符', type: 'error' });
+ return;
+ }
+ try {
+ this._profile = await this._identity.updateProfile({ nickname: trimmed });
+ this._nicknameEditing = false;
+ this._render();
+ eventBus.emit('toast:show', { message: '昵称已更新', type: 'success' });
+ eventBus.emit('account:profileChanged', this._profile);
+ } catch (e) {
+ eventBus.emit('toast:show', { message: e?.message || '保存失败', type: 'error' });
+ }
+ }
+
+ async _handleAvatarUpload(file) {
+ try {
+ this._profile = await this._identity.uploadAvatar(file);
+ this._render();
+ eventBus.emit('toast:show', { message: '头像已更新', type: 'success' });
+ eventBus.emit('account:profileChanged', this._profile);
+ } catch (e) {
+ eventBus.emit('toast:show', { message: e?.message || '头像上传失败', type: 'error' });
+ }
+ }
+
+ async _handleLogout() {
+ try {
+ await this._identity.logout();
+ } catch {}
+ this._profile = null;
+ this._nicknameEditing = false;
+ this._render();
+ eventBus.emit('toast:show', { message: '已退出登录', type: 'success' });
+ eventBus.emit('account:profileChanged', null);
+ }
+
+ // ═══════════════════════════════════════
+ // 登录弹窗
+ // ═══════════════════════════════════════
+
+ _openLoginModal() {
+ let modal = document.getElementById('login-modal');
+ if (!modal) {
+ modal = document.createElement('div');
+ modal.id = 'login-modal';
+ modal.className = 'login-modal';
+ document.body.appendChild(modal);
+ }
+ const isUTools = !!window.utools;
+ modal.innerHTML = `
+
+
+
+
+ ${isUTools ? `
+
+
或
+ ` : ''}
+
+
+
+
+
+
+
首次登录将自动注册账号
+
+
+ `;
+ modal.classList.add('login-modal--active');
+
+ // 弹窗挂载在 document.body 上,不在 this._el 内,
+ // 因此需要单独绑定点击事件
+ modal.onclick = (e) => {
+ const modalAction = e.target.closest('[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;
+ }
+ };
+ }
+
+ _closeLoginModal() {
+ const modal = document.getElementById('login-modal');
+ if (modal) {
+ modal.classList.remove('login-modal--active');
+ setTimeout(() => modal.remove(), 200);
+ }
+ }
+
+ async _handleSendCode(email) {
+ const btn = document.getElementById('send-code-btn');
+ if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+ eventBus.emit('toast:show', { message: '请输入有效的邮箱地址', type: 'error' });
+ return;
+ }
+ try {
+ if (btn) { btn.disabled = true; btn.textContent = '发送中…'; }
+ await this._identity.requestEmailCode(email, 'login');
+ eventBus.emit('toast:show', { message: '验证码已发送', type: 'success' });
+ this._startCountdown(btn, 60);
+ } catch (e) {
+ eventBus.emit('toast:show', { message: e?.message || '发送失败', type: 'error' });
+ if (btn) { btn.disabled = false; btn.textContent = '发送验证码'; }
+ }
+ }
+
+ _startCountdown(btn, seconds) {
+ if (!btn) return;
+ let remaining = seconds;
+ btn.disabled = true;
+ btn.textContent = `${remaining}s`;
+ const timer = setInterval(() => {
+ remaining--;
+ if (remaining <= 0) {
+ clearInterval(timer);
+ btn.disabled = false;
+ btn.textContent = '发送验证码';
+ } else {
+ btn.textContent = `${remaining}s`;
+ }
+ }, 1000);
+ }
+
+ async _handleEmailLogin(email, code) {
+ if (!email || !code) {
+ eventBus.emit('toast:show', { message: '请填写邮箱和验证码', type: 'error' });
+ return;
+ }
+ try {
+ await this._identity.loginWithEmailCode(email, code, 'login');
+ this._closeLoginModal();
+ eventBus.emit('toast:show', { message: '登录成功', type: 'success' });
+ await this._loadProfile();
+ } catch (e) {
+ eventBus.emit('toast:show', { message: e?.message || '登录失败', type: 'error' });
+ }
+ }
+
+ async _handleUToolsLogin() {
+ try {
+ const api = window.utools;
+ if (!api?.fetchUserServerTemporaryToken) {
+ eventBus.emit('toast:show', { message: '当前环境不支持一键登录', type: 'error' });
+ return;
+ }
+ const { token: accessToken } = await api.fetchUserServerTemporaryToken();
+ const deviceId = api.getDeviceId?.() || 'utools-device';
+ await this._identity.loginWithUTools(accessToken, deviceId);
+ this._closeLoginModal();
+ eventBus.emit('toast:show', { message: '登录成功', type: 'success' });
+ await this._loadProfile();
+ } catch (e) {
+ eventBus.emit('toast:show', { message: e?.message || '登录失败', type: 'error' });
+ }
+ }
+
_escapeAttr(value) {
return escapeAttr(value);
}
diff --git a/plugins/Image-Toolbox/core/src/ui/OptionsBar.js b/plugins/Image-Toolbox/core/src/ui/OptionsBar.js
index 27d6394f1..29d6b7a91 100644
--- a/plugins/Image-Toolbox/core/src/ui/OptionsBar.js
+++ b/plugins/Image-Toolbox/core/src/ui/OptionsBar.js
@@ -56,6 +56,16 @@ class OptionsBar {
this._el.addEventListener('click', (e) => {
this._handleControlEvent(e);
});
+
+ // 鼠标悬停在配色预设滑动区时,滚轮转为横向滚动
+ this._el.addEventListener('wheel', (e) => {
+ const scrollEl = e.target.closest('.shape-style-scroll');
+ if (!scrollEl) return;
+ // 仅在纵向滚轮占主导时接管(触控板原生横向滚动不拦截)
+ if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
+ e.preventDefault();
+ scrollEl.scrollLeft += e.deltaY;
+ }, { passive: false });
}
_updateControls() {
@@ -67,11 +77,33 @@ class OptionsBar {
const module = this._tm.getCurrentModule();
if (module && typeof module.getOptionsBarHTML === 'function') {
controlsEl.innerHTML = module.getOptionsBarHTML();
+ this._scrollActiveShapePresetIntoView(controlsEl);
} else {
controlsEl.innerHTML = '';
}
}
+ /**
+ * 图形工具配色预设可横向滑动,重渲染后把当前选中的预设滚到可视区,
+ * 避免点击后滚动位置被重置导致激活项不可见。
+ */
+ _scrollActiveShapePresetIntoView(container) {
+ const scrollEl = container.querySelector('.shape-style-scroll');
+ if (!scrollEl) return;
+ const activeBtn = scrollEl.querySelector('.shape-style-btn.active');
+ if (!activeBtn) return;
+
+ const scrollRect = scrollEl.getBoundingClientRect();
+ const btnRect = activeBtn.getBoundingClientRect();
+ const margin = 8;
+
+ if (btnRect.left < scrollRect.left + margin) {
+ scrollEl.scrollLeft -= (scrollRect.left + margin - btnRect.left);
+ } else if (btnRect.right > scrollRect.right - margin) {
+ scrollEl.scrollLeft += (btnRect.right - (scrollRect.right - margin));
+ }
+ }
+
_handleControlEvent(e) {
const pickerToggle = e.target.closest('[data-shape-picker-toggle]');
if (pickerToggle) {
diff --git a/plugins/Image-Toolbox/core/src/ui/Toolbar.js b/plugins/Image-Toolbox/core/src/ui/Toolbar.js
index bd3320784..8d5ee385b 100644
--- a/plugins/Image-Toolbox/core/src/ui/Toolbar.js
+++ b/plugins/Image-Toolbox/core/src/ui/Toolbar.js
@@ -1,6 +1,7 @@
import { eventBus } from '../index.js';
import { escapeHTML, escapeAttr } from '../utils/helpers.js';
-import { TOOLBAR_LABELS_VISIBLE_KEY, TOOLBAR_LABELS_VISIBLE } from './AccountPage.js';
+import { TOOLBAR_LABELS_VISIBLE_KEY, TOOLBAR_LABELS_VISIBLE, TOOLBAR_COLLAPSED_KEY, TOOLBAR_COLLAPSED, TOOLBAR_TOGGLE_VISIBLE_KEY, TOOLBAR_TOGGLE_VISIBLE } from './AccountPage.js';
+import IdentityClient from '../identity/IdentityClient.js';
/**
* Toolbar UI component.
@@ -13,6 +14,9 @@ class Toolbar {
this._host = host;
this._currentTool = 'select';
this._user = this._getHostUser();
+ this._identity = new IdentityClient();
+ this._profile = null;
+ this._profileLoading = false;
this._eventBusUnsubscribers = [];
// SVG 图标模板
@@ -27,11 +31,16 @@ class Toolbar {
shape: `
`,
undo: `
`,
redo: `
`,
+ collapse: `
`,
+ expand: `
`,
};
this._render();
this._bindEvents();
this._applyLabelsVisibility();
+ this._applyToolbarState();
+ this._applyToggleVisible();
+ this._loadProfileIfAuthenticated();
}
_render() {
@@ -82,6 +91,9 @@ class Toolbar {
`;
this._el.innerHTML = `
+
${toolsHtml}
`;
@@ -89,6 +101,12 @@ class Toolbar {
_bindEvents() {
this._el.addEventListener('click', (e) => {
+ const toggleBtn = e.target.closest('[data-action="toggle-collapse"]');
+ if (toggleBtn) {
+ this._toggleCollapsed();
+ return;
+ }
+
const account = e.target.closest('.toolbar__account');
if (account) {
eventBus.emit('account:open');
@@ -135,6 +153,16 @@ class Toolbar {
}),
eventBus.on('toolbar:labelsVisibilityChanged', (value) => {
this._applyLabelsVisibility(value);
+ }),
+ eventBus.on('toolbar:collapsedChanged', (value) => {
+ this._applyToolbarState(value);
+ }),
+ eventBus.on('toolbar:toggleVisibleChanged', (value) => {
+ this._applyToggleVisible(value);
+ }),
+ // 账户页登录 / 登出 / 资料更新后,同步刷新侧栏头像
+ eventBus.on('account:profileChanged', () => {
+ this._loadProfileIfAuthenticated();
})
);
}
@@ -150,6 +178,46 @@ class Toolbar {
this._el.classList.toggle('toolbar--labels-hidden', resolved === TOOLBAR_LABELS_VISIBLE.OFF);
}
+ _getCollapsed() {
+ const saved = localStorage.getItem(TOOLBAR_COLLAPSED_KEY);
+ return saved === TOOLBAR_COLLAPSED.EXPANDED ? TOOLBAR_COLLAPSED.EXPANDED : TOOLBAR_COLLAPSED.COLLAPSED;
+ }
+
+ _applyToolbarState(value) {
+ const resolved = value || this._getCollapsed();
+ if (!this._el) return;
+ const isExpanded = resolved === TOOLBAR_COLLAPSED.EXPANDED;
+ this._el.classList.toggle('toolbar--expanded', isExpanded);
+ document.getElementById('app')?.classList.toggle('app--toolbar-expanded', isExpanded);
+
+ const toggleBtn = this._el.querySelector('.toolbar__toggle');
+ if (toggleBtn) {
+ toggleBtn.title = isExpanded ? '收起侧栏' : '展开侧栏';
+ toggleBtn.setAttribute('aria-label', isExpanded ? '收起侧栏' : '展开侧栏');
+ toggleBtn.innerHTML = isExpanded ? this._icons.collapse : this._icons.expand;
+ }
+ }
+
+ _getToggleVisible() {
+ const saved = localStorage.getItem(TOOLBAR_TOGGLE_VISIBLE_KEY);
+ return saved === TOOLBAR_TOGGLE_VISIBLE.OFF ? TOOLBAR_TOGGLE_VISIBLE.OFF : TOOLBAR_TOGGLE_VISIBLE.ON;
+ }
+
+ _applyToggleVisible(value) {
+ const resolved = value || this._getToggleVisible();
+ if (!this._el) return;
+ this._el.classList.toggle('toolbar--toggle-hidden', resolved === TOOLBAR_TOGGLE_VISIBLE.OFF);
+ }
+
+ _toggleCollapsed() {
+ const current = this._getCollapsed();
+ const next = current === TOOLBAR_COLLAPSED.COLLAPSED
+ ? TOOLBAR_COLLAPSED.EXPANDED
+ : TOOLBAR_COLLAPSED.COLLAPSED;
+ localStorage.setItem(TOOLBAR_COLLAPSED_KEY, next);
+ eventBus.emit('toolbar:collapsedChanged', next);
+ }
+
_updateHistoryButtons(canUndo, canRedo) {
const undoBtn = this._el.querySelector('[data-tool="undo"]');
const redoBtn = this._el.querySelector('[data-tool="redo"]');
@@ -176,16 +244,15 @@ class Toolbar {
}
_renderAccount() {
- const user = this._user || {};
- const name = user.nickname || user.name || user.userName || user.username || `${this._getHostName()} 用户`;
- const avatar = user.avatar || user.avatarUrl || user.photo || '';
- const initial = this._getInitial(name);
+ const { name, avatar, initial } = this._getAccountView();
const title = this._escapeAttr(name);
+ const nameHtml = `
${this._escapeHTML(name)}`;
if (avatar) {
return `
`;
}
@@ -193,10 +260,55 @@ class Toolbar {
return `
`;
}
+ /**
+ * 计算侧栏左下角头像视图。
+ * 登录平台账号时优先使用平台头像,否则回退到宿主用户信息。
+ */
+ _getAccountView() {
+ // 平台账号已登录 → 使用平台档案
+ if (this._identity.isAuthenticated()) {
+ const profile = this._profile || {};
+ const name = profile.nickname || '我的账户';
+ const avatar = profile.avatar || '';
+ return { name, avatar, initial: this._getInitial(name) };
+ }
+
+ // 未登录平台账号 → 使用宿主用户信息
+ const user = this._user || {};
+ const name = user.nickname || user.name || user.userName || user.username || `${this._getHostName()} 用户`;
+ const avatar = user.avatar || user.avatarUrl || user.photo || '';
+ return { name, avatar, initial: this._getInitial(name) };
+ }
+
+ /**
+ * 平台已登录时拉取用户档案,用于侧栏头像展示。
+ */
+ async _loadProfileIfAuthenticated() {
+ if (!this._identity.isAuthenticated()) {
+ // 退出登录后清空平台档案,回退到宿主用户头像
+ if (this._profile) {
+ this._profile = null;
+ this._render();
+ }
+ return;
+ }
+
+ this._profileLoading = true;
+ try {
+ this._profile = await this._identity.getProfile();
+ } catch (e) {
+ console.warn('[Toolbar] 加载平台用户档案失败:', e);
+ this._profile = null;
+ }
+ this._profileLoading = false;
+ this._render();
+ }
+
_getHostUser() {
try {
const result = this._host?.user?.getCurrentUser?.() || this._host?.getHostUser?.() || null;
diff --git a/plugins/Image-Toolbox/core/src/updateRecords.js b/plugins/Image-Toolbox/core/src/updateRecords.js
index 0f43c518a..b4c26e53f 100644
--- a/plugins/Image-Toolbox/core/src/updateRecords.js
+++ b/plugins/Image-Toolbox/core/src/updateRecords.js
@@ -1,4 +1,28 @@
export const updateRecords = [
+ {
+ version: '2.4',
+ date: '2026-08-04',
+ changes: {
+ added: [
+ { text: '左侧工具栏支持展开/收起', platforms: null },
+ { text: '图形工具新增菱形图形', platforms: null },
+ { text: '新增「接入统一账号系统」功能', platforms: null }
+ ],
+ fixed: [
+ { text: '修复窗口大小缩放时,已添加的文字、图形、画笔、马赛克等编辑内容与原图错位的问题', platforms: null },
+ { text: '修复使用调色工具调整滤镜滑块后无法撤销的问题', platforms: null },
+ { text: '修复调色面板销毁时未清理 DOM 事件监听器导致的内存泄漏问题', platforms: null },
+ { text: '修复马赛克工具辅助图形(选区框、套索预览、画笔预览)出现在导出结果和图层列表中的问题', platforms: null }
+ ],
+ improved: [
+ { text: '图形工具配色预设栏支持左右滑动查看更多配色,粗细按钮固定在右侧不被压缩', platforms: null },
+ { text: '优化应用滤镜预设时的性能,批量设置时只重算一次滤镜,响应更迅速', platforms: null },
+ { text: '优化窗口缩放时编辑内容的布局保持逻辑,按比例同步调整所有覆盖层和裁剪路径的相对位置', platforms: null }
+ ],
+ adjusted: [],
+ removed: []
+ }
+ },
{
version: '2.3.1',
date: '2026-07-17',
diff --git a/plugins/Image-Toolbox/plugin.json b/plugins/Image-Toolbox/plugin.json
index cabeb9c35..85f90bb93 100644
--- a/plugins/Image-Toolbox/plugin.json
+++ b/plugins/Image-Toolbox/plugin.json
@@ -2,7 +2,7 @@
"name": "image-toolbox-ztools",
"title": "图片工具箱",
"description": "马赛克、剪切、加字等图片编辑功能",
- "version": "2.3.1",
+ "version": "2.4",
"main": "src/index.html",
"logo": "logo.png",
"preload": "preload.js",
diff --git a/plugins/Image-Toolbox/src/style.css b/plugins/Image-Toolbox/src/style.css
index 22b101553..b95ab1f1e 100644
--- a/plugins/Image-Toolbox/src/style.css
+++ b/plugins/Image-Toolbox/src/style.css
@@ -123,6 +123,11 @@ html, body {
"toolbar optionsbar optionsbar";
}
+/* ── 工具栏展开状态 ── */
+.app.app--toolbar-expanded {
+ --toolbar-width: 160px;
+}
+
/* ── 工具栏(左侧) ── */
.toolbar {
grid-area: toolbar;
@@ -263,6 +268,104 @@ html, body {
font-weight: 600;
}
+/* 昵称(仅展开时显示) */
+.toolbar__account-name {
+ display: none;
+}
+
+/* 工具栏收起/展开按钮 */
+.toolbar__toggle {
+ width: 36px;
+ height: 26px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: none;
+ background: transparent;
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ border-radius: 4px;
+ transition: background 0.15s, color 0.15s;
+ flex-shrink: 0;
+ margin-bottom: 2px;
+}
+
+.toolbar__toggle:hover {
+ background: var(--bg-hover);
+ color: var(--color-text);
+}
+
+.toolbar__toggle svg {
+ width: 16px;
+ height: 16px;
+}
+
+/* 隐藏展开/收起按钮 */
+.toolbar--toggle-hidden .toolbar__toggle {
+ display: none;
+}
+
+/* ── 展开状态:水平布局 ── */
+.toolbar--expanded {
+ align-items: stretch;
+ padding: 6px 4px;
+}
+
+.toolbar--expanded .toolbar__toggle {
+ align-self: flex-end;
+ margin-bottom: 4px;
+}
+
+.toolbar--expanded .toolbar__tools,
+.toolbar--expanded .toolbar__footer {
+ align-items: stretch;
+}
+
+.toolbar--expanded .toolbar__btn {
+ flex-direction: row;
+ justify-content: flex-start;
+ width: 100%;
+ height: 34px;
+ padding: 0 10px;
+ gap: 8px;
+ font-size: 12px;
+ border-radius: 6px;
+}
+
+.toolbar--expanded .toolbar__btn span {
+ font-size: 12px;
+}
+
+.toolbar--expanded .toolbar__btn svg {
+ width: 16px;
+ height: 16px;
+}
+
+.toolbar--expanded .toolbar__separator {
+ width: auto;
+ margin: 4px 8px;
+}
+
+.toolbar--expanded .toolbar__account {
+ flex-direction: row;
+ justify-content: flex-start;
+ width: 100%;
+ height: 40px;
+ padding: 0 10px;
+ gap: 8px;
+ border-radius: 6px;
+}
+
+.toolbar--expanded .toolbar__account-name {
+ display: block;
+ font-size: 12px;
+ color: var(--color-text);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ min-width: 0;
+}
+
/* ── 账户页 ── */
.account-page {
width: 100%;
@@ -452,6 +555,11 @@ html, body {
line-height: 1.7;
}
+.account-page__hint {
+ margin-top: 4px;
+ opacity: 0.7;
+}
+
.account-card__value {
margin-top: 5px;
font-size: 16px;
@@ -887,7 +995,9 @@ html, body {
flex-wrap: nowrap;
align-items: center;
gap: 8px;
- flex-shrink: 0;
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow: hidden;
}
/* 选项栏控件 */
@@ -900,6 +1010,30 @@ html, body {
flex-shrink: 0;
}
+/* 可横向滑动的选项组:左右两侧的控件保持固定,中间此组自动压缩并内部滑动 */
+.options-group--scrollable {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow: hidden;
+}
+
+.shape-style-scroll {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ align-items: center;
+ gap: 4px;
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow-x: auto;
+ overflow-y: hidden;
+ scrollbar-width: thin;
+}
+
+.shape-style-scroll::-webkit-scrollbar {
+ height: 6px;
+}
+
.options-label {
font-size: 11px;
color: var(--color-text-secondary);
@@ -2170,3 +2304,297 @@ html, body {
75% { opacity: 1; transform: translateX(-50%) translateY(0); }
100% { opacity: 0; transform: translateX(-50%) translateY(-10px); }
}
+
+/* ═══ 账户区域 — 按钮 / 昵称编辑 / 头像上传 ═══ */
+
+.account-page__btn {
+ padding: 8px 16px;
+ border: 1px solid var(--color-border);
+ border-radius: 8px;
+ background: var(--bg-card);
+ color: var(--color-text);
+ font-size: 13px;
+ cursor: pointer;
+ transition: background 0.15s, border-color 0.15s;
+}
+
+.account-page__btn:hover {
+ background: var(--bg-hover);
+ border-color: var(--color-text-secondary);
+}
+
+.account-page__btn--primary {
+ background: var(--bg-active);
+ border-color: var(--bg-active);
+ color: #ffffff;
+}
+
+.account-page__btn--primary:hover {
+ opacity: 0.88;
+}
+
+.account-page__btn--small {
+ padding: 6px 12px;
+ font-size: 12px;
+}
+
+.account-page__loading {
+ padding: 20px;
+ text-align: center;
+ color: var(--color-text-secondary);
+ font-size: 13px;
+}
+
+.account-page__nickname-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 8px 0 4px;
+}
+
+.account-page__nickname-input {
+ flex: 1;
+ min-width: 0;
+ padding: 7px 12px;
+ border: 1px solid var(--color-border);
+ border-radius: 8px;
+ background: var(--bg-card);
+ color: var(--color-text);
+ font-size: 14px;
+ font-family: inherit;
+}
+
+.account-page__nickname-input:focus {
+ outline: none;
+ border-color: var(--bg-active);
+}
+
+.account-page__nickname-display {
+ flex: 1;
+ min-width: 0;
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--color-text);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.account-page__avatar-edit-hint {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ background: var(--bg-active);
+ color: #fff;
+ font-size: 10px;
+ opacity: 0;
+ transition: opacity 0.15s;
+}
+
+.account-card__avatar-wrap {
+ position: relative;
+ cursor: pointer;
+}
+
+.account-card__avatar-wrap:hover .account-page__avatar-edit-hint {
+ opacity: 1;
+}
+
+/* ═══ 登录弹窗 ═══ */
+
+.login-modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 10000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.2s ease;
+}
+
+.login-modal--active {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+.login-modal__backdrop {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.4);
+}
+
+.login-modal__card {
+ position: relative;
+ width: 340px;
+ max-width: 90vw;
+ background: var(--bg-card);
+ border-radius: 16px;
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2);
+ overflow: hidden;
+ transform: scale(0.95);
+ transition: transform 0.2s ease;
+}
+
+.login-modal--active .login-modal__card {
+ transform: scale(1);
+}
+
+.login-modal__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 18px 20px 14px;
+ border-bottom: 1px solid var(--color-border);
+}
+
+.login-modal__header h3 {
+ font-size: 16px;
+ font-weight: 700;
+}
+
+.login-modal__close {
+ width: 28px;
+ height: 28px;
+ border: none;
+ background: transparent;
+ color: var(--color-text-secondary);
+ font-size: 20px;
+ cursor: pointer;
+ border-radius: 6px;
+ line-height: 1;
+}
+
+.login-modal__close:hover {
+ background: var(--bg-hover);
+ color: var(--color-text);
+}
+
+.login-modal__body {
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.login-modal__btn {
+ padding: 10px 16px;
+ border: 1px solid var(--color-border);
+ border-radius: 10px;
+ background: var(--bg-card);
+ color: var(--color-text);
+ font-size: 14px;
+ cursor: pointer;
+ transition: background 0.15s, opacity 0.15s;
+}
+
+.login-modal__btn:hover {
+ background: var(--bg-hover);
+}
+
+.login-modal__btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.login-modal__btn--primary {
+ background: var(--bg-active);
+ border-color: var(--bg-active);
+ color: #ffffff;
+}
+
+.login-modal__btn--primary:hover {
+ opacity: 0.88;
+ background: var(--bg-active);
+}
+
+.login-modal__btn--small {
+ padding: 8px 12px;
+ font-size: 12px;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.login-modal__btn--utools {
+ background: var(--bg-active);
+ border-color: var(--bg-active);
+ color: #ffffff;
+ font-weight: 500;
+}
+
+.login-modal__btn--utools:hover {
+ opacity: 0.88;
+}
+
+.login-modal__divider {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ color: var(--color-text-secondary);
+ font-size: 12px;
+}
+
+.login-modal__divider::before,
+.login-modal__divider::after {
+ content: '';
+ flex: 1;
+ height: 1px;
+ background: var(--color-border);
+}
+
+.login-modal__field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.login-modal__field label {
+ font-size: 12px;
+ color: var(--color-text-secondary);
+ font-weight: 500;
+}
+
+.login-modal__field input {
+ padding: 9px 14px;
+ border: 1px solid var(--color-border);
+ border-radius: 8px;
+ background: var(--bg-card);
+ color: var(--color-text);
+ font-size: 14px;
+ font-family: inherit;
+}
+
+.login-modal__field input:focus {
+ outline: none;
+ border-color: var(--bg-active);
+}
+
+.login-modal__code-row {
+ display: flex;
+ gap: 8px;
+}
+
+.login-modal__code-row input {
+ flex: 1;
+ min-width: 0;
+}
+
+.login-modal__hint {
+ text-align: center;
+ font-size: 12px;
+ color: var(--color-text-secondary);
+ margin: 0;
+}