fix: 账号页闪烁/浏览类型/截图复制/时区统一

This commit is contained in:
2025-12-14 11:30:49 +08:00
parent 2ec88eac3b
commit a9c8aac48f
59 changed files with 685 additions and 339 deletions

View File

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

View File

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

View File

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