feat(app): migrate schedules and screenshots (stage 4)

This commit is contained in:
2025-12-14 00:15:19 +08:00
parent 9798ed52c3
commit 54cf6fe538
23 changed files with 924 additions and 44 deletions

View File

@@ -0,0 +1,42 @@
import { publicApi } from './http'
export async function fetchSchedules() {
const { data } = await publicApi.get('/schedules')
return data
}
export async function createSchedule(payload) {
const { data } = await publicApi.post('/schedules', payload)
return data
}
export async function updateSchedule(scheduleId, payload) {
const { data } = await publicApi.put(`/schedules/${scheduleId}`, payload)
return data
}
export async function deleteSchedule(scheduleId) {
const { data } = await publicApi.delete(`/schedules/${scheduleId}`)
return data
}
export async function toggleSchedule(scheduleId, payload) {
const { data } = await publicApi.post(`/schedules/${scheduleId}/toggle`, payload)
return data
}
export async function runScheduleNow(scheduleId) {
const { data } = await publicApi.post(`/schedules/${scheduleId}/run`, {})
return data
}
export async function fetchScheduleLogs(scheduleId, params = {}) {
const { data } = await publicApi.get(`/schedules/${scheduleId}/logs`, { params })
return data
}
export async function clearScheduleLogs(scheduleId) {
const { data } = await publicApi.delete(`/schedules/${scheduleId}/logs`)
return data
}

View File

@@ -0,0 +1,17 @@
import { publicApi } from './http'
export async function fetchScreenshots() {
const { data } = await publicApi.get('/screenshots')
return data
}
export async function deleteScreenshot(filename) {
const { data } = await publicApi.delete(`/screenshots/${encodeURIComponent(filename)}`)
return data
}
export async function clearScreenshots() {
const { data } = await publicApi.post('/screenshots/clear', {})
return data
}

View File

@@ -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>

View File

@@ -1,20 +1,253 @@
<script setup>
import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { clearScreenshots, deleteScreenshot, fetchScreenshots } from '../api/screenshots'
const loading = ref(false)
const screenshots = ref([])
const previewOpen = ref(false)
const previewUrl = ref('')
const previewTitle = ref('')
function buildUrl(filename) {
return `/screenshots/${encodeURIComponent(filename)}`
}
async function load() {
loading.value = true
try {
const data = await fetchScreenshots()
screenshots.value = Array.isArray(data) ? data : []
} catch (e) {
if (e?.response?.status === 401) window.location.href = '/login'
screenshots.value = []
} finally {
loading.value = false
}
}
function openPreview(item) {
previewTitle.value = item.display_name || item.filename || '截图预览'
previewUrl.value = buildUrl(item.filename)
previewOpen.value = true
}
async function onClearAll() {
try {
await ElMessageBox.confirm('确定要清空全部截图吗?', '清空截图', {
confirmButtonText: '清空',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await clearScreenshots()
if (res?.success) {
ElMessage.success(`已清空(删除 ${res?.deleted || 0} 张)`)
screenshots.value = []
previewOpen.value = false
return
}
ElMessage.error(res?.error || '操作失败')
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '操作失败')
}
}
async function onDelete(item) {
try {
await ElMessageBox.confirm(`确定要删除截图「${item.display_name || item.filename}」吗?`, '删除截图', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await deleteScreenshot(item.filename)
if (res?.success) {
screenshots.value = screenshots.value.filter((s) => s.filename !== item.filename)
if (previewUrl.value.includes(encodeURIComponent(item.filename))) previewOpen.value = false
ElMessage.success('已删除')
return
}
ElMessage.error(res?.error || '删除失败')
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '删除失败')
}
}
async function copyLink(item) {
const url = `${window.location.origin}${buildUrl(item.filename)}`
try {
await navigator.clipboard.writeText(url)
ElMessage.success('链接已复制')
} catch {
ElMessage.warning('复制失败,请手动复制链接')
}
}
async function copyImage(item) {
const url = buildUrl(item.filename)
try {
const resp = await fetch(url, { credentials: 'include' })
const blob = await resp.blob()
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })])
ElMessage.success('图片已复制')
} catch {
await copyLink(item)
}
}
function download(item) {
const link = document.createElement('a')
link.href = buildUrl(item.filename)
link.download = item.display_name || item.filename
document.body.appendChild(link)
link.click()
link.remove()
}
onMounted(load)
</script>
<template>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<h2 class="title">截图管理</h2>
<div class="app-muted">阶段1页面壳子已就绪功能将在后续阶段迁移</div>
<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="load">刷新</el-button>
<el-button type="danger" plain :disabled="screenshots.length === 0" @click="onClearAll">清空全部</el-button>
</div>
</div>
<el-skeleton v-if="loading" :rows="6" animated />
<template v-else>
<el-empty v-if="screenshots.length === 0" description="暂无截图" />
<div v-else class="grid">
<el-card v-for="item in screenshots" :key="item.filename" shadow="never" class="shot-card" :body-style="{ padding: '0' }">
<img class="shot-img" :src="buildUrl(item.filename)" :alt="item.display_name || item.filename" loading="lazy" @click="openPreview(item)" />
<div class="shot-body">
<div class="shot-name" :title="item.display_name || item.filename">{{ item.display_name || item.filename }}</div>
<div class="shot-meta app-muted">{{ item.created || '' }}</div>
<div class="shot-actions">
<el-button size="small" text type="primary" @click="copyImage(item)">复制图片</el-button>
<el-button size="small" text @click="download(item)">下载</el-button>
<el-button size="small" text type="danger" @click="onDelete(item)">删除</el-button>
</div>
</div>
</el-card>
</div>
</template>
<el-dialog v-model="previewOpen" :title="previewTitle" width="min(920px, 94vw)">
<div class="preview">
<img :src="previewUrl" :alt="previewTitle" class="preview-img" />
</div>
<template #footer>
<el-button @click="previewOpen = false">关闭</el-button>
</template>
</el-dialog>
</el-card>
</template>
<style scoped>
.card {
.panel {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.title {
margin: 0 0 6px;
.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(240px, 1fr));
gap: 12px;
}
.shot-card {
border-radius: 14px;
border: 1px solid var(--app-border);
overflow: hidden;
}
.shot-img {
width: 100%;
aspect-ratio: 16/9;
object-fit: cover;
cursor: pointer;
display: block;
}
.shot-body {
padding: 12px;
}
.shot-name {
font-size: 13px;
font-weight: 800;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.shot-meta {
margin-top: 4px;
font-size: 12px;
}
.shot-actions {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.preview {
display: flex;
justify-content: center;
}
.preview-img {
max-width: 100%;
max-height: 78vh;
object-fit: contain;
border-radius: 10px;
border: 1px solid var(--app-border);
background: #fff;
}
@media (max-width: 480px) {
.grid {
grid-template-columns: 1fr;
}
}
</style>