Files
huihuiSquare/frontend/src/views/Users.vue
T

615 lines
26 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">
<!-- Toolbar -->
<div class="page-header">
<div style="display:flex;align-items:center;gap:12px">
<span class="page-title">虚拟用户管理</span>
<el-tag type="info">共 {{ total }} 个用户</el-tag>
</div>
<div style="display:flex;gap:8px">
<el-button @click="downloadTemplate" size="small">
<el-icon><Download /></el-icon> 下载模板
</el-button>
<el-upload :show-file-list="false" accept=".xlsx,.xls" :before-upload="handleImport">
<el-tooltip content="仅账号和密码为必填,昵称为空时自动生成" placement="top">
<el-button size="small" type="warning">
<el-icon><Upload /></el-icon> 批量导入
</el-button>
</el-tooltip>
</el-upload>
<el-button size="small" @click="handleExport">
<el-icon><Download /></el-icon> 导出
</el-button>
<el-popconfirm title="将删除重复账号,只保留最早导入的一条,确认?" @confirm="handleDeduplicate" confirm-button-text="确认去重" cancel-button-text="取消">
<template #reference>
<el-button size="small" type="warning">
<el-icon><CopyDocument /></el-icon> 去重
</el-button>
</template>
</el-popconfirm>
<el-popconfirm title="将对所有未登录/登录失效的用户执行登录,确认?" @confirm="handleLoginAll" confirm-button-text="确认" cancel-button-text="取消">
<template #reference>
<el-button size="small" type="success" :loading="loginAllLoading">
<el-icon><Connection /></el-icon> 一键登录全部
</el-button>
</template>
</el-popconfirm>
<el-popconfirm title="确定要登出所有虚拟用户吗?" @confirm="handleLogoutAll">
<template #reference>
<el-button size="small" type="info" :loading="logoutAllLoading">
<el-icon><SwitchButton /></el-icon> 一键登出全部
</el-button>
</template>
</el-popconfirm>
<el-tooltip content="从目标平台同步所有已登录用户的昵称/真实姓名/性别/头像" placement="top">
<el-button size="small" type="info" :loading="syncLoading" @click="handleSyncAll">
<el-icon><Refresh /></el-icon> 同步用户信息
</el-button>
</el-tooltip>
<el-button type="primary" size="small" @click="openCreateDialog">
<el-icon><Plus /></el-icon> 新增用户
</el-button>
</div>
</div>
<!-- Filters -->
<div class="filter-bar">
<el-input v-model="filters.keyword" placeholder="搜索昵称/账号" clearable @change="loadUsers" style="width:200px">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-select v-model="filters.status" placeholder="用户状态" clearable @change="loadUsers" style="width:130px">
<el-option v-for="(label,val) in statusMap" :key="val" :label="label" :value="Number(val)" />
</el-select>
<el-select v-model="filters.is_enabled" placeholder="启用状态" clearable @change="loadUsers" style="width:120px">
<el-option label="已启用" :value="1" /><el-option label="已禁用" :value="0" />
</el-select>
<el-button @click="resetFilters">重置</el-button>
</div>
<!-- Batch actions -->
<div v-if="selectedIds.length" class="batch-bar">
<span class="batch-info">已选 {{ selectedIds.length }} 项</span>
<el-button size="small" type="success" @click="batchAction('enable')">批量启用</el-button>
<el-button size="small" type="warning" @click="batchAction('disable')">批量禁用</el-button>
<el-button size="small" @click="batchAction('logout')">批量登出</el-button>
<el-button size="small" type="danger" @click="batchAction('delete')">批量删除</el-button>
</div>
<!-- Table -->
<el-table
:data="users" v-loading="loading" :row-class-name="rowClassName"
@selection-change="selectedIds = $event.map(r => r.id)"
class="data-table"
>
<el-table-column type="selection" width="48" />
<el-table-column label="用户" width="200">
<template #default="{ row }">
<div style="display:flex;align-items:center;gap:10px">
<el-avatar :size="36" :src="row.avatar_url || ''" style="background:#30363d;flex-shrink:0">
{{ row.nickname?.[0] }}
</el-avatar>
<div>
<div style="font-weight:600;font-size:13px">{{ row.nickname }}</div>
<div style="font-size:11px;color:var(--color-text-muted)">{{ row.account }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType[row.status]" size="small">{{ row.status_label }}</el-tag>
</template>
</el-table-column>
<el-table-column label="活跃度" width="80" align="center">
<template #default="{ row }">
<el-tag :type="activityType[row.activity_level]" size="small" effect="plain">{{ row.activity_label }}</el-tag>
</template>
</el-table-column>
<el-table-column label="人格" min-width="140">
<template #default="{ row }">
<div v-if="row.personality" style="display:flex;gap:4px;flex-wrap:wrap">
<el-tag size="small" effect="plain">{{ row.personality.character_type }}</el-tag>
<el-tag size="small" effect="plain" type="warning">{{ row.personality.language_style }}</el-tag>
<el-tag v-for="t in (row.personality.interest_tags||[]).slice(0,2)" :key="t" size="small" effect="plain" type="info">{{ t }}</el-tag>
</div>
<span v-else style="color:var(--color-text-muted);font-size:12px">未生成</span>
</template>
</el-table-column>
<el-table-column label="今日评论" width="90" align="center">
<template #default="{ row }">
<span :style="row.today_comment_count >= row.daily_comment_limit ? 'color:var(--color-accent-red)' : ''">
{{ row.today_comment_count }}/{{ row.daily_comment_limit }}
</span>
</template>
</el-table-column>
<el-table-column label="累计互动" width="90" align="center" prop="total_interactions" />
<el-table-column label="最后互动" width="145">
<template #default="{ row }">
<span style="font-size:12px;color:var(--color-text-muted)">{{ row.last_interact_at ? new Date(row.last_interact_at).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false}) : '--' }}</span>
</template>
</el-table-column>
<el-table-column label="启用" width="70" align="center">
<template #default="{ row }">
<el-switch :model-value="row.is_enabled === 1" @change="toggleEnabled(row)" />
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<div style="display:flex;gap:6px;align-items:center;flex-wrap:nowrap;">
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="success" v-if="row.status !== 2" @click="doLogin(row)">登录</el-button>
<el-button size="small" type="warning" v-else @click="doLogout(row)">登出</el-button>
<el-dropdown size="small" trigger="click">
<el-button size="small">更多<el-icon style="margin-left:2px"><ArrowDown /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="openPersonality(row)">🧠 人格设置</el-dropdown-item>
<el-dropdown-item @click="doDelete(row)" style="color:#f85149">🗑️ 删除用户</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</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="loadUsers" style="margin-top:16px;justify-content:flex-end;display:flex"
/>
<!-- Create/Edit Dialog -->
<el-dialog v-model="dialogVisible" :title="editUser ? '编辑用户' : '新增虚拟用户'" width="560px">
<el-form :model="form" label-width="100px" :rules="rules" ref="formRef">
<el-form-item label="昵称">
<el-input v-model="form.nickname" placeholder="选填,留空自动生成" />
</el-form-item>
<el-form-item label="平台账号" prop="account">
<el-input v-model="form.account" placeholder="新闻平台登录账号" :disabled="!!editUser" />
</el-form-item>
<el-form-item label="登录密码" :prop="editUser ? '' : 'password'">
<el-input v-model="form.password" type="password" :placeholder="editUser ? '不修改请留空' : '登录密码'" show-password />
</el-form-item>
<el-form-item label="头像">
<div style="display:flex;gap:10px;align-items:flex-start;width:100%">
<el-avatar :size="50" :src="form.avatar_url" style="flex-shrink:0">
<span>{{ (form.nickname||'?')[0] }}</span>
</el-avatar>
<div style="flex:1">
<el-input v-model="form.avatar_url" placeholder="头像图片链接" style="margin-bottom:6px" />
<el-upload v-if="editUser" :show-file-list="false" :before-upload="handleAvatarUpload" accept="image/*">
<el-button size="small" type="primary" plain :loading="avatarUploading">📷 上传头像</el-button>
</el-upload>
</div>
</div>
</el-form-item>
<el-form-item label="真实姓名">
<el-input v-model="form.real_name" placeholder="可选" />
</el-form-item>
<el-form-item label="平台昵称">
<el-input v-model="form.nickname" placeholder="同步到目标平台的昵称" />
<div style="font-size:11px;color:var(--color-text-muted);margin-top:4px">昵称将同步到目标平台个人中心显示</div>
</el-form-item>
<el-form-item label="性别">
<el-radio-group v-model="form.sex">
<el-radio :value="0">未知</el-radio>
<el-radio :value="1">男</el-radio>
<el-radio :value="2">女</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="个人简介">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="个人简介(可同步到平台)" maxlength="100" show-word-limit />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="form.email" placeholder="邮箱地址(可同步到平台)" />
</el-form-item>
<el-form-item label="活跃度">
<el-radio-group v-model="form.activity_level">
<el-radio :value="0">低 (3-5次/天)</el-radio>
<el-radio :value="1">中 (8-15次/天)</el-radio>
<el-radio :value="2">高 (20-30次/天)</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="评论上限">
<el-input-number v-model="form.daily_comment_limit" :min="1" :max="100" />
<span style="margin-left:8px;font-size:12px;color:var(--color-text-muted)">次/天</span>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm" :loading="submitting">
{{ editUser ? '保存' : '创建并生成人格' }}
</el-button>
</template>
</el-dialog>
<!-- Personality Dialog -->
<el-dialog v-model="personalityVisible" title="AI人格管理" width="560px">
<div v-if="currentPersonality" class="personality-panel">
<div class="p-header">
<span class="p-user">{{ currentUser?.nickname }}</span>
<el-button size="small" type="primary" @click="regenPersonality" :loading="regenLoading">
<el-icon><RefreshRight /></el-icon> 重新生成
</el-button>
</div>
<div class="p-grid">
<div class="p-item"><span class="p-key">性格类型</span>
<el-select v-model="currentPersonality.character_type" size="small" style="width:120px">
<el-option v-for="c in characters" :key="c" :label="c" :value="c" />
</el-select>
</div>
<div class="p-item"><span class="p-key">语言风格</span>
<el-select v-model="currentPersonality.language_style" size="small" style="width:120px">
<el-option v-for="s in langStyles" :key="s" :label="s" :value="s" />
</el-select>
</div>
<div class="p-item"><span class="p-key">互动倾向</span>
<el-select v-model="currentPersonality.interact_tendency" size="small" style="width:120px">
<el-option v-for="t in tendencies" :key="t" :label="t" :value="t" />
</el-select>
</div>
<div class="p-item"><span class="p-key">字数范围</span>
<div style="display:flex;align-items:center;gap:6px">
<el-input-number v-model="currentPersonality.word_count_min" :min="10" :max="200" size="small" style="width:90px" />
<span>~</span>
<el-input-number v-model="currentPersonality.word_count_max" :min="30" :max="500" size="small" style="width:90px" />
</div>
</div>
</div>
<div class="p-item" style="margin-top:12px">
<span class="p-key">兴趣偏好</span>
<el-select v-model="currentPersonality.interest_tags" multiple size="small" style="width:100%">
<el-option v-for="t in interestTags" :key="t" :label="t" :value="t" />
</el-select>
</div>
<div class="p-item" style="margin-top:12px">
<span class="p-key">人格描述</span>
<el-input v-model="currentPersonality.personality_desc" type="textarea" :rows="3" size="small" />
</div>
</div>
<div v-else class="p-empty">
<el-empty description="尚未生成人格">
<el-button type="primary" @click="regenPersonality">立即生成</el-button>
</el-empty>
</div>
<template #footer>
<el-button @click="personalityVisible = false">关闭</el-button>
<el-button v-if="currentPersonality" type="primary" @click="savePersonality" :loading="savingPersonality">保存人格</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getUsers, createUser, updateUser, deleteUser, batchUserAction,
loginUser, logoutUser, generatePersonality, updatePersonality,
downloadTemplate as dlTemplate, importUsers, exportUsers, deduplicateUsers, loginAllUsers, syncAllProfiles,
uploadAvatar
} from '@/api'
const users = ref([])
const total = ref(0)
const loading = ref(false)
const page = ref(1)
const pageSize = ref(20)
const selectedIds = ref([])
const filters = reactive({ keyword: '', status: null, is_enabled: null })
const dialogVisible = ref(false)
const submitting = ref(false)
const editUser = ref(null)
const formRef = ref()
const form = reactive({ nickname: '', account: '', password: '', avatar_url: '', activity_level: 1, daily_comment_limit: 10, remark: '' })
const rules = {
account: [{ required: true, message: '请输入平台账号(必填)' }],
password: [{ required: true, min: 6, message: '密码至少6位(必填)' }],
}
const personalityVisible = ref(false)
const currentUser = ref(null)
const currentPersonality = ref(null)
const regenLoading = ref(false)
const savingPersonality = ref(false)
const statusMap = { 0: '未登录', 1: '登录中', 2: '已登录', 3: '登录失效', 4: '封禁' }
const statusType = { 0: 'info', 1: 'warning', 2: 'success', 3: 'danger', 4: 'danger' }
const activityType = { 0: 'info', 1: 'warning', 2: 'success' }
const characters = ['开朗','内敛','毒舌','温和','理性','感性','幽默','严谨']
const langStyles = ['严肃','幽默','文艺','吐槽','口语化','学术','简洁','丰富']
const tendencies = ['爱评论','爱点赞','爱收藏','潜水','爱转发','爱回复']
const interestTags = ['科技','财经','娱乐','体育','政治','文化','教育','医疗','汽车','房产','旅游','美食','军事','国际','环保','农业']
async function loadUsers() {
loading.value = true
try {
const res = await getUsers({ page: page.value, page_size: pageSize.value, ...filters })
users.value = res.data?.items || []
total.value = res.data?.total || 0
} finally { loading.value = false }
}
function resetFilters() {
Object.assign(filters, { keyword: '', status: null, is_enabled: null })
loadUsers()
}
function openCreateDialog() {
editUser.value = null
Object.assign(form, { nickname: '', account: '', password: '', avatar_url: '', real_name: '', sex: 0, description: '', email: '', activity_level: 1, daily_comment_limit: 10, remark: '' })
dialogVisible.value = true
}
function openEdit(row) {
editUser.value = row
Object.assign(form, { nickname: row.nickname, account: row.account, password: '', avatar_url: row.avatar_url || '', real_name: row.real_name || '', sex: row.sex ?? 0, description: row.description || '', email: row.email || '', activity_level: row.activity_level, daily_comment_limit: row.daily_comment_limit, remark: row.remark || '' })
dialogVisible.value = true
}
async function submitForm() {
await formRef.value.validate()
submitting.value = true
try {
if (editUser.value) {
const data = { ...form }
if (!data.password) delete data.password
data.sync_to_platform = true // 自动同步到目标平台
const res = await updateUser(editUser.value.id, data)
if (res.code === 200 || res.code === 206) {
// 局部刷新:只更新当前行数据,不刷新整个列表
const idx = users.value.findIndex(u => u.id === editUser.value.id)
if (idx !== -1) {
const updated = { ...users.value[idx], ...{
nickname: form.nickname || users.value[idx].nickname,
avatar_url: form.avatar_url,
real_name: form.real_name,
sex: form.sex,
activity_level: form.activity_level,
daily_comment_limit: form.daily_comment_limit,
remark: form.remark,
}}
users.value.splice(idx, 1, updated)
}
if (res.code === 206) {
ElMessage.warning(res.message || '本地已保存,平台同步失败')
} else {
ElMessage.success('更新成功')
}
} else {
ElMessage.error(res.message || '更新失败')
}
} else {
await createUser(form)
ElMessage.success('用户创建成功,AI人格已自动生成')
loadUsers()
}
dialogVisible.value = false
} finally { submitting.value = false }
}
async function doDelete(row) {
await ElMessageBox.confirm(`确认删除用户 "${row.nickname}"?`, '确认', { type: 'warning' })
await deleteUser(row.id)
ElMessage.success('删除成功')
loadUsers()
}
async function batchAction(action) {
if (action === 'delete') {
await ElMessageBox.confirm(`确认删除选中的 ${selectedIds.value.length} 个用户?`, '危险操作', { type: 'warning' })
}
await batchUserAction({ user_ids: selectedIds.value, action })
ElMessage.success('操作成功')
loadUsers()
}
async function toggleEnabled(row) {
await updateUser(row.id, { is_enabled: row.is_enabled ? 0 : 1 })
loadUsers()
}
async function doLogin(row) {
const loading = ElMessage({ message: `正在登录 ${row.nickname}...`, duration: 0 })
try {
await loginUser(row.id)
loading.close()
ElMessage.success('登录成功')
loadUsers()
} catch { loading.close() }
}
async function doLogout(row) {
await logoutUser(row.id)
ElMessage.success('已登出')
loadUsers()
}
function openPersonality(row) {
currentUser.value = row
currentPersonality.value = row.personality ? { ...row.personality } : null
personalityVisible.value = true
}
async function regenPersonality() {
regenLoading.value = true
try {
const res = await generatePersonality(currentUser.value.id)
currentPersonality.value = res.data
ElMessage.success('人格重新生成成功')
loadUsers()
} finally { regenLoading.value = false }
}
async function savePersonality() {
savingPersonality.value = true
try {
await updatePersonality(currentUser.value.id, currentPersonality.value)
ElMessage.success('人格已保存')
loadUsers()
} finally { savingPersonality.value = false }
}
async function downloadTemplate() {
const res = await dlTemplate()
const url = URL.createObjectURL(new Blob([res]))
const a = document.createElement('a'); a.href = url; a.download = 'import_template.xlsx'; a.click()
}
async function handleImport(file) {
const fd = new FormData(); fd.append('file', file)
// 显示进度提示
const loading = ElMessage({
message: '正在导入中,请稍候...',
type: 'info',
duration: 0,
showClose: false,
icon: 'Loading'
})
try {
const res = await importUsers(fd)
loading.close()
const d = res.data || {}
const success = d.success || 0
const failed = d.failed || 0
if (failed > 0 && success === 0) {
ElMessage.error(`导入失败:${failed}条失败,请检查数据格式`)
} else if (failed > 0) {
ElMessage.warning(`导入完成:成功 ${success} 条,失败 ${failed} 条`)
} else {
ElMessage.success(`导入成功:共导入 ${success} 条用户`)
}
// 导入完成后自动刷新列表
await loadUsers()
} catch(e) {
loading.close()
ElMessage.error('导入失败:' + (e.message || '未知错误'))
}
return false
}
async function handleExport() {
const res = await exportUsers()
const url = URL.createObjectURL(new Blob([res]))
const a = document.createElement('a'); a.href = url; a.download = 'users_export.xlsx'; a.click()
}
const loginAllLoading = ref(false)
const logoutAllLoading = ref(false)
const syncLoading = ref(false)
const avatarUploading = ref(false)
async function handleAvatarUpload(file) {
if (!editUser.value) return false
avatarUploading.value = true
try {
const formData = new FormData()
formData.append('file', file)
formData.append('sync_to_platform', 'true') // 始终同步到平台
const res = await uploadAvatar(editUser.value.id, formData)
if (res.code === 200 || res.code === 0) {
form.avatar_url = res.data?.avatar_url || ''
ElMessage.success('头像上传成功')
} else {
ElMessage.error(res.message || '头像上传失败')
}
} catch(e) {
ElMessage.error('上传失败:' + (e.message || '未知错误'))
} finally {
avatarUploading.value = false
}
return false // 阻止 el-upload 默认上传
}
async function handleLogoutAll() {
logoutAllLoading.value = true
try {
const res = await request.post('/users/logout-all')
ElMessage.success(res.message || '已登出所有用户')
loadUsers()
} catch(e) {
ElMessage.error('登出失败:' + (e.message || '未知错误'))
} finally {
logoutAllLoading.value = false
}
}
async function handleSyncAll() {
syncLoading.value = true
try {
const res = await syncAllProfiles()
const d = res.data || {}
ElMessage.success(res.message || `同步完成:${d.synced} 个用户`)
await loadUsers()
} catch(e) {
ElMessage.error('同步失败:' + (e.message || '未知错误'))
} finally {
syncLoading.value = false
}
}
async function handleLoginAll() {
loginAllLoading.value = true
try {
const res = await loginAllUsers()
const d = res.data || {}
ElMessage.success(res.message || `登录完成:成功 ${d.success} 个`)
await loadUsers()
} catch(e) {
ElMessage.error('一键登录失败:' + (e.message || '未知错误'))
} finally {
loginAllLoading.value = false
}
}
async function handleDeduplicate() {
const res = await deduplicateUsers()
ElMessage.success(res.message || '去重完成')
loadUsers()
}
// 暗色主题行样式:深浅交替,避免白底
function rowClassName({ rowIndex }) {
return rowIndex % 2 === 0 ? 'row-dark' : 'row-darker'
}
onMounted(() => { loadUsers(); window.addEventListener('page-refresh', loadUsers) })
onUnmounted(() => window.removeEventListener('page-refresh', loadUsers))
</script>
<style scoped>
.filter-bar { display: flex; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
.batch-bar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: rgba(88,166,255,0.08); border: 1px solid rgba(88,166,255,0.3); border-radius: 8px; margin-bottom: 10px; }
.batch-info { font-size: 13px; color: var(--color-accent); margin-right: 4px; }
.data-table { border-radius: 8px; overflow: hidden; }
.personality-panel { }
.p-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.p-user { font-size: 15px; font-weight: 600; }
.p-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.p-item { display: flex; align-items: center; gap: 10px; }
.p-key { font-size: 12px; color: var(--color-text-muted); white-space: nowrap; width: 60px; flex-shrink: 0; }
.p-empty { padding: 20px 0; }
/* 暗色主题表格行样式 */
:deep(.row-dark td) { background: var(--color-bg-card) !important; }
:deep(.row-darker td) { background: var(--color-bg-secondary) !important; }
:deep(.el-table__row:hover td) { background: rgba(88,166,255,0.08) !important; }
:deep(.el-table__header-wrapper th) {
background: var(--color-bg) !important;
color: var(--color-text-muted) !important;
border-bottom: 2px solid var(--color-border) !important;
font-size: 12px !important;
font-weight: 600 !important;
}
:deep(.el-table__body-wrapper) { background: transparent !important; }
:deep(.el-table) { background: transparent !important; }
</style>