599 lines
17 KiB
Vue
599 lines
17 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 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,
|
||
account_ids: [],
|
||
})
|
||
|
||
const browseTypeOptions = [
|
||
{ label: '应读', value: '应读' },
|
||
{ label: '未读', value: '未读' },
|
||
{ label: '注册前未读', value: '注册前未读' },
|
||
]
|
||
|
||
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)
|
||
|
||
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 loadSchedules() {
|
||
loading.value = true
|
||
try {
|
||
schedules.value = await fetchSchedules()
|
||
} catch (e) {
|
||
if (e?.response?.status === 401) window.location.href = '/login'
|
||
schedules.value = []
|
||
} 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.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 = schedule.browse_type || '应读'
|
||
form.enable_screenshot = Number(schedule.enable_screenshot ?? 1) !== 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,
|
||
account_ids: form.account_ids,
|
||
}
|
||
|
||
if (editingId.value) {
|
||
await updateSchedule(editingId.value, payload)
|
||
ElMessage.success('保存成功')
|
||
} else {
|
||
await createSchedule(payload)
|
||
ElMessage.success('创建成功')
|
||
}
|
||
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 loadSchedules()
|
||
} 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>
|
||
<el-tag size="small" effect="light" :type="Number(s.enabled) ? 'success' : 'info'">
|
||
{{ Number(s.enabled) ? '启用' : '停用' }}
|
||
</el-tag>
|
||
</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>
|
||
</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>
|
||
</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-input v-model="form.schedule_time" placeholder="08:00" :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="截图">
|
||
<el-switch v-model="form.enable_screenshot" :disabled="!canUseSchedule" inline-prompt active-text="截图" inactive-text="不截图" />
|
||
</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;
|
||
}
|
||
|
||
.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: flex-start;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.schedule-card {
|
||
border-radius: 14px;
|
||
border: 1px solid var(--app-border);
|
||
}
|
||
|
||
.schedule-top {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
}
|
||
</style>
|