Files
zsglpt/app-frontend/src/pages/SchedulesPage.vue
T
19174616018 7007f5f6f5 feat: 完成 Passkey 能力与前后台加载优化
更新说明:\n1. 新增用户端与管理员端 Passkey 登录/注册/设备管理(最多3台,支持设备备注、删除设备)。\n2. 修复 Passkey 注册与登录流程中的浏览器/证书/CSRF相关问题,增强错误提示。\n3. 前台登录页改为独立入口,首屏仅加载必要资源,其他页面按需加载。\n4. 系统配置页改为静默获取金山文档状态,避免首屏阻塞,并优化状态展示为“检测中/已登录/未登录/异常”。\n5. 补充后端接口与页面渲染适配,修复多入口下样式依赖注入问题。\n6. 同步更新前后台构建产物与相关静态资源。
2026-02-15 23:51:46 +08:00

704 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>