feat(app): migrate schedules and screenshots (stage 4)
This commit is contained in:
@@ -1,20 +1,598 @@
|
||||
<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>
|
||||
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
|
||||
<h2 class="title">定时任务</h2>
|
||||
<div class="app-muted">阶段1:页面壳子已就绪,功能将在后续阶段迁移。</div>
|
||||
</el-card>
|
||||
<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>
|
||||
.card {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vip-alert {
|
||||
border-radius: var(--app-radius);
|
||||
border: 1px solid var(--app-border);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 6px;
|
||||
.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: 800;
|
||||
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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user