1. Interactions.vue: 每30秒自动刷新互动记录列表 2. Dashboard.vue: 每30秒自动刷新数据看板 3. Logs.vue: 时间格式修复(T→空格,去掉时区标识) 4. logs.py: created_at 改用 strftime 输出 +08:00 格式(而非 isoformat 的 +00:00) 页面保持浏览时自动获取最新数据,离开页面时自动清除定时器
215 lines
8.6 KiB
Vue
215 lines
8.6 KiB
Vue
<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()
|
||
}
|
||
|
||
// 构造文章详情页 URL(H5 分享页面)
|
||
// 用 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>
|