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 ` -
- + + + + `; } @@ -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 = ` + + + `; + 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 = ``; 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; +}