Files
huihuiSquare/frontend/src/views/Interactions.vue
stefanfeng b43ee777fc feat: 互动记录/数据看板自动刷新 + 日志时间格式修复
1. Interactions.vue: 每30秒自动刷新互动记录列表
2. Dashboard.vue: 每30秒自动刷新数据看板
3. Logs.vue: 时间格式修复(T→空格,去掉时区标识)
4. logs.py: created_at 改用 strftime 输出 +08:00 格式(而非 isoformat 的 +00:00)

页面保持浏览时自动获取最新数据,离开页面时自动清除定时器
2026-04-07 14:51:59 +08:00

215 lines
8.6 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.
<template>
<div class="page-container">
<div class="page-header">
<span class="page-title">互动记录</span>
<el-button size="small" @click="handleExport">
<el-icon><Download /></el-icon> 导出
</el-button>
</div>
<div class="filter-bar">
<el-input v-model="filters.keyword" placeholder="搜索用户/文章" clearable @change="load" style="width:200px">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-select v-model="filters.interact_type" placeholder="互动类型" clearable @change="load" style="width:120px">
<el-option v-for="(l,v) in typeMap" :key="v" :label="l" :value="v" />
</el-select>
<el-select v-model="filters.status" placeholder="状态" clearable @change="load" style="width:110px">
<el-option label="执行中" :value="0"/><el-option label="成功" :value="1"/><el-option label="失败" :value="2"/>
</el-select>
<el-date-picker v-model="dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD" @change="onDateChange" style="width:240px" />
<el-button @click="resetFilters">重置</el-button>
</div>
<el-table :data="records" v-loading="loading" stripe class="data-table">
<el-table-column label="用户" width="140">
<template #default="{ row }">
<div style="font-size:13px;font-weight:600">{{ row.user_nickname }}</div>
<div style="font-size:11px;color:var(--color-text-muted)">{{ row.user_account }}</div>
</template>
</el-table-column>
<el-table-column label="文章" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<el-link
:href="row.article_url || '#'"
target="_blank" type="primary" style="font-size:13px">{{ row.article_title || row.article_id || '--' }}</el-link>
</template>
</el-table-column>
<el-table-column label="类型" width="80" align="center">
<template #default="{ row }">
<el-tag :type="typeTagType[row.interact_type]" size="small">{{ row.interact_type_label }}</el-tag>
</template>
</el-table-column>
<el-table-column label="内容" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span style="font-size:12px;color:var(--color-text-muted)">{{ row.content || '--' }}</span>
</template>
</el-table-column>
<el-table-column label="Token" width="75" align="center">
<template #default="{ row }">
<span style="font-size:12px;color:var(--color-accent)">{{ row.token_consumed || 0 }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType[row.status]" size="small">{{ row.status_label }}</el-tag>
</template>
</el-table-column>
<el-table-column label="执行时间" width="145">
<template #default="{ row }">
<span style="font-size:12px;color:var(--color-text-muted)">{{ row.executed_at ? row.executed_at.replace('T',' ').replace('+08:00','').replace('+00:00','') : '-' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="160" align="center">
<template #default="{ row }">
<el-button
v-if="row.status === 1 && row.interact_type !== 'forward' && row.interact_type !== 'read'"
size="small" type="danger" plain
:loading="cancellingId === row.id"
@click="handleCancel(row)">取消</el-button>
<el-button v-if="row.status === 2 && row.retry_count < 3" size="small" type="warning" @click="retry(row)">重试</el-button>
<el-tooltip v-else-if="row.status === 2" content="已超过最大重试次数" placement="top">
<el-button size="small" disabled>重试</el-button>
</el-tooltip>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page" v-model:page-size="pageSize"
:total="total" :page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
@change="load" style="margin-top:16px;justify-content:flex-end;display:flex"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted, nextTick, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getInteractions, retryInteraction, exportInteractions, cancelInteraction , getSystemConfigs } from '@/api'
const records = ref([])
const total = ref(0)
const loading = ref(false)
const articleBaseDomain = ref('') // 从系统配置动态读取
const page = ref(1)
const pageSize = ref(20)
const dateRange = ref(null)
const filters = reactive({ keyword: '', interact_type: null, status: null, start_date: '', end_date: '' })
const typeMap = { comment: '评论', reply: '回复', like: '点赞', collect: '收藏', forward: '转发' }
const typeTagType = { comment: '', reply: 'info', like: 'success', collect: 'warning', forward: 'danger' }
const statusTagType = { 0: 'info', 1: 'success', 2: 'danger' }
function onDateChange(v) {
filters.start_date = v?.[0] || ''; filters.end_date = v?.[1] || ''
load()
}
// 构造文章详情页 URLH5 分享页面)
// 用 computed 确保 articleBaseDomain 变化时响应式更新
const articleUrlBase = computed(() => articleBaseDomain.value)
function getArticleUrl(articleId) {
if (!articleId || !articleUrlBase.value) return '#'
return `${articleUrlBase.value}/huihui-h5/#/news/share?id=${articleId}&login=no`
}
// 从系统配置获取业务接口域名,用于拼接文章链接
async function loadArticleDomain() {
try {
const res = await getSystemConfigs()
// data 是 {key: {value, type, desc}} 格式的字典
const cfgData = res.data || {}
const bizUrl = cfgData['news_platform_base_url']?.value || ''
if (bizUrl) {
// 从 https://99hui.com/api/huihuibusiness 提取 https://99hui.com
const parts = bizUrl.split('/')
if (parts.length >= 3) {
articleBaseDomain.value = parts[0] + '//' + parts[2]
}
}
} catch(e) { console.error('loadArticleDomain error:', e) }
}
async function load() {
loading.value = true
try {
const res = await getInteractions({ page: page.value, page_size: pageSize.value, ...filters })
const items = res.data?.items || []
// 直接拼接文章 URL避免响应式追踪问题
items.forEach(item => {
if (item.article_id && articleBaseDomain.value) {
item.article_url = `${articleBaseDomain.value}/huihui-h5/#/news/share?id=${item.article_id}&login=no`
} else {
item.article_url = ''
}
})
records.value = items
total.value = res.data?.total || 0
} finally { loading.value = false }
}
function resetFilters() {
Object.assign(filters, { keyword: '', interact_type: null, status: null, start_date: '', end_date: '' })
dateRange.value = null; load()
}
const cancellingId = ref(null)
async function handleCancel(row) {
try {
await ElMessageBox.confirm(
`确认取消「${row.interact_type === 'comment' ? '评论' : row.interact_type === 'like' ? '点赞' : '收藏'}」互动?`,
'取消互动', { confirmButtonText: '确认取消', cancelButtonText: '再想想', type: 'warning' }
)
} catch { return }
cancellingId.value = row.id
try {
const res = await cancelInteraction(row.id)
if (res.code === 200 || res.code === 0) {
ElMessage.success('取消成功')
await loadInteractions()
} else {
ElMessage.error(res.message || '取消失败')
}
} catch(e) {
ElMessage.error('取消失败:' + (e.message || '未知错误'))
} finally {
cancellingId.value = null
}
}
async function retry(row) {
await retryInteraction(row.id)
ElMessage.success('重试已触发')
load()
}
async function handleExport() {
const res = await exportInteractions(filters)
const url = URL.createObjectURL(new Blob([res]))
const a = document.createElement('a'); a.href = url; a.download = 'interactions.xlsx'; a.click()
}
let _autoRefreshTimer = null
onMounted(async () => {
await loadArticleDomain() // 先等域名加载完
load()
window.addEventListener('page-refresh', load)
// 每30秒自动刷新互动记录
_autoRefreshTimer = setInterval(load, 30000)
})
onUnmounted(() => {
window.removeEventListener('page-refresh', load)
if (_autoRefreshTimer) clearInterval(_autoRefreshTimer)
})
</script>
<style scoped>
.filter-bar { display: flex; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
.data-table { border-radius: 8px; overflow: hidden; }
</style>