fix: 账号页闪烁/浏览类型/截图复制/时区统一
This commit is contained in:
@@ -59,7 +59,6 @@ const editForm = reactive({
|
||||
|
||||
const browseTypeOptions = [
|
||||
{ label: '应读', value: '应读' },
|
||||
{ label: '未读', value: '未读' },
|
||||
{ label: '注册前未读', value: '注册前未读' },
|
||||
]
|
||||
|
||||
@@ -80,8 +79,13 @@ function normalizeAccountPayload(acc) {
|
||||
}
|
||||
|
||||
function replaceAccounts(list) {
|
||||
for (const key of Object.keys(accountsById)) delete accountsById[key]
|
||||
for (const acc of list || []) normalizeAccountPayload(acc)
|
||||
const nextList = Array.isArray(list) ? list : []
|
||||
const nextIds = new Set(nextList.map((acc) => String(acc?.id || '')))
|
||||
|
||||
for (const existingId of Object.keys(accountsById)) {
|
||||
if (!nextIds.has(existingId)) delete accountsById[existingId]
|
||||
}
|
||||
for (const acc of nextList) normalizeAccountPayload(acc)
|
||||
}
|
||||
|
||||
function ensureBrowseTypeDefaults() {
|
||||
@@ -138,8 +142,9 @@ function showRuntimeProgress(acc) {
|
||||
return true
|
||||
}
|
||||
|
||||
async function refreshStats() {
|
||||
statsLoading.value = true
|
||||
async function refreshStats(options = {}) {
|
||||
const silent = Boolean(options?.silent)
|
||||
if (!silent) statsLoading.value = true
|
||||
try {
|
||||
const data = await fetchRunStats()
|
||||
stats.today_completed = Number(data?.today_completed || 0)
|
||||
@@ -150,7 +155,7 @@ async function refreshStats() {
|
||||
} catch (e) {
|
||||
if (e?.response?.status === 401) window.location.href = '/login'
|
||||
} finally {
|
||||
statsLoading.value = false
|
||||
if (!silent) statsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,6 +461,41 @@ function bindSocket() {
|
||||
let unbindSocket = null
|
||||
let statsTimer = null
|
||||
|
||||
const shouldPollStats = computed(() => {
|
||||
// 仅在“真正执行中”才轮询(排队中不轮询,避免空转导致页面闪烁)
|
||||
return accounts.value.some((acc) => {
|
||||
if (!acc?.is_running) return false
|
||||
const statusText = String(acc.status || '')
|
||||
if (statusText.includes('排队')) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
function stopStatsPolling() {
|
||||
if (!statsTimer) return
|
||||
window.clearInterval(statsTimer)
|
||||
statsTimer = null
|
||||
}
|
||||
|
||||
function startStatsPolling() {
|
||||
if (statsTimer) return
|
||||
statsTimer = window.setInterval(() => refreshStats({ silent: true }), 10_000)
|
||||
}
|
||||
|
||||
function syncStatsPolling(prevRunning = null) {
|
||||
const running = shouldPollStats.value
|
||||
|
||||
// 任务从“运行中 -> 空闲”时,补拉一次统计以确保最终数据正确,再停止轮询
|
||||
if (prevRunning === true && running === false) refreshStats({ silent: true }).catch(() => {})
|
||||
|
||||
if (running) startStatsPolling()
|
||||
else stopStatsPolling()
|
||||
}
|
||||
|
||||
watch(shouldPollStats, (running, prevRunning) => {
|
||||
syncStatsPolling(prevRunning)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!userStore.vipInfo) {
|
||||
userStore.refreshVipInfo().catch(() => {
|
||||
@@ -467,12 +507,12 @@ onMounted(async () => {
|
||||
|
||||
await refreshAccounts()
|
||||
await refreshStats()
|
||||
statsTimer = window.setInterval(refreshStats, 10_000)
|
||||
syncStatsPolling()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (unbindSocket) unbindSocket()
|
||||
if (statsTimer) window.clearInterval(statsTimer)
|
||||
stopStatsPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -565,7 +605,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-skeleton v-if="loading || statsLoading" :rows="5" animated />
|
||||
<el-skeleton v-if="loading" :rows="5" animated />
|
||||
<template v-else>
|
||||
<el-empty v-if="accounts.length === 0" description="暂无账号,点击右上角添加" />
|
||||
<div v-else class="grid">
|
||||
|
||||
@@ -45,10 +45,14 @@ const form = reactive({
|
||||
|
||||
const browseTypeOptions = [
|
||||
{ label: '应读', value: '应读' },
|
||||
{ label: '未读', value: '未读' },
|
||||
{ label: '注册前未读', value: '注册前未读' },
|
||||
]
|
||||
|
||||
function normalizeBrowseType(value) {
|
||||
if (String(value) === '注册前未读') return '注册前未读'
|
||||
return '应读'
|
||||
}
|
||||
|
||||
const weekdayOptions = [
|
||||
{ label: '周一', value: '1' },
|
||||
{ label: '周二', value: '2' },
|
||||
@@ -92,7 +96,11 @@ async function loadAccounts() {
|
||||
async function loadSchedules() {
|
||||
loading.value = true
|
||||
try {
|
||||
schedules.value = await fetchSchedules()
|
||||
const list = await fetchSchedules()
|
||||
schedules.value = (Array.isArray(list) ? list : []).map((s) => ({
|
||||
...s,
|
||||
browse_type: normalizeBrowseType(s?.browse_type),
|
||||
}))
|
||||
} catch (e) {
|
||||
if (e?.response?.status === 401) window.location.href = '/login'
|
||||
schedules.value = []
|
||||
@@ -121,7 +129,7 @@ function openEdit(schedule) {
|
||||
.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.browse_type = normalizeBrowseType(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
|
||||
|
||||
@@ -34,6 +34,87 @@ function openPreview(item) {
|
||||
previewOpen.value = true
|
||||
}
|
||||
|
||||
function findRenderedShotImage(filename) {
|
||||
try {
|
||||
const escaped = typeof CSS !== 'undefined' && typeof CSS.escape === 'function' ? CSS.escape(String(filename)) : String(filename)
|
||||
return document.querySelector(`img[data-shot-filename="${escaped}"]`)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function canvasToPngBlob(canvas) {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('toBlob_failed'))), 'image/png')
|
||||
})
|
||||
}
|
||||
|
||||
async function imageElementToPngBlob(imgEl) {
|
||||
if (!imgEl) throw new Error('no_image')
|
||||
if (!imgEl.complete || imgEl.naturalWidth <= 0) {
|
||||
if (typeof imgEl.decode === 'function') await imgEl.decode()
|
||||
else {
|
||||
await new Promise((resolve, reject) => {
|
||||
imgEl.addEventListener('load', resolve, { once: true })
|
||||
imgEl.addEventListener('error', reject, { once: true })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = imgEl.naturalWidth
|
||||
canvas.height = imgEl.naturalHeight
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('no_canvas')
|
||||
ctx.drawImage(imgEl, 0, 0)
|
||||
return await canvasToPngBlob(canvas)
|
||||
}
|
||||
|
||||
async function blobToPng(blob) {
|
||||
if (!blob) throw new Error('no_blob')
|
||||
if (blob.type === 'image/png') return blob
|
||||
|
||||
if (typeof createImageBitmap === 'function') {
|
||||
const bitmap = await createImageBitmap(blob)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = bitmap.width
|
||||
canvas.height = bitmap.height
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('no_canvas')
|
||||
ctx.drawImage(bitmap, 0, 0)
|
||||
return await canvasToPngBlob(canvas)
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(blob)
|
||||
try {
|
||||
const img = new Image()
|
||||
img.src = url
|
||||
if (typeof img.decode === 'function') await img.decode()
|
||||
return await imageElementToPngBlob(img)
|
||||
} finally {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
async function screenshotUrlToPngBlob(url, filename) {
|
||||
// 优先使用页面上已渲染完成的 <img>(避免额外请求;也更容易满足剪贴板“用户手势”限制)
|
||||
const imgEl = findRenderedShotImage(filename)
|
||||
if (imgEl) {
|
||||
try {
|
||||
return await imageElementToPngBlob(imgEl)
|
||||
} catch {
|
||||
// fallback to fetch
|
||||
}
|
||||
}
|
||||
|
||||
const resp = await fetch(url, { credentials: 'include', cache: 'no-store' })
|
||||
if (!resp.ok) throw new Error('fetch_failed')
|
||||
const blob = await resp.blob()
|
||||
const mime = resp.headers.get('Content-Type') || blob.type || ''
|
||||
if (!mime.startsWith('image/')) throw new Error('not_image')
|
||||
return await blobToPng(blob)
|
||||
}
|
||||
|
||||
async function onClearAll() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要清空全部截图吗?', '清空截图', {
|
||||
@@ -98,14 +179,28 @@ async function copyImage(item) {
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, { credentials: 'include' })
|
||||
if (!resp.ok) throw new Error('fetch_failed')
|
||||
const blob = await resp.blob()
|
||||
const mime = blob.type || ''
|
||||
if (!mime.startsWith('image/')) throw new Error('not_image')
|
||||
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })])
|
||||
ElMessage.success('图片已复制')
|
||||
} catch {
|
||||
// 关键点:用 Promise 形式的数据源,让 clipboard.write 在用户手势内立即发生(更稳)
|
||||
try {
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
'image/png': screenshotUrlToPngBlob(url, item.filename),
|
||||
}),
|
||||
])
|
||||
} catch {
|
||||
const pngBlob = await screenshotUrlToPngBlob(url, item.filename)
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
|
||||
}
|
||||
ElMessage.success('图片已复制到剪贴板')
|
||||
} catch (e) {
|
||||
try {
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
await navigator.clipboard.writeText(`${window.location.origin}${url}`)
|
||||
ElMessage.warning('复制图片失败,已复制图片链接(可直接粘贴到浏览器打开)')
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
ElMessage.warning('复制图片失败:请确认允许剪贴板权限;可用“下载”。')
|
||||
}
|
||||
}
|
||||
@@ -138,7 +233,14 @@ onMounted(load)
|
||||
|
||||
<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)" />
|
||||
<img
|
||||
class="shot-img"
|
||||
:src="buildUrl(item.filename)"
|
||||
:alt="item.display_name || item.filename"
|
||||
:data-shot-filename="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>
|
||||
|
||||
Reference in New Issue
Block a user