更新说明:\n1. 新增用户端与管理员端 Passkey 登录/注册/设备管理(最多3台,支持设备备注、删除设备)。\n2. 修复 Passkey 注册与登录流程中的浏览器/证书/CSRF相关问题,增强错误提示。\n3. 前台登录页改为独立入口,首屏仅加载必要资源,其他页面按需加载。\n4. 系统配置页改为静默获取金山文档状态,避免首屏阻塞,并优化状态展示为“检测中/已登录/未登录/异常”。\n5. 补充后端接口与页面渲染适配,修复多入口下样式依赖注入问题。\n6. 同步更新前后台构建产物与相关静态资源。
704 lines
19 KiB
Vue
704 lines
19 KiB
Vue
<script setup>
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
|
||
import { fetchAccounts } from '../api/accounts'
|
||
import {
|
||
clearScheduleLogs,
|
||
createSchedule,
|
||
deleteSchedule,
|
||
fetchScheduleLogs,
|
||
fetchSchedules,
|
||
runScheduleNow,
|
||
toggleSchedule,
|
||
updateSchedule,
|
||
} from '../api/schedules'
|
||
import { useUserStore } from '../stores/user'
|
||
|
||
const userStore = useUserStore()
|
||
|
||
const loading = ref(false)
|
||
const schedules = ref([])
|
||
const schedulePage = ref(1)
|
||
const scheduleTotal = ref(0)
|
||
const schedulePageSize = 12
|
||
|
||
const accountsLoading = ref(false)
|
||
const accountOptions = ref([])
|
||
|
||
const editorOpen = ref(false)
|
||
const editorSaving = ref(false)
|
||
const editingId = ref(null)
|
||
|
||
const logsOpen = ref(false)
|
||
const logsLoading = ref(false)
|
||
const logs = ref([])
|
||
const logsSchedule = ref(null)
|
||
|
||
const vipModalOpen = ref(false)
|
||
|
||
const form = reactive({
|
||
name: '',
|
||
schedule_time: '08:00',
|
||
weekdays: ['1', '2', '3', '4', '5'],
|
||
browse_type: '应读',
|
||
enable_screenshot: true,
|
||
random_delay: false,
|
||
account_ids: [],
|
||
})
|
||
|
||
const browseTypeOptions = [
|
||
{ label: '应读', value: '应读' },
|
||
{ label: '注册前未读', value: '注册前未读' },
|
||
]
|
||
|
||
function normalizeBrowseType(value) {
|
||
if (String(value) === '注册前未读') return '注册前未读'
|
||
return '应读'
|
||
}
|
||
|
||
const weekdayOptions = [
|
||
{ label: '周一', value: '1' },
|
||
{ label: '周二', value: '2' },
|
||
{ label: '周三', value: '3' },
|
||
{ label: '周四', value: '4' },
|
||
{ label: '周五', value: '5' },
|
||
{ label: '周六', value: '6' },
|
||
{ label: '周日', value: '7' },
|
||
]
|
||
|
||
const canUseSchedule = computed(() => userStore.isVip)
|
||
const scheduleTotalPages = computed(() => Math.max(1, Math.ceil((scheduleTotal.value || 0) / schedulePageSize)))
|
||
|
||
function normalizeTime(value) {
|
||
const match = String(value || '').match(/^(\d{1,2}):(\d{2})$/)
|
||
if (!match) return null
|
||
const hour = Number(match[1])
|
||
const minute = Number(match[2])
|
||
if (Number.isNaN(hour) || Number.isNaN(minute)) return null
|
||
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null
|
||
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
|
||
}
|
||
|
||
function weekdaysText(textOrArray) {
|
||
const raw = Array.isArray(textOrArray) ? textOrArray : String(textOrArray || '').split(',').filter(Boolean)
|
||
const map = Object.fromEntries(weekdayOptions.map((w) => [w.value, w.label]))
|
||
return raw.map((d) => map[String(d)] || String(d)).join(' ')
|
||
}
|
||
|
||
async function loadAccounts() {
|
||
accountsLoading.value = true
|
||
try {
|
||
const list = await fetchAccounts({ refresh: false })
|
||
accountOptions.value = (list || []).map((acc) => ({ label: acc.username, value: acc.id }))
|
||
} catch {
|
||
accountOptions.value = []
|
||
} finally {
|
||
accountsLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function reloadSchedulesAfterMutate() {
|
||
if (schedulePage.value > 1 && schedules.value.length <= 1) {
|
||
schedulePage.value -= 1
|
||
}
|
||
await loadSchedules()
|
||
}
|
||
|
||
async function onSchedulePageChange(page) {
|
||
schedulePage.value = page
|
||
await loadSchedules()
|
||
}
|
||
|
||
async function loadSchedules() {
|
||
loading.value = true
|
||
try {
|
||
const params = {
|
||
limit: schedulePageSize,
|
||
offset: (schedulePage.value - 1) * schedulePageSize,
|
||
}
|
||
const payload = await fetchSchedules(params)
|
||
const rawItems = Array.isArray(payload) ? payload : (Array.isArray(payload?.items) ? payload.items : [])
|
||
const rawTotal = Array.isArray(payload) ? rawItems.length : Number(payload?.total ?? rawItems.length)
|
||
schedules.value = rawItems.map((s) => ({
|
||
...s,
|
||
browse_type: normalizeBrowseType(s?.browse_type),
|
||
}))
|
||
scheduleTotal.value = Number.isFinite(rawTotal) ? Math.max(0, rawTotal) : rawItems.length
|
||
} catch (e) {
|
||
if (e?.response?.status === 401) window.location.href = '/login'
|
||
schedules.value = []
|
||
scheduleTotal.value = 0
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function openCreate() {
|
||
editingId.value = null
|
||
form.name = ''
|
||
form.schedule_time = '08:00'
|
||
form.weekdays = ['1', '2', '3', '4', '5']
|
||
form.browse_type = '应读'
|
||
form.enable_screenshot = true
|
||
form.random_delay = false
|
||
form.account_ids = []
|
||
editorOpen.value = true
|
||
}
|
||
|
||
function openEdit(schedule) {
|
||
editingId.value = schedule.id
|
||
form.name = schedule.name || ''
|
||
form.schedule_time = normalizeTime(schedule.schedule_time) || '08:00'
|
||
form.weekdays = String(schedule.weekdays || '')
|
||
.split(',')
|
||
.filter(Boolean)
|
||
.map((v) => String(v))
|
||
if (form.weekdays.length === 0) form.weekdays = ['1', '2', '3', '4', '5']
|
||
form.browse_type = normalizeBrowseType(schedule.browse_type)
|
||
form.enable_screenshot = Number(schedule.enable_screenshot ?? 1) !== 0
|
||
form.random_delay = Number(schedule.random_delay ?? 0) !== 0
|
||
form.account_ids = Array.isArray(schedule.account_ids) ? schedule.account_ids.slice() : []
|
||
editorOpen.value = true
|
||
}
|
||
|
||
async function saveSchedule() {
|
||
if (!canUseSchedule.value) {
|
||
vipModalOpen.value = true
|
||
return
|
||
}
|
||
|
||
const normalizedTime = normalizeTime(form.schedule_time)
|
||
if (!normalizedTime) {
|
||
ElMessage.error('时间格式错误,请使用 HH:MM')
|
||
return
|
||
}
|
||
if (!form.weekdays || form.weekdays.length === 0) {
|
||
ElMessage.warning('请选择至少一个执行日期')
|
||
return
|
||
}
|
||
|
||
editorSaving.value = true
|
||
try {
|
||
const payload = {
|
||
name: form.name.trim() || '我的定时任务',
|
||
schedule_time: normalizedTime,
|
||
weekdays: form.weekdays.join(','),
|
||
browse_type: form.browse_type,
|
||
enable_screenshot: form.enable_screenshot ? 1 : 0,
|
||
random_delay: form.random_delay ? 1 : 0,
|
||
account_ids: form.account_ids,
|
||
}
|
||
|
||
if (editingId.value) {
|
||
await updateSchedule(editingId.value, payload)
|
||
ElMessage.success('保存成功')
|
||
} else {
|
||
await createSchedule(payload)
|
||
ElMessage.success('创建成功')
|
||
schedulePage.value = 1
|
||
}
|
||
editorOpen.value = false
|
||
await loadSchedules()
|
||
} catch (e) {
|
||
const data = e?.response?.data
|
||
ElMessage.error(data?.error || '保存失败')
|
||
} finally {
|
||
editorSaving.value = false
|
||
}
|
||
}
|
||
|
||
async function onDelete(schedule) {
|
||
try {
|
||
await ElMessageBox.confirm(`确定要删除定时任务「${schedule.name || '未命名任务'}」吗?`, '删除任务', {
|
||
confirmButtonText: '删除',
|
||
cancelButtonText: '取消',
|
||
type: 'warning',
|
||
})
|
||
} catch {
|
||
return
|
||
}
|
||
|
||
try {
|
||
const res = await deleteSchedule(schedule.id)
|
||
if (res?.success) {
|
||
ElMessage.success('已删除')
|
||
await reloadSchedulesAfterMutate()
|
||
} else {
|
||
ElMessage.error(res?.error || '删除失败')
|
||
}
|
||
} catch (e) {
|
||
const data = e?.response?.data
|
||
ElMessage.error(data?.error || '删除失败')
|
||
}
|
||
}
|
||
|
||
async function onToggle(schedule, enabled) {
|
||
if (!canUseSchedule.value) {
|
||
vipModalOpen.value = true
|
||
return
|
||
}
|
||
|
||
try {
|
||
const res = await toggleSchedule(schedule.id, { enabled })
|
||
if (res?.success) {
|
||
schedule.enabled = enabled ? 1 : 0
|
||
ElMessage.success(enabled ? '已启用' : '已禁用')
|
||
}
|
||
} catch {
|
||
ElMessage.error('操作失败')
|
||
}
|
||
}
|
||
|
||
async function onRunNow(schedule) {
|
||
if (!canUseSchedule.value) {
|
||
vipModalOpen.value = true
|
||
return
|
||
}
|
||
|
||
try {
|
||
const res = await runScheduleNow(schedule.id)
|
||
if (res?.success) ElMessage.success(res?.message || '已开始执行')
|
||
else ElMessage.error(res?.error || '执行失败')
|
||
} catch (e) {
|
||
const data = e?.response?.data
|
||
ElMessage.error(data?.error || '执行失败')
|
||
}
|
||
}
|
||
|
||
async function openLogs(schedule) {
|
||
logsSchedule.value = schedule
|
||
logsOpen.value = true
|
||
logsLoading.value = true
|
||
try {
|
||
logs.value = await fetchScheduleLogs(schedule.id, { limit: 20 })
|
||
} catch {
|
||
logs.value = []
|
||
} finally {
|
||
logsLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function clearLogs() {
|
||
const schedule = logsSchedule.value
|
||
if (!schedule) return
|
||
|
||
try {
|
||
await ElMessageBox.confirm('确定要清空该任务的所有执行日志吗?', '清空日志', {
|
||
confirmButtonText: '清空',
|
||
cancelButtonText: '取消',
|
||
type: 'warning',
|
||
})
|
||
} catch {
|
||
return
|
||
}
|
||
|
||
try {
|
||
const res = await clearScheduleLogs(schedule.id)
|
||
if (res?.success) {
|
||
ElMessage.success(`已清空 ${res?.deleted || 0} 条日志`)
|
||
logs.value = []
|
||
} else {
|
||
ElMessage.error(res?.error || '操作失败')
|
||
}
|
||
} catch {
|
||
ElMessage.error('操作失败')
|
||
}
|
||
}
|
||
|
||
function statusTagType(status) {
|
||
const text = String(status || '')
|
||
if (text === 'success' || text === 'completed') return 'success'
|
||
if (text === 'failed') return 'danger'
|
||
return 'info'
|
||
}
|
||
|
||
function formatDuration(seconds) {
|
||
const value = Number(seconds || 0)
|
||
const mins = Math.floor(value / 60)
|
||
const secs = value % 60
|
||
if (mins <= 0) return `${secs} 秒`
|
||
return `${mins} 分 ${secs} 秒`
|
||
}
|
||
|
||
onMounted(async () => {
|
||
if (!userStore.vipInfo) {
|
||
userStore.refreshVipInfo().catch(() => {
|
||
window.location.href = '/login'
|
||
})
|
||
}
|
||
|
||
await Promise.all([loadAccounts(), loadSchedules()])
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="page">
|
||
<el-alert
|
||
v-if="!canUseSchedule"
|
||
type="warning"
|
||
show-icon
|
||
:closable="false"
|
||
title="定时任务为 VIP 专属功能,升级后可使用。"
|
||
class="vip-alert"
|
||
>
|
||
<template #default>
|
||
<div class="vip-actions">
|
||
<el-button type="primary" plain @click="vipModalOpen = true">了解VIP特权</el-button>
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
|
||
<el-card shadow="never" class="panel" :body-style="{ padding: '14px' }">
|
||
<div class="panel-head">
|
||
<div class="panel-title">定时任务</div>
|
||
<div class="panel-actions">
|
||
<el-button :loading="loading" @click="loadSchedules">刷新</el-button>
|
||
<el-button type="primary" :disabled="!canUseSchedule" @click="openCreate">新建任务</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<el-skeleton v-if="loading" :rows="6" animated />
|
||
<template v-else>
|
||
<el-empty v-if="schedules.length === 0" description="暂无定时任务" />
|
||
<div v-else class="grid">
|
||
<el-card v-for="s in schedules" :key="s.id" shadow="never" class="schedule-card" :body-style="{ padding: '14px' }">
|
||
<div class="schedule-top">
|
||
<div class="schedule-main">
|
||
<div class="schedule-title">
|
||
<span class="schedule-name">{{ s.name || '未命名任务' }}</span>
|
||
</div>
|
||
<div class="schedule-meta app-muted">
|
||
<span>⏰ {{ normalizeTime(s.schedule_time) || s.schedule_time }}</span>
|
||
<span>📅 {{ weekdaysText(s.weekdays) }}</span>
|
||
</div>
|
||
<div class="schedule-meta app-muted">
|
||
<span>📋 {{ s.browse_type || '应读' }}</span>
|
||
<span>👥 {{ (s.account_ids || []).length }} 个账号</span>
|
||
<span>{{ Number(s.enable_screenshot ?? 1) !== 0 ? '📸 截图' : '📷 不截图' }}</span>
|
||
<span v-if="Number(s.random_delay ?? 0) !== 0">🎲 随机±15分钟</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="schedule-switch">
|
||
<el-switch
|
||
:model-value="Boolean(Number(s.enabled))"
|
||
:disabled="!canUseSchedule"
|
||
inline-prompt
|
||
active-text="启用"
|
||
inactive-text="停用"
|
||
@change="(val) => onToggle(s, val)"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="schedule-actions">
|
||
<el-button size="small" type="primary" :disabled="!canUseSchedule" @click="onRunNow(s)">立即执行</el-button>
|
||
<el-button size="small" @click="openLogs(s)">日志</el-button>
|
||
<el-button size="small" :disabled="!canUseSchedule" @click="openEdit(s)">编辑</el-button>
|
||
<el-button size="small" type="danger" text :disabled="!canUseSchedule" @click="onDelete(s)">删除</el-button>
|
||
</div>
|
||
</el-card>
|
||
</div>
|
||
|
||
<div v-if="scheduleTotal > schedulePageSize" class="pagination">
|
||
<el-pagination
|
||
v-model:current-page="schedulePage"
|
||
:page-size="schedulePageSize"
|
||
:total="scheduleTotal"
|
||
layout="prev, pager, next, jumper, ->, total"
|
||
@current-change="onSchedulePageChange"
|
||
/>
|
||
<div class="page-hint app-muted">第 {{ schedulePage }} / {{ scheduleTotalPages }} 页</div>
|
||
</div>
|
||
</template>
|
||
</el-card>
|
||
|
||
<el-dialog v-model="editorOpen" :title="editingId ? '编辑定时任务' : '新建定时任务'" width="min(720px, 92vw)">
|
||
<el-form label-position="top">
|
||
<el-form-item label="任务名称">
|
||
<el-input v-model="form.name" placeholder="我的定时任务" :disabled="!canUseSchedule" />
|
||
</el-form-item>
|
||
<el-form-item label="执行时间(HH:MM)">
|
||
<el-time-picker
|
||
v-model="form.schedule_time"
|
||
placeholder="选择时间"
|
||
format="HH:mm"
|
||
value-format="HH:mm"
|
||
style="width: 180px"
|
||
:disabled="!canUseSchedule"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="执行日期">
|
||
<el-checkbox-group v-model="form.weekdays" :disabled="!canUseSchedule">
|
||
<el-checkbox v-for="w in weekdayOptions" :key="w.value" :label="w.value">{{ w.label }}</el-checkbox>
|
||
</el-checkbox-group>
|
||
</el-form-item>
|
||
<el-form-item label="浏览类型">
|
||
<el-select v-model="form.browse_type" style="width: 160px" :disabled="!canUseSchedule">
|
||
<el-option v-for="opt in browseTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="截图">
|
||
<div class="switch-row">
|
||
<el-switch
|
||
v-model="form.enable_screenshot"
|
||
:disabled="!canUseSchedule"
|
||
inline-prompt
|
||
active-text="截图"
|
||
inactive-text="不截图"
|
||
/>
|
||
<el-switch
|
||
v-model="form.random_delay"
|
||
:disabled="!canUseSchedule"
|
||
inline-prompt
|
||
active-text="随机±15分钟"
|
||
inactive-text="固定时间"
|
||
/>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="参与账号">
|
||
<el-select
|
||
v-model="form.account_ids"
|
||
multiple
|
||
filterable
|
||
collapse-tags
|
||
collapse-tags-tooltip
|
||
placeholder="选择账号(可多选)"
|
||
style="width: 100%"
|
||
:loading="accountsLoading"
|
||
:disabled="!canUseSchedule"
|
||
>
|
||
<el-option v-for="opt in accountOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||
</el-select>
|
||
</el-form-item>
|
||
</el-form>
|
||
|
||
<template #footer>
|
||
<el-button @click="editorOpen = false">取消</el-button>
|
||
<el-button type="primary" :loading="editorSaving" :disabled="!canUseSchedule" @click="saveSchedule">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-dialog v-model="logsOpen" :title="logsSchedule ? `【${logsSchedule.name || '未命名任务'}】执行日志` : '执行日志'" width="min(760px, 92vw)">
|
||
<el-skeleton v-if="logsLoading" :rows="6" animated />
|
||
<template v-else>
|
||
<el-empty v-if="logs.length === 0" description="暂无执行日志" />
|
||
<div v-else class="logs">
|
||
<el-card v-for="log in logs" :key="log.id" shadow="never" class="log-card" :body-style="{ padding: '12px' }">
|
||
<div class="log-head">
|
||
<el-tag size="small" effect="light" :type="statusTagType(log.status)">
|
||
{{ log.status === 'failed' ? '失败' : log.status === 'running' ? '进行中' : '成功' }}
|
||
</el-tag>
|
||
<span class="app-muted">{{ log.created_at || '' }}</span>
|
||
</div>
|
||
<div class="log-body">
|
||
<div>账号数:{{ log.total_accounts || 0 }} 个</div>
|
||
<div>成功:{{ log.success_count || 0 }} 个 · 失败:{{ log.failed_count || 0 }} 个</div>
|
||
<div>耗时:{{ formatDuration(log.duration || 0) }}</div>
|
||
<div v-if="log.error_message" class="log-error">错误:{{ log.error_message }}</div>
|
||
</div>
|
||
</el-card>
|
||
</div>
|
||
</template>
|
||
|
||
<template #footer>
|
||
<el-button @click="logsOpen = false">关闭</el-button>
|
||
<el-button type="danger" plain :disabled="logs.length === 0" @click="clearLogs">清空日志</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-dialog v-model="vipModalOpen" title="VIP 特权" width="min(560px, 92vw)">
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
title="升级 VIP 后可解锁:无限账号、优先排队、定时任务、批量操作。"
|
||
show-icon
|
||
/>
|
||
<div class="vip-body">
|
||
<div class="vip-tip app-muted">升级方式:请通过“反馈”联系管理员开通。</div>
|
||
</div>
|
||
<template #footer>
|
||
<el-button type="primary" @click="vipModalOpen = false">我知道了</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.switch-row {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
}
|
||
|
||
.vip-alert {
|
||
border-radius: var(--app-radius);
|
||
border: 1px solid var(--app-border);
|
||
}
|
||
|
||
.vip-actions {
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.panel {
|
||
border-radius: var(--app-radius);
|
||
border: 1px solid var(--app-border);
|
||
}
|
||
|
||
.panel-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin-bottom: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.panel-title {
|
||
font-size: 16px;
|
||
font-weight: 900;
|
||
}
|
||
|
||
.panel-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||
gap: 12px;
|
||
align-items: start;
|
||
}
|
||
|
||
.schedule-card {
|
||
border-radius: 14px;
|
||
border: 1px solid var(--app-border);
|
||
}
|
||
|
||
.schedule-top {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.schedule-main {
|
||
min-width: 0;
|
||
flex: 1;
|
||
}
|
||
|
||
.schedule-title {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
}
|
||
|
||
.schedule-name {
|
||
font-size: 14px;
|
||
font-weight: 900;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.schedule-meta {
|
||
margin-top: 6px;
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.schedule-actions {
|
||
margin-top: 12px;
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.pagination {
|
||
margin-top: 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.page-hint {
|
||
font-size: 12px;
|
||
}
|
||
|
||
.logs {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.log-card {
|
||
border-radius: 12px;
|
||
border: 1px solid var(--app-border);
|
||
}
|
||
|
||
.log-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.log-body {
|
||
margin-top: 8px;
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.log-error {
|
||
margin-top: 6px;
|
||
color: #b91c1c;
|
||
}
|
||
|
||
.vip-body {
|
||
padding: 12px 0 0;
|
||
}
|
||
|
||
.vip-tip {
|
||
margin-top: 10px;
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
@media (max-width: 480px) {
|
||
.grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.panel-actions {
|
||
width: 100%;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.schedule-switch {
|
||
width: 100%;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
}
|
||
}
|
||
</style>
|