新增断点基础设施与响应式布局,使手机(<768px)与平板(768–1023px)下可正常浏览和操作,桌面端(≥1024px)布局保持原有形态不变;引入此前未被入口引用的全局样式文件,使全局小屏兜底规则生效,并修复小屏分页控件被裁切、触屏端图表点击误触直接跳转的问题。 变更内容: - 断点基础设施:新增 `frontend/src/composables/useBreakpoints.ts`(isMobile / isTablet / isDesktop / viewportWidth / isTouch),作为全站统一断点与触屏判定来源 - 全局样式:`frontend/src/main.ts` 引入 `./style.css`(此前从未被引入,导致其中规则一直无效),使弹层 max-width 92vw、body 防横向溢出等兜底规则实际生效 - 主布局 `views/Layout.vue`:小屏侧栏收起为 NDrawer、Header 增加菜单按钮,切回桌面自动收起;内容区高度改用 dvh,内边距随断点收窄;新增三语文案 `layout.menu` - 表格:为列宽可能超出窄屏的 NDataTable 统一补 `:scroll-x`(收支记录、邮箱账单、规则/分类/标签/渠道、账单预览、AI 使用、关系编辑等) - 弹窗:为固定宽度弹窗补 `max-width: 92vw`(分类/标签/渠道/规则、收支新增编辑与原始数据与一键设置、通知渠道、登录与注册卡片) - 栅格与图表:`Dashboard.vue` / `SqlQuery.vue` 栅格改用断点列(responsive="screen"),图表高度随断点降低 - 浮窗:`FloatingPanel.vue` 按视口约束宽高、最小尺寸随视口下调;`AiQueryWindow.vue` / `EmojiPicker.vue` 小屏自适应 - 表单与列表:左标签表单小屏改为顶部标签(AI 配置、数据库配置、通知配置、邮箱账单弹窗);未换行 NSpace 与关系编辑器成员行小屏可换行 - 分页修复:≤1023px 下 `.n-pagination` 允许换行、居中、高度自适应(naive-ui 运行期注入样式优先级更高,用 !important 覆盖),表格内置分页容器同步居中,避免页大小选择与跳页输入被裁到视口外 - 触屏图表交互:`Dashboard.vue` 触屏端点击趋势柱或饼图扇区时按点击坐标弹出确认气泡(「查看明细 / 取消」),确认后才跳转;`handleTrendBlankClick` 在触屏端直接返回、禁用「绘图区整列可点」;桌面端保持点击即跳转;新增三语文案 `dashboard.chartActionHint` / `dashboard.chartActionView` 说明: - 桌面端布局与跳转逻辑不变,点击柱子直接跳转、不显示气泡 - 分页控件在窄屏换行显示,页大小可切换、跳页输入可见可用 - 触屏端滑动/缩放不触发跳转(仅图形本体点击且需二次确认) - 采用「基础可用适配」范围,未做移动端卡片式重排;断点统一走 `useBreakpoints`,后续新增页面按工作区 `AGENTS.md`《前端响应式适配(移动端 / 小屏)》约定实现 验证: - 测试环境(5174)以 375 / 768 / 800 / 1280 宽度遍历统计面板、收支记录、分类管理、设置、邮箱账单、日志、智能查数等页面,均无横向溢出;手机端侧栏抽屉可开合;分页控件均在视口内且页大小可切换 - 触屏(375,hasTouch)点柱子/饼图扇区弹出确认气泡且不跳转,点空白不弹不跳,点「查看明细」跳转 `/transactions` 并写入筛选偏好;桌面(1280)点柱子直接跳转 - `./package.sh` 构建成功(bootJar + bootGuiJar,含前端 build) - `npx vue-tsc -b --force` 类型检查通过
552 lines
18 KiB
Vue
552 lines
18 KiB
Vue
<script setup lang="ts">
|
|
// 定时通知配置面板:设置账单导入提醒周期,管理邮箱/Server 酱推送渠道,并查看发送日志。
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import {
|
|
useMessage, useDialog, NCard, NForm, NFormItem, NInput, NInputNumber, NSwitch, NButton,
|
|
NSpace, NAlert, NText, NTag, NIcon, NSelect, NModal, NDivider,
|
|
} from 'naive-ui'
|
|
import {
|
|
NotificationsOutline, SaveOutline, AddOutline, TrashOutline, CreateOutline, SendOutline,
|
|
} from '@vicons/ionicons5'
|
|
import {
|
|
getNotificationConfig, saveNotificationConfig, listNotificationChannels,
|
|
createNotificationChannel, updateNotificationChannel, deleteNotificationChannel,
|
|
testNotificationChannel, sendNotificationNow, listNotificationLogs,
|
|
getNotificationUrl, updateNotificationUrl,
|
|
} from '../api/notification'
|
|
import type { NotificationChannel, NotificationLog } from '../api/notification'
|
|
import { useBreakpoints } from '../composables/useBreakpoints'
|
|
|
|
const message = useMessage()
|
|
const dialog = useDialog()
|
|
const { t } = useI18n()
|
|
// 小屏时表单标签置顶,避免左侧固定标签挤压输入区
|
|
const { isMobile } = useBreakpoints()
|
|
|
|
// 提醒周期表单
|
|
const config = ref({ enabled: false, frequency: 'MONTHLY', dayOfMonth: 1, dayOfWeek: 1, sendTime: '09:00' })
|
|
const saving = ref(false)
|
|
const sendingNow = ref(false)
|
|
|
|
// 通知链接地址(全局应用配置,用于通知正文中的跳转链接)
|
|
const linkUrl = ref('')
|
|
const savingLink = ref(false)
|
|
|
|
// 渠道与日志
|
|
const channels = ref<NotificationChannel[]>([])
|
|
const logs = ref<NotificationLog[]>([])
|
|
const testingId = ref<number | null>(null)
|
|
|
|
// 渠道编辑弹窗
|
|
const showChannelModal = ref(false)
|
|
const editingId = ref<number | null>(null)
|
|
const editingHasPassword = ref(false)
|
|
const savingChannel = ref(false)
|
|
const channelForm = ref(emptyChannelForm())
|
|
|
|
/** 渠道表单默认值 */
|
|
function emptyChannelForm() {
|
|
return {
|
|
type: 'SERVERCHAN',
|
|
name: '',
|
|
enabled: true,
|
|
sendKey: '',
|
|
smtpHost: 'smtp.qq.com',
|
|
smtpPort: 465,
|
|
username: '',
|
|
password: '',
|
|
to: '',
|
|
ssl: true,
|
|
}
|
|
}
|
|
|
|
const frequencyOptions = computed(() => [
|
|
{ label: t('notification.freqDaily'), value: 'DAILY' },
|
|
{ label: t('notification.freqWeekly'), value: 'WEEKLY' },
|
|
{ label: t('notification.freqMonthly'), value: 'MONTHLY' },
|
|
])
|
|
|
|
const weekOptions = computed(() => [
|
|
{ label: t('notification.weekday1'), value: 1 },
|
|
{ label: t('notification.weekday2'), value: 2 },
|
|
{ label: t('notification.weekday3'), value: 3 },
|
|
{ label: t('notification.weekday4'), value: 4 },
|
|
{ label: t('notification.weekday5'), value: 5 },
|
|
{ label: t('notification.weekday6'), value: 6 },
|
|
{ label: t('notification.weekday7'), value: 7 },
|
|
])
|
|
|
|
// 每月提醒日限制在 1-28,避免不同月份天数差异
|
|
const monthDayOptions = computed(() => Array.from({ length: 28 }, (_, i) => ({ label: String(i + 1), value: i + 1 })))
|
|
|
|
// 前端开放 Server 酱与邮箱两种推送渠道
|
|
const channelTypeOptions = computed(() => [
|
|
{ label: t('notification.typeServerChan'), value: 'SERVERCHAN' },
|
|
{ label: t('notification.typeEmail'), value: 'EMAIL' },
|
|
])
|
|
|
|
/** 渠道类型显示名 */
|
|
function channelTypeLabel(type: string | null) {
|
|
if (type === 'SERVERCHAN') return t('notification.typeServerChan')
|
|
if (type === 'EMAIL') return t('notification.typeEmail')
|
|
return type || ''
|
|
}
|
|
|
|
/** 渠道类型标签颜色 */
|
|
function channelTagType(type: string) {
|
|
if (type === 'SERVERCHAN') return 'success'
|
|
if (type === 'EMAIL') return 'warning'
|
|
return 'info'
|
|
}
|
|
|
|
/** 格式化后端时间字符串用于展示 */
|
|
function formatTime(value: string | null) {
|
|
return value ? value.replace('T', ' ').slice(0, 19) : ''
|
|
}
|
|
|
|
/** 加载通知配置、渠道与日志 */
|
|
async function loadAll() {
|
|
try {
|
|
const [configRes, channelRes, logRes, linkRes]: any = await Promise.all([
|
|
getNotificationConfig(), listNotificationChannels(), listNotificationLogs(20), getNotificationUrl(),
|
|
])
|
|
linkUrl.value = linkRes.data || ''
|
|
const data = configRes.data
|
|
config.value = {
|
|
enabled: !!data.enabled,
|
|
frequency: data.frequency || 'MONTHLY',
|
|
dayOfMonth: data.dayOfMonth || 1,
|
|
dayOfWeek: data.dayOfWeek || 1,
|
|
sendTime: data.sendTime || '09:00',
|
|
}
|
|
channels.value = channelRes.data || []
|
|
logs.value = logRes.data || []
|
|
} catch (e: any) {
|
|
message.error(e.message || t('notification.loadFailed'))
|
|
}
|
|
}
|
|
|
|
/** 保存通知链接地址(全局应用配置) */
|
|
async function handleSaveLinkUrl() {
|
|
savingLink.value = true
|
|
try {
|
|
const res: any = await updateNotificationUrl(linkUrl.value.trim())
|
|
linkUrl.value = res.data || ''
|
|
message.success(t('notification.linkUrlSaveSuccess'))
|
|
} catch (e: any) {
|
|
message.error(e.message || t('notification.linkUrlSaveFailed'))
|
|
} finally {
|
|
savingLink.value = false
|
|
}
|
|
}
|
|
|
|
/** 保存提醒周期 */
|
|
async function handleSaveConfig() {
|
|
if (!/^\d{2}:\d{2}$/.test(config.value.sendTime)) {
|
|
message.error(t('notification.sendTimeInvalid'))
|
|
return
|
|
}
|
|
saving.value = true
|
|
try {
|
|
await saveNotificationConfig({ ...config.value })
|
|
message.success(t('notification.saveSuccess'))
|
|
await loadAll()
|
|
} catch (e: any) {
|
|
message.error(e.message || t('common.operationFailed'))
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
/** 打开渠道弹窗,传入渠道表示编辑 */
|
|
function openChannelModal(channel: NotificationChannel | null) {
|
|
editingId.value = channel ? channel.id : null
|
|
editingHasPassword.value = !!channel?.hasPassword
|
|
const base = emptyChannelForm()
|
|
if (channel) {
|
|
const cfg = channel.config || {}
|
|
Object.assign(base, {
|
|
type: channel.type,
|
|
name: channel.name,
|
|
enabled: channel.enabled,
|
|
sendKey: cfg.sendKey || '',
|
|
smtpHost: cfg.smtpHost || 'smtp.qq.com',
|
|
smtpPort: cfg.smtpPort || 465,
|
|
username: cfg.username || '',
|
|
password: '',
|
|
to: cfg.to || '',
|
|
ssl: cfg.ssl !== false,
|
|
})
|
|
}
|
|
channelForm.value = base
|
|
showChannelModal.value = true
|
|
}
|
|
|
|
/** 根据类型构建渠道配置,邮箱授权码为空时不提交 */
|
|
function buildChannelConfig() {
|
|
const form = channelForm.value
|
|
if (form.type === 'SERVERCHAN') {
|
|
return { sendKey: form.sendKey.trim() }
|
|
}
|
|
const config: Record<string, any> = {
|
|
smtpHost: form.smtpHost.trim(),
|
|
smtpPort: form.smtpPort,
|
|
username: form.username.trim(),
|
|
to: form.to.trim(),
|
|
ssl: form.ssl,
|
|
}
|
|
if (form.password) {
|
|
config.password = form.password
|
|
}
|
|
return config
|
|
}
|
|
|
|
/** 保存渠道 */
|
|
async function handleSaveChannel() {
|
|
const form = channelForm.value
|
|
if (form.type === 'SERVERCHAN') {
|
|
if (!form.sendKey.trim()) {
|
|
message.error(t('notification.sendKeyRequired'))
|
|
return
|
|
}
|
|
} else if (!form.username.trim()) {
|
|
message.error(t('notification.usernameRequired'))
|
|
return
|
|
}
|
|
savingChannel.value = true
|
|
try {
|
|
const payload = { type: form.type, name: form.name, enabled: form.enabled, config: buildChannelConfig() }
|
|
if (editingId.value) {
|
|
await updateNotificationChannel(editingId.value, payload)
|
|
} else {
|
|
await createNotificationChannel(payload)
|
|
}
|
|
message.success(t('notification.saveChannelSuccess'))
|
|
showChannelModal.value = false
|
|
await loadAll()
|
|
} catch (e: any) {
|
|
message.error(e.message || t('common.operationFailed'))
|
|
} finally {
|
|
savingChannel.value = false
|
|
}
|
|
}
|
|
|
|
/** 测试渠道 */
|
|
async function handleTestChannel(channel: NotificationChannel) {
|
|
testingId.value = channel.id
|
|
try {
|
|
await testNotificationChannel(channel.id)
|
|
message.success(t('notification.testSuccess'))
|
|
} catch (e: any) {
|
|
message.error(e.message || t('notification.testFailed'))
|
|
} finally {
|
|
testingId.value = null
|
|
await loadAll()
|
|
}
|
|
}
|
|
|
|
/** 删除渠道 */
|
|
function handleDeleteChannel(channel: NotificationChannel) {
|
|
dialog.warning({
|
|
title: t('common.confirmDelete'),
|
|
content: t('notification.confirmDeleteChannel', { name: channel.name }),
|
|
positiveText: t('common.confirm'),
|
|
negativeText: t('common.cancel'),
|
|
onPositiveClick: async () => {
|
|
try {
|
|
await deleteNotificationChannel(channel.id)
|
|
message.success(t('common.deleteSuccess'))
|
|
await loadAll()
|
|
} catch (e: any) {
|
|
message.error(e.message || t('common.deleteFailed'))
|
|
}
|
|
},
|
|
})
|
|
}
|
|
|
|
/** 立即发送提醒 */
|
|
async function handleSendNow() {
|
|
sendingNow.value = true
|
|
try {
|
|
const res: any = await sendNotificationNow()
|
|
message.success(t('notification.sendNowSuccess', { count: res.data }))
|
|
} catch (e: any) {
|
|
message.error(e.message || t('notification.sendNowFailed'))
|
|
} finally {
|
|
sendingNow.value = false
|
|
await loadAll()
|
|
}
|
|
}
|
|
|
|
onMounted(loadAll)
|
|
</script>
|
|
|
|
<template>
|
|
<NCard :bordered="false" class="config-card">
|
|
<template #header>
|
|
<NSpace align="center" :size="8">
|
|
<NIcon :component="NotificationsOutline" />
|
|
<span>{{ t('settings.notificationSection') }}</span>
|
|
</NSpace>
|
|
</template>
|
|
<template #header-extra>
|
|
<NSpace align="center" :size="8">
|
|
<NTag :type="config.enabled ? 'success' : 'default'" size="small" :bordered="false">
|
|
{{ config.enabled ? t('notification.statusOn') : t('notification.statusOff') }}
|
|
</NTag>
|
|
<NButton size="small" quaternary :loading="sendingNow" @click="handleSendNow">
|
|
<template #icon>
|
|
<NIcon :component="SendOutline" />
|
|
</template>
|
|
{{ t('notification.sendNow') }}
|
|
</NButton>
|
|
</NSpace>
|
|
</template>
|
|
|
|
<NAlert type="info" :show-icon="true" style="margin-bottom: 20px;">
|
|
{{ t('notification.intro') }}
|
|
</NAlert>
|
|
|
|
<NForm :label-placement="isMobile ? 'top' : 'left'" :label-width="isMobile ? undefined : 130">
|
|
<NFormItem :label="t('notification.linkUrl')">
|
|
<NSpace align="center" :size="8" style="width: 100%;">
|
|
<NInput
|
|
v-model:value="linkUrl"
|
|
:placeholder="t('notification.linkUrlPlaceholder')"
|
|
style="max-width: 420px"
|
|
/>
|
|
<NButton :loading="savingLink" @click="handleSaveLinkUrl">
|
|
<template #icon>
|
|
<NIcon :component="SaveOutline" />
|
|
</template>
|
|
{{ t('notification.save') }}
|
|
</NButton>
|
|
</NSpace>
|
|
</NFormItem>
|
|
<NFormItem :label="t('notification.enabled')">
|
|
<div class="switch-row">
|
|
<NSwitch v-model:value="config.enabled" />
|
|
<NText depth="3" style="font-size: 12px;">{{ t('notification.enabledHint') }}</NText>
|
|
</div>
|
|
</NFormItem>
|
|
<NFormItem :label="t('notification.frequency')">
|
|
<NSpace align="center" :size="8">
|
|
<NSelect v-model:value="config.frequency" :options="frequencyOptions" style="width: 120px" />
|
|
<NSelect
|
|
v-if="config.frequency === 'WEEKLY'"
|
|
v-model:value="config.dayOfWeek"
|
|
:options="weekOptions"
|
|
style="width: 120px"
|
|
/>
|
|
<template v-else-if="config.frequency === 'MONTHLY'">
|
|
<NSelect v-model:value="config.dayOfMonth" :options="monthDayOptions" style="width: 90px" />
|
|
<NText depth="3">{{ t('notification.dayOfMonthSuffix') }}</NText>
|
|
</template>
|
|
</NSpace>
|
|
</NFormItem>
|
|
<NFormItem :label="t('notification.sendTime')">
|
|
<NInput
|
|
v-model:value="config.sendTime"
|
|
:placeholder="t('notification.sendTimePlaceholder')"
|
|
style="width: 160px"
|
|
/>
|
|
</NFormItem>
|
|
<NFormItem>
|
|
<NSpace justify="end" style="width: 100%;">
|
|
<NButton type="primary" :loading="saving" @click="handleSaveConfig">
|
|
<template #icon>
|
|
<NIcon :component="SaveOutline" />
|
|
</template>
|
|
{{ t('notification.save') }}
|
|
</NButton>
|
|
</NSpace>
|
|
</NFormItem>
|
|
</NForm>
|
|
|
|
<NDivider />
|
|
|
|
<div class="section-header">
|
|
<span class="section-title">{{ t('notification.channels') }}</span>
|
|
<NButton size="small" @click="openChannelModal(null)">
|
|
<template #icon>
|
|
<NIcon :component="AddOutline" />
|
|
</template>
|
|
{{ t('notification.addChannel') }}
|
|
</NButton>
|
|
</div>
|
|
|
|
<div v-if="channels.length" class="channel-list">
|
|
<div v-for="channel in channels" :key="channel.id" class="channel-item">
|
|
<div class="channel-main">
|
|
<NTag size="small" :bordered="false" :type="channelTagType(channel.type)">
|
|
{{ channelTypeLabel(channel.type) }}
|
|
</NTag>
|
|
<span class="channel-name">{{ channel.name }}</span>
|
|
<NTag v-if="channel.enabled" size="small" type="success" :bordered="false">
|
|
{{ t('notification.enabledLabel') }}
|
|
</NTag>
|
|
<NTag v-else size="small" :bordered="false">{{ t('notification.disabledLabel') }}</NTag>
|
|
</div>
|
|
<NSpace :size="4" align="center">
|
|
<NButton size="tiny" quaternary :loading="testingId === channel.id" @click="handleTestChannel(channel)">
|
|
{{ t('notification.test') }}
|
|
</NButton>
|
|
<NButton size="tiny" quaternary @click="openChannelModal(channel)">
|
|
<template #icon>
|
|
<NIcon :component="CreateOutline" />
|
|
</template>
|
|
</NButton>
|
|
<NButton size="tiny" quaternary type="error" @click="handleDeleteChannel(channel)">
|
|
<template #icon>
|
|
<NIcon :component="TrashOutline" />
|
|
</template>
|
|
</NButton>
|
|
</NSpace>
|
|
</div>
|
|
</div>
|
|
<NText v-else depth="3">{{ t('notification.noChannels') }}</NText>
|
|
|
|
<NDivider />
|
|
|
|
<div class="section-header">
|
|
<span class="section-title">{{ t('notification.logs') }}</span>
|
|
</div>
|
|
|
|
<div v-if="logs.length" class="log-list">
|
|
<div v-for="log in logs" :key="log.id" class="log-item">
|
|
<NTag size="tiny" :bordered="false" :type="log.success ? 'success' : 'error'">
|
|
{{ log.success ? t('notification.success') : t('notification.failed') }}
|
|
</NTag>
|
|
<span class="log-time">{{ formatTime(log.createdAt) }}</span>
|
|
<span class="log-channel">{{ channelTypeLabel(log.channelType) }}</span>
|
|
<span class="log-message">{{ log.message }}</span>
|
|
</div>
|
|
</div>
|
|
<NText v-else depth="3">{{ t('notification.noLogs') }}</NText>
|
|
|
|
<NModal
|
|
v-model:show="showChannelModal"
|
|
preset="card"
|
|
:title="editingId ? t('notification.editChannel') : t('notification.addChannel')"
|
|
style="width: 560px; max-width: 92vw"
|
|
>
|
|
<NForm :label-placement="isMobile ? 'top' : 'left'" :label-width="isMobile ? undefined : 120">
|
|
<NFormItem :label="t('notification.channelType')">
|
|
<NSelect v-model:value="channelForm.type" :options="channelTypeOptions" :disabled="!!editingId" />
|
|
</NFormItem>
|
|
<NFormItem :label="t('notification.channelName')">
|
|
<NInput v-model:value="channelForm.name" :placeholder="t('notification.channelNamePlaceholder')" />
|
|
</NFormItem>
|
|
|
|
<template v-if="channelForm.type === 'SERVERCHAN'">
|
|
<NFormItem :label="t('notification.sendKey')">
|
|
<NInput v-model:value="channelForm.sendKey" :placeholder="t('notification.sendKeyPlaceholder')" />
|
|
</NFormItem>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<NFormItem :label="t('notification.smtpHost')">
|
|
<NInput v-model:value="channelForm.smtpHost" placeholder="smtp.qq.com" />
|
|
</NFormItem>
|
|
<NFormItem :label="t('notification.smtpPort')">
|
|
<NInputNumber v-model:value="channelForm.smtpPort" :min="1" :max="65535" style="width: 160px" />
|
|
</NFormItem>
|
|
<NFormItem :label="t('notification.username')">
|
|
<NInput v-model:value="channelForm.username" placeholder="example@qq.com" />
|
|
</NFormItem>
|
|
<NFormItem :label="t('notification.password')">
|
|
<NInput
|
|
v-model:value="channelForm.password"
|
|
type="password"
|
|
show-password-on="click"
|
|
:placeholder="editingHasPassword ? t('notification.passwordKeep') : t('notification.passwordPlaceholder')"
|
|
/>
|
|
</NFormItem>
|
|
<NFormItem :label="t('notification.to')">
|
|
<NInput v-model:value="channelForm.to" :placeholder="t('notification.toPlaceholder')" />
|
|
</NFormItem>
|
|
<NFormItem :label="t('notification.ssl')">
|
|
<NSwitch v-model:value="channelForm.ssl" />
|
|
</NFormItem>
|
|
</template>
|
|
|
|
<NFormItem :label="t('notification.enabledLabel')">
|
|
<NSwitch v-model:value="channelForm.enabled" />
|
|
</NFormItem>
|
|
</NForm>
|
|
|
|
<template #footer>
|
|
<NSpace justify="end">
|
|
<NButton @click="showChannelModal = false">{{ t('common.cancel') }}</NButton>
|
|
<NButton type="primary" :loading="savingChannel" @click="handleSaveChannel">
|
|
{{ t('common.confirm') }}
|
|
</NButton>
|
|
</NSpace>
|
|
</template>
|
|
</NModal>
|
|
</NCard>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.switch-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
.section-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 12px;
|
|
}
|
|
.section-title {
|
|
font-weight: 600;
|
|
}
|
|
.channel-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
.channel-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 8px 12px;
|
|
border: 1px solid #efefef;
|
|
border-radius: 6px;
|
|
}
|
|
.channel-main {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.channel-name {
|
|
font-weight: 500;
|
|
}
|
|
.log-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
.log-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
font-size: 12px;
|
|
}
|
|
.log-time {
|
|
color: #999;
|
|
}
|
|
.log-channel {
|
|
color: #666;
|
|
min-width: 100px;
|
|
}
|
|
.log-message {
|
|
color: #333;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
</style>
|