diff --git a/server/src/wled_controller/static/css/cards.css b/server/src/wled_controller/static/css/cards.css index 8b149f3..92e02b2 100644 --- a/server/src/wled_controller/static/css/cards.css +++ b/server/src/wled_controller/static/css/cards.css @@ -204,6 +204,26 @@ body.cs-drag-active .card-drag-handle { position: static; } +.card-actions .color-picker-wrapper, +.template-card-actions .color-picker-wrapper { + display: flex; + align-items: center; + margin-left: auto; +} + +.card-actions .color-picker-popover, +.template-card-actions .color-picker-popover { + top: auto; + bottom: calc(100% + 8px); + left: auto; + right: 0; +} + +.card.cp-elevated, +.template-card.cp-elevated { + z-index: 10; +} + .card-autostart-btn { background: none; border: none; diff --git a/server/src/wled_controller/static/css/layout.css b/server/src/wled_controller/static/css/layout.css index 034f4a3..7bb18d0 100644 --- a/server/src/wled_controller/static/css/layout.css +++ b/server/src/wled_controller/static/css/layout.css @@ -315,6 +315,20 @@ h2 { color: var(--text-secondary); cursor: pointer; } +.color-picker-reset { + display: flex; + align-items: center; + gap: 8px; + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid var(--border-color); + font-size: 0.78rem; + color: var(--text-secondary); + cursor: pointer; +} +.color-picker-reset:hover { + color: var(--danger-color); +} .color-picker-custom input[type="color"] { width: 28px; height: 28px; diff --git a/server/src/wled_controller/static/js/core/card-colors.js b/server/src/wled_controller/static/js/core/card-colors.js new file mode 100644 index 0000000..52c9f9d --- /dev/null +++ b/server/src/wled_controller/static/js/core/card-colors.js @@ -0,0 +1,113 @@ +/** + * Card color assignment — localStorage-backed color labels for any card. + * + * Usage in card creation functions: + * import { wrapCard } from '../core/card-colors.js'; + * + * return wrapCard({ + * dataAttr: 'data-device-id', + * id: device.id, + * removeOnclick: `removeDevice('${device.id}')`, + * removeTitle: t('common.delete'), + * content: `
...
`, + * actions: ``, + * }); + * + * The helper wraps content in a standard card shell: + * - Outer div (.card / .template-card) with border-left color + * - .card-top-actions with remove button + optional top buttons + * - Bottom actions (.card-actions / .template-card-actions) with color picker + */ + +import { createColorPicker, registerColorPicker } from './color-picker.js'; + +const STORAGE_KEY = 'cardColors'; +const DEFAULT_SWATCH = '#808080'; + +function _getAll() { + try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {}; } + catch { return {}; } +} + +export function getCardColor(id) { + return _getAll()[id] || ''; +} + +export function setCardColor(id, hex) { + const m = _getAll(); + if (hex) m[id] = hex; else delete m[id]; + localStorage.setItem(STORAGE_KEY, JSON.stringify(m)); +} + +/** + * Returns inline style string for card border-left. + * Empty string when no color is set. + */ +export function cardColorStyle(entityId) { + const c = getCardColor(entityId); + return c ? `border-left: 3px solid ${c}` : ''; +} + +/** + * Returns color picker HTML + registers the pick callback. + * @param {string} entityId Unique entity ID + * @param {string} cardAttr Data attribute selector, e.g. 'data-device-id' + */ +export function cardColorButton(entityId, cardAttr) { + const color = getCardColor(entityId) || DEFAULT_SWATCH; + const pickerId = `cc-${entityId}`; + + registerColorPicker(pickerId, (hex) => { + setCardColor(entityId, hex); + const card = document.querySelector(`[${cardAttr}="${entityId}"]`); + if (card) card.style.borderLeft = hex ? `3px solid ${hex}` : ''; + }); + + return createColorPicker({ id: pickerId, currentColor: color, anchor: 'left', showReset: true, resetColor: DEFAULT_SWATCH }); +} + +/** + * Build a standard card shell with color support. + * + * Provides consistent structure across all card types: + * - .card-top-actions: remove button + optional extra top buttons + * - Bottom actions: action buttons + color picker (always last) + * - Automatic border-left color from localStorage + * + * @param {object} opts + * @param {'card'|'template-card'} [opts.type='card'] Card CSS class + * @param {string} opts.dataAttr Data attribute name, e.g. 'data-device-id' + * @param {string} opts.id Entity ID value + * @param {string} [opts.classes] Extra CSS classes on root element + * @param {string} [opts.topButtons] HTML for extra top-right buttons (power, autostart) + * @param {string} opts.removeOnclick onclick handler string for remove button + * @param {string} opts.removeTitle title attribute for remove button + * @param {string} opts.content Inner HTML (header, props, metrics, etc.) + * @param {string} opts.actions Action button HTML (without wrapper div) + */ +export function wrapCard({ + type = 'card', + dataAttr, + id, + classes = '', + topButtons = '', + removeOnclick, + removeTitle, + content, + actions, +}) { + const actionsClass = type === 'template-card' ? 'template-card-actions' : 'card-actions'; + const colorStyle = cardColorStyle(id); + return ` +
+
+ ${topButtons} + +
+ ${content} +
+ ${actions} + ${cardColorButton(id, dataAttr)} +
+
`; +} diff --git a/server/src/wled_controller/static/js/core/color-picker.js b/server/src/wled_controller/static/js/core/color-picker.js index 79bcf0c..0688583 100644 --- a/server/src/wled_controller/static/js/core/color-picker.js +++ b/server/src/wled_controller/static/js/core/color-picker.js @@ -28,12 +28,16 @@ const PRESETS = [ /** * Build the HTML string for a color-picker widget. */ -export function createColorPicker({ id, currentColor, onPick, anchor = 'right' }) { +export function createColorPicker({ id, currentColor, onPick, anchor = 'right', showReset = false, resetColor = '#808080' }) { const dots = PRESETS.map(c => { const active = c.toLowerCase() === currentColor.toLowerCase() ? ' active' : ''; return ``; }).join(''); + const resetBtn = showReset + ? `
${t('accent.reset')}
` + : ''; + return `` + `` + `` + + resetBtn + `` + ``; } @@ -65,14 +70,21 @@ function _rgbToHex(rgb) { } window._cpToggle = function (id) { - // Close all other pickers first + // Close all other pickers first (and drop their card elevation) document.querySelectorAll('.color-picker-popover').forEach(p => { - if (p.id !== `cp-pop-${id}`) p.style.display = 'none'; + if (p.id !== `cp-pop-${id}`) { + p.style.display = 'none'; + const card = p.closest('.card, .template-card'); + if (card) card.classList.remove('cp-elevated'); + } }); const pop = document.getElementById(`cp-pop-${id}`); if (!pop) return; const show = pop.style.display === 'none'; pop.style.display = show ? '' : 'none'; + // Elevate the card so the popover isn't clipped by sibling cards + const card = pop.closest('.card, .template-card'); + if (card) card.classList.toggle('cp-elevated', show); if (show) { // Mark active dot const swatch = document.getElementById(`cp-swatch-${id}`); @@ -99,13 +111,35 @@ window._cpPick = function (id, hex) { d.classList.toggle('active', dHex.toLowerCase() === hex.toLowerCase()); }); pop.style.display = 'none'; + const card = pop.closest('.card, .template-card'); + if (card) card.classList.remove('cp-elevated'); } // Fire callback if (_callbacks[id]) _callbacks[id](hex); }; +window._cpReset = function (id, resetColor) { + // Reset swatch to neutral color + const swatch = document.getElementById(`cp-swatch-${id}`); + if (swatch) swatch.style.background = resetColor; + // Clear active dots and close popover + const pop = document.getElementById(`cp-pop-${id}`); + if (pop) { + pop.querySelectorAll('.color-picker-dot').forEach(d => d.classList.remove('active')); + pop.style.display = 'none'; + const card = pop.closest('.card, .template-card'); + if (card) card.classList.remove('cp-elevated'); + } + // Fire callback with empty string to signal removal + if (_callbacks[id]) _callbacks[id](''); +}; + export function closeAllColorPickers() { - document.querySelectorAll('.color-picker-popover').forEach(p => p.style.display = 'none'); + document.querySelectorAll('.color-picker-popover').forEach(p => { + p.style.display = 'none'; + const card = p.closest('.card, .template-card'); + if (card) card.classList.remove('cp-elevated'); + }); } // Close on outside click diff --git a/server/src/wled_controller/static/js/features/automations.js b/server/src/wled_controller/static/js/features/automations.js index 7dd9bf2..d6f9f11 100644 --- a/server/src/wled_controller/static/js/features/automations.js +++ b/server/src/wled_controller/static/js/features/automations.js @@ -10,6 +10,7 @@ import { Modal } from '../core/modal.js'; import { CardSection } from '../core/card-sections.js'; import { updateTabBadge } from './tabs.js'; import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE } from '../core/icons.js'; +import { wrapCard } from '../core/card-colors.js'; import { csScenes, createSceneCard } from './scene-presets.js'; class AutomationEditorModal extends Modal { @@ -152,11 +153,13 @@ function createAutomationCard(automation, sceneMap = new Map()) { lastActivityMeta = `${ICON_CLOCK} ${ts.toLocaleString()}`; } - return ` -
-
- -
+ return wrapCard({ + dataAttr: 'data-automation-id', + id: automation.id, + classes: !automation.enabled ? 'automation-status-disabled' : '', + removeOnclick: `deleteAutomation('${automation.id}', '${escapeHtml(automation.name)}')`, + removeTitle: t('common.delete'), + content: `
${escapeHtml(automation.name)} @@ -169,14 +172,13 @@ function createAutomationCard(automation, sceneMap = new Map()) { ${deactivationLabel ? `${deactivationLabel}` : ''} ${lastActivityMeta}
-
${condPills}
-
+
${condPills}
`, + actions: ` -
-
`; + `, + }); } export async function openAutomationEditor(automationId) { diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js index 60967bb..b6bb195 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -13,6 +13,7 @@ import { ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM, ICON_LINK, ICON_SPARKLES, ICON_FAST_FORWARD, ICON_ACTIVITY, } from '../core/icons.js'; +import { wrapCard } from '../core/card-colors.js'; class CSSEditorModal extends Modal { constructor() { @@ -707,9 +708,12 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) { ? `` : ''; - return ` -
- + return wrapCard({ + dataAttr: 'data-css-id', + id: source.id, + removeOnclick: `deleteColorStrip('${source.id}')`, + removeTitle: t('common.delete'), + content: `
${icon} ${escapeHtml(source.name)} @@ -717,14 +721,12 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
${propsHtml} -
-
+
`, + actions: ` - ${calibrationBtn} -
-
- `; + ${calibrationBtn}`, + }); } /* ── Editor open/close ────────────────────────────────────────── */ diff --git a/server/src/wled_controller/static/js/features/devices.js b/server/src/wled_controller/static/js/features/devices.js index 2948159..f873892 100644 --- a/server/src/wled_controller/static/js/features/devices.js +++ b/server/src/wled_controller/static/js/features/devices.js @@ -10,6 +10,7 @@ import { t } from '../core/i18n.js'; import { showToast, showConfirm } from '../core/ui.js'; import { Modal } from '../core/modal.js'; import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG } from '../core/icons.js'; +import { wrapCard } from '../core/card-colors.js'; class DeviceSettingsModal extends Modal { constructor() { super('device-settings-modal'); } @@ -77,12 +78,13 @@ export function createDeviceCard(device) { const ledCount = state.device_led_count || device.led_count; - return ` -
-
- ${(device.capabilities || []).includes('power_control') ? `` : ''} - -
+ return wrapCard({ + dataAttr: 'data-device-id', + id: device.id, + topButtons: (device.capabilities || []).includes('power_control') ? `` : '', + removeOnclick: `removeDevice('${device.id}')`, + removeTitle: t('device.button.remove'), + content: `
@@ -105,15 +107,12 @@ export function createDeviceCard(device) { onchange="saveCardBrightness('${device.id}', this.value)" title="${_deviceBrightnessCache[device.id] != null ? Math.round(_deviceBrightnessCache[device.id] / 255 * 100) + '%' : '...'}" ${_deviceBrightnessCache[device.id] == null ? 'disabled' : ''}> -
` : ''} -
- - -
-
- `; +
` : ''}`, + actions: ` + `, + }); } export async function turnOffDevice(deviceId) { diff --git a/server/src/wled_controller/static/js/features/kc-targets.js b/server/src/wled_controller/static/js/features/kc-targets.js index 2c0c65e..11bfc20 100644 --- a/server/src/wled_controller/static/js/features/kc-targets.js +++ b/server/src/wled_controller/static/js/features/kc-targets.js @@ -19,6 +19,7 @@ import { ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_START, ICON_STOP, ICON_PAUSE, ICON_LINK_SOURCE, ICON_PATTERN_TEMPLATE, ICON_FPS, ICON_PALETTE, } from '../core/icons.js'; +import { wrapCard } from '../core/card-colors.js'; class KCEditorModal extends Modal { constructor() { @@ -118,12 +119,13 @@ export function createKCTargetCard(target, sourceMap, patternTemplateMap, valueS swatchesHtml = `${t('kc.colors.none')}`; } - return ` -
-
- - -
+ return wrapCard({ + dataAttr: 'data-kc-target-id', + id: target.id, + topButtons: ``, + removeOnclick: `deleteKCTarget('${target.id}')`, + removeTitle: t('common.delete'), + content: `
${escapeHtml(target.name)} @@ -182,8 +184,8 @@ export function createKCTargetCard(target, sourceMap, patternTemplateMap, valueS
` : ''}
- ` : ''} -
+ ` : ''}`, + actions: ` ${isProcessing ? ` -
-
- `; + `, + }); } // ===== KEY COLORS TEST ===== diff --git a/server/src/wled_controller/static/js/features/pattern-templates.js b/server/src/wled_controller/static/js/features/pattern-templates.js index c6cb969..6726d74 100644 --- a/server/src/wled_controller/static/js/features/pattern-templates.js +++ b/server/src/wled_controller/static/js/features/pattern-templates.js @@ -19,6 +19,7 @@ import { t } from '../core/i18n.js'; import { showToast, showConfirm } from '../core/ui.js'; import { Modal } from '../core/modal.js'; import { getPictureSourceIcon, ICON_PATTERN_TEMPLATE, ICON_CLONE, ICON_EDIT } from '../core/icons.js'; +import { wrapCard } from '../core/card-colors.js'; class PatternTemplateModal extends Modal { constructor() { @@ -52,22 +53,24 @@ const patternModal = new PatternTemplateModal(); export function createPatternTemplateCard(pt) { const rectCount = (pt.rectangles || []).length; const desc = pt.description ? `
${escapeHtml(pt.description)}
` : ''; - return ` -
- + return wrapCard({ + type: 'template-card', + dataAttr: 'data-pattern-template-id', + id: pt.id, + removeOnclick: `deletePatternTemplate('${pt.id}')`, + removeTitle: t('common.delete'), + content: `
${ICON_PATTERN_TEMPLATE} ${escapeHtml(pt.name)}
${desc}
▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''} -
-
+
`, + actions: ` - -
-
- `; + `, + }); } export async function showPatternTemplateEditor(templateId = null, cloneData = null) { diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index fbb315d..16b25e0 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -45,6 +45,7 @@ import { ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH, ICON_RADIO, ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_HELP, } from '../core/icons.js'; +import { wrapCard } from '../core/card-colors.js'; // ── Card section instances ── const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id' }); @@ -1184,29 +1185,35 @@ function renderPictureSourcesList(streams) {
`; } - return ` -
- + return wrapCard({ + type: 'template-card', + dataAttr: 'data-stream-id', + id: stream.id, + removeOnclick: `deleteStream('${stream.id}')`, + removeTitle: t('common.delete'), + content: `
${typeIcon} ${escapeHtml(stream.name)}
${detailsHtml} - ${stream.description ? `
${escapeHtml(stream.description)}
` : ''} -
+ ${stream.description ? `
${escapeHtml(stream.description)}
` : ''}`, + actions: ` - -
-
- `; + `, + }); }; const renderCaptureTemplateCard = (template) => { const engineIcon = getEngineIcon(template.engine_type); const configEntries = Object.entries(template.engine_config); - return ` -
- + return wrapCard({ + type: 'template-card', + dataAttr: 'data-template-id', + id: template.id, + removeOnclick: `deleteTemplate('${template.id}')`, + removeTitle: t('common.delete'), + content: `
${ICON_TEMPLATE} ${escapeHtml(template.name)}
@@ -1227,14 +1234,12 @@ function renderPictureSourcesList(streams) { `).join('')} - ` : ''} -
+ ` : ''}`, + actions: ` - -
-
- `; + `, + }); }; const renderPPTemplateCard = (tmpl) => { @@ -1243,21 +1248,23 @@ function renderPictureSourcesList(streams) { const filterNames = tmpl.filters.map(fi => `${escapeHtml(_getFilterName(fi.filter_id))}`); filterChainHtml = `
${filterNames.join('')}
`; } - return ` -
- + return wrapCard({ + type: 'template-card', + dataAttr: 'data-pp-template-id', + id: tmpl.id, + removeOnclick: `deletePPTemplate('${tmpl.id}')`, + removeTitle: t('common.delete'), + content: `
${ICON_TEMPLATE} ${escapeHtml(tmpl.name)}
${tmpl.description ? `
${escapeHtml(tmpl.description)}
` : ''} - ${filterChainHtml} -
+ ${filterChainHtml}`, + actions: ` - -
-
- `; + `, + }); }; const rawStreams = streams.filter(s => s.stream_type === 'raw'); @@ -1305,28 +1312,34 @@ function renderPictureSourcesList(streams) { propsHtml = `${devLabel} #${devIdx}${tplBadge}`; } - return ` -
- + return wrapCard({ + type: 'template-card', + dataAttr: 'data-id', + id: src.id, + removeOnclick: `deleteAudioSource('${src.id}')`, + removeTitle: t('common.delete'), + content: `
${icon} ${escapeHtml(src.name)}
${propsHtml}
- ${src.description ? `
${escapeHtml(src.description)}
` : ''} -
+ ${src.description ? `
${escapeHtml(src.description)}
` : ''}`, + actions: ` - -
-
- `; + `, + }); }; const renderAudioTemplateCard = (template) => { const configEntries = Object.entries(template.engine_config || {}); - return ` -
- + return wrapCard({ + type: 'template-card', + dataAttr: 'data-audio-template-id', + id: template.id, + removeOnclick: `deleteAudioTemplate('${template.id}')`, + removeTitle: t('common.delete'), + content: `
${ICON_AUDIO_TEMPLATE} ${escapeHtml(template.name)}
@@ -1347,14 +1360,12 @@ function renderPictureSourcesList(streams) { `).join('')} - ` : ''} -
+ ` : ''}`, + actions: ` - -
-
- `; + `, + }); }; const panels = tabs.map(tab => { diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index a923d8d..647729e 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -23,6 +23,7 @@ import { ICON_LED, ICON_FPS, ICON_OVERLAY, ICON_LED_PREVIEW, ICON_GLOBE, ICON_RADIO, ICON_PLUG, ICON_FILM, ICON_SUN_DIM, ICON_TARGET_ICON, ICON_HELP, } from '../core/icons.js'; +import { wrapCard } from '../core/card-colors.js'; import { CardSection } from '../core/card-sections.js'; import { updateSubTabHash, updateTabBadge } from './tabs.js'; @@ -850,12 +851,13 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo healthTitle = devOnline ? t('device.health.online') : t('device.health.offline'); } - return ` -
-
- - -
+ return wrapCard({ + dataAttr: 'data-target-id', + id: target.id, + topButtons: ``, + removeOnclick: `deleteTarget('${target.id}')`, + removeTitle: t('common.delete'), + content: `
@@ -908,8 +910,8 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo
-
-
+
`, + actions: ` ${isProcessing ? ` - `) : ''} -
-
- `; + `) : ''}`, + }); } async function _targetAction(action) { diff --git a/server/src/wled_controller/static/js/features/value-sources.js b/server/src/wled_controller/static/js/features/value-sources.js index 721ceae..3fd395b 100644 --- a/server/src/wled_controller/static/js/features/value-sources.js +++ b/server/src/wled_controller/static/js/features/value-sources.js @@ -21,6 +21,7 @@ import { ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL, ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH, } from '../core/icons.js'; +import { wrapCard } from '../core/card-colors.js'; import { loadPictureSources } from './streams.js'; export { getValueSourceIcon }; @@ -522,21 +523,23 @@ export function createValueSourceCard(src) { `; } - return ` -
- + return wrapCard({ + type: 'template-card', + dataAttr: 'data-id', + id: src.id, + removeOnclick: `deleteValueSource('${src.id}')`, + removeTitle: t('common.delete'), + content: `
${icon} ${escapeHtml(src.name)}
${propsHtml}
- ${src.description ? `
${escapeHtml(src.description)}
` : ''} -
+ ${src.description ? `
${escapeHtml(src.description)}
` : ''}`, + actions: ` - -
-
- `; + `, + }); } // ── Helpers ─────────────────────────────────────────────────── diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 4b5bba5..8fbd09a 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -5,6 +5,7 @@ "theme.toggle": "Toggle theme", "accent.title": "Accent color", "accent.custom": "Custom", + "accent.reset": "Reset", "locale.change": "Change language", "auth.login": "Login", "auth.logout": "Logout", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 426cce1..548ae29 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -5,6 +5,7 @@ "theme.toggle": "Переключить тему", "accent.title": "Цвет акцента", "accent.custom": "Свой", + "accent.reset": "Сброс", "locale.change": "Изменить язык", "auth.login": "Войти", "auth.logout": "Выйти", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 273998e..c1134ce 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -5,6 +5,7 @@ "theme.toggle": "切换主题", "accent.title": "主题色", "accent.custom": "自定义", + "accent.reset": "重置", "locale.change": "切换语言", "auth.login": "登录", "auth.logout": "退出",