feat: AI虚拟用户新闻互动系统 v1.3.0 初始提交

- 虚拟用户管理(昵称/头像/性别/简介/邮箱同步到目标平台)
- AI互动调度(点赞/收藏/评论/转发)
- 日志时间改为北京时间
- 评论达上限后继续执行点赞收藏转发
- 一键登出全部功能
- 浅色主题UI
This commit is contained in:
stefanfeng
2026-03-31 10:20:57 +08:00
commit 0cfc9bf9c8
53 changed files with 8457 additions and 0 deletions

View File

@@ -0,0 +1,165 @@
<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 }">
<span style="font-size:13px">{{ row.article_title || '--' }}</span>
</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 ? new Date(row.executed_at).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false}) : '-' }}</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 } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getInteractions, retryInteraction, exportInteractions, cancelInteraction } from '@/api'
const records = ref([])
const total = ref(0)
const loading = ref(false)
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()
}
async function load() {
loading.value = true
try {
const res = await getInteractions({ page: page.value, page_size: pageSize.value, ...filters })
records.value = res.data?.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()
}
onMounted(() => { load(); window.addEventListener('page-refresh', load) })
onUnmounted(() => window.removeEventListener('page-refresh', load))
</script>
<style scoped>
.filter-bar { display: flex; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
.data-table { border-radius: 8px; overflow: hidden; }
</style>