Files
live_recorder/frontend/src/views/SettingsView.vue
T

790 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import apiClient, { getApiErrorMessage } from "@/api/client";
import type { SystemSettings } from "@/types";
import { outputFormatLabelMap, recordingTemplateLabelMap, saveModeLabelMap } from "@/types";
const loading = ref(false);
const saving = ref(false);
const testingEmail = ref(false);
const loadError = ref("");
const form = reactive<SystemSettings>({
ffmpegPath: "ffmpeg",
outputRoot: "records",
outputDirectoryTemplate: "{platform}/{yyyy}/{MM}/{dd}/{anchor}",
outputFileNameTemplate: "{HHmmss}_{anchor}_{title}_{roomId}{segmentSuffix}",
defaultQuality: "origin",
defaultOutputFormat: 0,
saveMode: 0,
recordingTemplate: 0,
segmentDurationMinutes: 30,
maxConcurrentFfmpegTranscodeTasks: 1,
mp4FinalizeTimeoutMinutes: 60,
enableStorageGuard: true,
pauseRecordingWhenFreeSpaceBelowMegabytes: 1024,
resumeRecordingWhenFreeSpaceAboveMegabytes: 4096,
enableAutoReconnect: true,
reconnectDelayMaxSeconds: 5,
readWriteTimeoutMilliseconds: 15000000,
enableDanmakuRecording: true,
danmakuIncludeNonChatEvents: true,
danmakuMinPollIntervalMilliseconds: 1000,
danmakuRetryDelayMaxSeconds: 15,
enableBackgroundPolling: true,
autoStartRecordingOnLive: true,
pollingIntervalSeconds: 60,
enableEventScripts: false,
liveStartedScriptPath: "",
liveEndedScriptPath: "",
segmentCompletedScriptPath: "",
eventScriptTimeoutSeconds: 60,
enableEmailNotification: false,
emailSmtpHost: "",
emailSmtpPort: 587,
emailUseSsl: true,
emailUsername: "",
emailPassword: "",
emailFromAddress: "",
emailFromDisplayName: "Live Recorder",
emailToAddresses: "",
notifyOnLiveStarted: true,
notifyOnException: true,
emailLiveStartedSubjectTemplate: "[{{appName}}] Live started: {{anchor}} {{title}} ({{roomId}})",
emailLiveStartedBodyTemplateHtml: `<div style="font-family: 'Segoe UI', 'PingFang SC', sans-serif; color: #1f2937; line-height: 1.7;">
<h2 style="margin: 0 0 16px; color: #3e5f7c;">Live started</h2>
<p>The monitored live room is now online.</p>
<ul>
<li><strong>Platform:</strong> {{platform}}</li>
<li><strong>Room ID:</strong> {{roomId}}</li>
<li><strong>Title:</strong> {{title}}</li>
<li><strong>Anchor:</strong> {{anchor}}</li>
<li><strong>Detected At (UTC):</strong> {{detectedAtUtc}}</li>
</ul>
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
</div>`,
emailExceptionSubjectTemplate: "[{{appName}}] Exception: {{source}}",
emailExceptionBodyTemplateHtml: `<div style="font-family: 'Segoe UI', 'PingFang SC', sans-serif; color: #1f2937; line-height: 1.7;">
<h2 style="margin: 0 0 16px; color: #8b5e3c;">Exception detected</h2>
<p>{{summary}}</p>
<ul>
<li><strong>Source:</strong> {{source}}</li>
<li><strong>Live Room ID:</strong> {{liveRoomId}}</li>
<li><strong>Room ID:</strong> {{roomId}}</li>
<li><strong>Record Task ID:</strong> {{recordTaskId}}</li>
<li><strong>Task Status:</strong> {{taskStatus}}</li>
<li><strong>Occurred At (UTC):</strong> {{occurredAtUtc}}</li>
</ul>
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
</div>`,
douyinUserAgent: "",
douyinReferer: "https://live.douyin.com/",
douyinCookie: ""
});
const outputTemplateTokens = [
"{platform}",
"{roomId}",
"{anchor}",
"{title}",
"{yyyy}",
"{MM}",
"{dd}",
"{HHmmss}",
"{date}",
"{time}",
"{fileStem}",
"{segmentSuffix}"
];
const emailTemplateTokens = [
"{{appName}}",
"{{platform}}",
"{{roomId}}",
"{{title}}",
"{{anchor}}",
"{{sourceUrl}}",
"{{detectedAtUtc}}",
"{{source}}",
"{{summary}}",
"{{detail}}",
"{{liveRoomId}}",
"{{recordTaskId}}",
"{{taskStatus}}",
"{{occurredAtUtc}}"
];
const canSendTestEmail = computed(() =>
Boolean(form.emailSmtpHost.trim() && form.emailFromAddress.trim() && form.emailToAddresses.trim())
);
const segmentedExamplePath = computed(() => {
const extension = form.defaultOutputFormat === 1 ? "ts" : "mp4";
return `Douyin/2026/04/15/主播名/221530_主播名_直播标题_123456789_00001.${extension}`;
});
const nestedSegmentedExamplePath = computed(() => {
const extension = form.defaultOutputFormat === 1 ? "ts" : "mp4";
return `Douyin/2026/04/15/主播名/221530_主播名_直播标题_123456789/221530_主播名_直播标题_123456789_00001.${extension}`;
});
async function loadSettings() {
loading.value = true;
loadError.value = "";
try {
const { data } = await apiClient.get<SystemSettings>("/settings");
Object.assign(form, data);
} catch (error) {
loadError.value = getApiErrorMessage(error, "系统设置加载失败,请稍后重试。");
} finally {
loading.value = false;
}
}
async function saveSettings() {
saving.value = true;
try {
const { data } = await apiClient.put<SystemSettings>("/settings", form);
Object.assign(form, data);
ElMessage.success("设置已保存");
} finally {
saving.value = false;
}
}
async function sendTestEmail() {
testingEmail.value = true;
try {
await apiClient.post("/settings/test-email", {
emailSmtpHost: form.emailSmtpHost,
emailSmtpPort: form.emailSmtpPort,
emailUseSsl: form.emailUseSsl,
emailUsername: form.emailUsername,
emailPassword: form.emailPassword,
emailFromAddress: form.emailFromAddress,
emailFromDisplayName: form.emailFromDisplayName,
emailToAddresses: form.emailToAddresses,
emailLiveStartedSubjectTemplate: form.emailLiveStartedSubjectTemplate,
emailLiveStartedBodyTemplateHtml: form.emailLiveStartedBodyTemplateHtml,
emailExceptionSubjectTemplate: form.emailExceptionSubjectTemplate,
emailExceptionBodyTemplateHtml: form.emailExceptionBodyTemplateHtml
});
ElMessage.success("测试邮件已发送,请检查收件箱");
} catch (error: any) {
const message = error?.response?.data?.message ?? error?.message ?? "测试邮件发送失败";
ElMessage.error(message);
} finally {
testingEmail.value = false;
}
}
onMounted(loadSettings);
</script>
<template>
<div class="page-stack">
<div class="page-header">
<div>
<h1 class="page-title">系统设置</h1>
<p class="page-subtitle">
配置录制参数路径模板弹幕录制后台巡检和邮件通知测试邮件会直接使用当前表单值不要求先保存
</p>
</div>
<el-button type="primary" :loading="saving" @click="saveSettings">保存设置</el-button>
</div>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<div class="settings-grid" v-loading="loading">
<el-card class="surface-card settings-card" shadow="never">
<h3 class="section-title">录制基础</h3>
<p class="section-subtitle">默认清晰度输出格式分段时长ffmpeg 模板和网络容错参数统一在这里维护</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="ffmpeg 路径">
<el-input v-model="form.ffmpegPath" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="录制输出根目录">
<el-input v-model="form.outputRoot" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="默认清晰度">
<el-input v-model="form.defaultQuality" placeholder="origin / FULL_HD / HD / SD" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="直播保存格式">
<el-select v-model="form.defaultOutputFormat">
<el-option
v-for="(label, value) in outputFormatLabelMap"
:key="value"
:label="label"
:value="Number(value)"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="保存模式">
<el-select v-model="form.saveMode">
<el-option
v-for="(label, value) in saveModeLabelMap"
:key="value"
:label="label"
:value="Number(value)"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="录制模板">
<el-select v-model="form.recordingTemplate">
<el-option
v-for="(label, value) in recordingTemplateLabelMap"
:key="value"
:label="label"
:value="Number(value)"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="分段时长(分钟)">
<el-input-number v-model="form.segmentDurationMinutes" :min="1" :max="720" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="同时转码任务数">
<el-input-number v-model="form.maxConcurrentFfmpegTranscodeTasks" :min="1" :max="16" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="MP4 转码超时(分钟)">
<el-input-number v-model="form.mp4FinalizeTimeoutMinutes" :min="1" :max="1440" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="最大重连延迟(秒)">
<el-input-number v-model="form.reconnectDelayMaxSeconds" :min="1" :max="300" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="读写超时(毫秒)">
<el-input-number v-model="form.readWriteTimeoutMilliseconds" :min="1000" :max="60000000" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="启用 ffmpeg 自动重连">
<el-switch v-model="form.enableAutoReconnect" />
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-card>
<el-card class="surface-card settings-card" shadow="never">
<h3 class="section-title">存储保护</h3>
<p class="section-subtitle">磁盘空间低于暂停阈值时会停止活跃录制并暂停 MP4 转码空间恢复到恢复阈值后后台巡检会重新启动录制并补转码</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="启用存储空间保护">
<el-switch v-model="form.enableStorageGuard" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="低于该空间暂停录制(MB">
<el-input-number
v-model="form.pauseRecordingWhenFreeSpaceBelowMegabytes"
:min="0"
:max="1048576"
:disabled="!form.enableStorageGuard"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="高于该空间恢复录制/转码(MB)">
<el-input-number
v-model="form.resumeRecordingWhenFreeSpaceAboveMegabytes"
:min="0"
:max="1048576"
:disabled="!form.enableStorageGuard"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="helper-panel">
恢复阈值建议大于暂停阈值避免磁盘空间在临界值附近反复暂停和恢复MP4 转码会额外预留源 TS 文件大小避免再次出现 No space left on device
</div>
</el-card>
<el-card class="surface-card settings-card" shadow="never">
<h3 class="section-title">弹幕录制</h3>
<p class="section-subtitle">控制是否并行录制弹幕 XML是否保留非聊天事件以及轮询和失败重连节奏</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="启用弹幕录制">
<el-switch v-model="form.enableDanmakuRecording" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="记录非聊天事件">
<el-switch v-model="form.danmakuIncludeNonChatEvents" :disabled="!form.enableDanmakuRecording" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="最小轮询间隔(毫秒)">
<el-input-number
v-model="form.danmakuMinPollIntervalMilliseconds"
:min="100"
:max="60000"
:disabled="!form.enableDanmakuRecording"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="最大重试退避(秒)">
<el-input-number
v-model="form.danmakuRetryDelayMaxSeconds"
:min="1"
:max="300"
:disabled="!form.enableDanmakuRecording"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="helper-panel">
XML 文件会继续与视频同 stem 保存不额外新增单独的弹幕路径模板关闭记录非聊天事件只会保留聊天 <code>&lt;d&gt;</code> 节点
</div>
</el-card>
<el-card class="surface-card settings-card" shadow="never">
<h3 class="section-title">路径模板</h3>
<p class="section-subtitle">目录层级和文件名都支持变量分段模式不再强制多建一层目录是否分出子目录完全由模板决定</p>
<el-form label-position="top">
<el-form-item label="目录模板">
<el-input
v-model="form.outputDirectoryTemplate"
type="textarea"
:rows="3"
placeholder="{platform}/{yyyy}/{MM}/{dd}/{anchor}"
/>
</el-form-item>
<el-form-item label="文件名模板">
<el-input
v-model="form.outputFileNameTemplate"
type="textarea"
:rows="3"
placeholder="{HHmmss}_{anchor}_{title}_{roomId}{segmentSuffix}"
/>
</el-form-item>
</el-form>
<div class="example-box">
<div class="example-box__label">示例输出</div>
<div class="monospace example-box__value">默认分段{{ segmentedExamplePath }}</div>
<div class="monospace example-box__value">目录模板含 {fileStem}{{ nestedSegmentedExamplePath }}</div>
</div>
<div class="helper-panel">
`{fileStem}` 只用于目录模板表示按文件模板渲染后的基础文件名`{segmentSuffix}` 只用于文件模板分段模式下会渲染为 `_00001` 这类后缀单文件模式下为空
</div>
<div class="token-list">
<span v-for="token in outputTemplateTokens" :key="token" class="token-chip">{{ token }}</span>
</div>
</el-card>
<el-card class="surface-card settings-card" shadow="never">
<h3 class="section-title">后台巡检</h3>
<p class="section-subtitle">控制是否定时检测直播状态并在开播后自动创建录制任务</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="启用后台巡检">
<el-switch v-model="form.enableBackgroundPolling" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="检测到开播后自动录制">
<el-switch v-model="form.autoStartRecordingOnLive" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="巡检间隔(秒)">
<el-input-number v-model="form.pollingIntervalSeconds" :min="10" :max="3600" />
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-card>
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">事件脚本</h3>
<p class="section-subtitle">主播开播下播分片完成时可执行自定义脚本脚本通过环境变量获取上下文分片完成时可读取 LIVE_RECORDER_SEGMENT_FILE_PATH</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="启用事件脚本">
<el-switch v-model="form.enableEventScripts" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="脚本超时(秒)">
<el-input-number
v-model="form.eventScriptTimeoutSeconds"
:min="1"
:max="3600"
:disabled="!form.enableEventScripts"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="开播脚本路径">
<el-input v-model="form.liveStartedScriptPath" :disabled="!form.enableEventScripts" placeholder="/app/scripts/live-started.sh" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="下播脚本路径">
<el-input v-model="form.liveEndedScriptPath" :disabled="!form.enableEventScripts" placeholder="/app/scripts/live-ended.sh" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="分片完成脚本路径">
<el-input v-model="form.segmentCompletedScriptPath" :disabled="!form.enableEventScripts" placeholder="/app/scripts/segment-completed.sh" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="helper-panel">
常用环境变量LIVE_RECORDER_EVENTLIVE_RECORDER_PLATFORMLIVE_RECORDER_ROOM_IDLIVE_RECORDER_ANCHORLIVE_RECORDER_RECORD_SESSION_IDLIVE_RECORDER_RECORD_TASK_IDLIVE_RECORDER_SEGMENT_INDEXLIVE_RECORDER_SEGMENT_FILE_PATHLIVE_RECORDER_DANMAKU_FILE_PATH
</div>
</el-card>
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">邮件通知</h3>
<p class="section-subtitle">支持 SMTP开播提醒和异常提醒 HTML 模板测试发送会直接使用当前表单中的 SMTP 与模板</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="启用邮件通知">
<el-switch v-model="form.enableEmailNotification" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="启用 SSL">
<el-switch v-model="form.emailUseSsl" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="开播提醒">
<el-switch v-model="form.notifyOnLiveStarted" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="异常提醒">
<el-switch v-model="form.notifyOnException" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="SMTP Host">
<el-input v-model="form.emailSmtpHost" placeholder="smtp.example.com" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="SMTP Port">
<el-input-number v-model="form.emailSmtpPort" :min="1" :max="65535" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="发件人名称">
<el-input v-model="form.emailFromDisplayName" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="SMTP 用户名">
<el-input v-model="form.emailUsername" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="SMTP 密码">
<el-input v-model="form.emailPassword" type="password" show-password />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="发件地址">
<el-input v-model="form.emailFromAddress" placeholder="noreply@example.com" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="收件人列表">
<el-input
v-model="form.emailToAddresses"
type="textarea"
:rows="3"
placeholder="多个地址可用逗号、分号或换行分隔"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<div class="template-section">
<div class="template-section__header">
<div>
<h4 class="template-section__title">开播提醒模板</h4>
<p class="template-section__subtitle">主题模板输出纯文本正文模板支持 HTML</p>
</div>
</div>
<el-form-item label="主题模板">
<el-input v-model="form.emailLiveStartedSubjectTemplate" />
</el-form-item>
<el-form-item label="HTML 正文模板">
<el-input v-model="form.emailLiveStartedBodyTemplateHtml" type="textarea" :rows="10" />
</el-form-item>
</div>
</el-col>
<el-col :span="24">
<div class="template-section">
<div class="template-section__header">
<div>
<h4 class="template-section__title">异常提醒模板</h4>
<p class="template-section__subtitle">异常邮件会把错误摘要和任务上下文按变量注入到 HTML </p>
</div>
</div>
<el-form-item label="主题模板">
<el-input v-model="form.emailExceptionSubjectTemplate" />
</el-form-item>
<el-form-item label="HTML 正文模板">
<el-input v-model="form.emailExceptionBodyTemplateHtml" type="textarea" :rows="12" />
</el-form-item>
</div>
</el-col>
</el-row>
</el-form>
<div class="template-help">
<div class="template-help__label">可用占位符</div>
<div class="token-list">
<span v-for="token in emailTemplateTokens" :key="token" class="token-chip">{{ token }}</span>
</div>
</div>
<div class="email-actions">
<div class="helper-text">测试发送不会自动保存配置邮件中会同时渲染开播提醒示例异常提醒示例”。</div>
<el-button :loading="testingEmail" :disabled="!canSendTestEmail" @click="sendTestEmail">测试发送</el-button>
</div>
</el-card>
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">Douyin 请求头</h3>
<p class="section-subtitle">这些伪装参数会同时用于 Douyin API 请求和 ffmpeg 输入拉流适合处理直播流读取校验问题</p>
<el-form label-position="top">
<el-form-item label="Douyin User-Agent">
<el-input v-model="form.douyinUserAgent" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="Douyin Referer">
<el-input v-model="form.douyinReferer" />
</el-form-item>
<el-form-item label="Douyin Cookie">
<el-input
v-model="form.douyinCookie"
type="textarea"
:rows="4"
placeholder="可粘贴 ttwid、msToken 等 Cookie"
/>
</el-form-item>
</el-form>
</el-card>
</div>
</div>
</template>
<style scoped>
.page-stack {
display: grid;
gap: 24px;
}
.page-error-alert {
border-radius: 14px;
}
.settings-grid {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(320px, 0.85fr);
align-items: start;
gap: 20px;
}
.settings-grid__full {
grid-column: 1 / -1;
}
.settings-card :deep(.el-card__body) {
padding-top: 20px;
}
.example-box {
display: grid;
gap: 10px;
padding: 16px 18px;
border-radius: 12px;
background: linear-gradient(180deg, rgba(243, 246, 250, 0.92), rgba(255, 255, 255, 0.84));
border: 1px solid rgba(91, 110, 129, 0.14);
}
.example-box__label {
font-size: 12px;
color: #6e7883;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.example-box__value {
font-size: 12px;
line-height: 1.7;
color: #44505c;
}
.helper-panel {
margin-top: 14px;
padding: 14px 16px;
border-radius: 12px;
background: rgba(245, 248, 251, 0.74);
border: 1px solid rgba(91, 110, 129, 0.12);
color: #627080;
font-size: 13px;
line-height: 1.7;
}
.template-section {
padding: 18px 18px 4px;
border-radius: 12px;
border: 1px solid rgba(91, 110, 129, 0.12);
background: linear-gradient(180deg, rgba(248, 250, 252, 0.94), rgba(255, 255, 255, 0.84));
}
.template-section__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.template-section__title {
margin: 0;
font-size: 15px;
font-weight: 700;
letter-spacing: -0.02em;
color: #1e2937;
}
.template-section__subtitle {
margin: 6px 0 0;
font-size: 13px;
line-height: 1.7;
color: #697586;
}
.template-help {
margin-top: 8px;
padding: 16px 18px;
border-radius: 12px;
border: 1px solid rgba(91, 110, 129, 0.12);
background: rgba(245, 248, 251, 0.72);
}
.template-help__label {
margin-bottom: 10px;
font-size: 12px;
font-weight: 700;
color: #687381;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.email-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid rgba(91, 110, 129, 0.12);
}
.helper-text {
color: #657180;
font-size: 13px;
line-height: 1.7;
}
@media (max-width: 1100px) {
.settings-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 900px) {
.page-header :deep(.el-button) {
width: 100%;
}
.email-actions {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 768px) {
.settings-card :deep(.el-col) {
flex: 0 0 100%;
max-width: 100%;
}
.template-section {
padding: 16px 16px 2px;
}
.email-actions :deep(.el-button) {
width: 100%;
}
}
</style>