Files
abot/admin/dashboard/templates/contacts_management.html

1511 lines
90 KiB
HTML
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.
{% extends "base.html" %}
{% block title %}通讯录管理 - 机器人管理后台{% endblock %}
{% block content %}
<div class="page-shell contacts-page">
<div class="page-hero">
<div class="page-hero-copy">
<div class="page-eyebrow">Contacts Workspace</div>
<h1>通讯录管理</h1>
<p>把联系人、群组、权限配置与群诊断统一放进一个联络工作台里,减少分散操作。</p>
</div>
<div class="page-hero-actions">
<el-input placeholder="搜索联系人 / 群组..." v-model="searchQuery" class="hero-search" clearable>
<i slot="prefix" class="el-input__icon el-icon-search"></i>
</el-input>
<el-button type="success" @click="updateContacts">更新通讯录</el-button>
<el-button type="primary" @click="refreshContacts">刷新数据</el-button>
</div>
</div>
<el-row :gutter="16" class="overview-grid">
<el-col :span="6">
<el-card class="overview-card overview-card--primary" shadow="hover">
<div class="overview-label">总联系人数</div>
<div class="overview-value">{% raw %}{{ statistics.total }}{% endraw %}</div>
<div class="overview-note">系统已收录联系人总量</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="overview-card" shadow="hover">
<div class="overview-label">群组数</div>
<div class="overview-value">{% raw %}{{ statistics.groups }}{% endraw %}</div>
<div class="overview-note">活跃或可管理的群组</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="overview-card" shadow="hover">
<div class="overview-label">个人联系人数</div>
<div class="overview-value">{% raw %}{{ statistics.personal }}{% endraw %}</div>
<div class="overview-note">私聊联系人规模</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="overview-card overview-card--soft" shadow="hover">
<div class="overview-label">公众号数</div>
<div class="overview-value">{% raw %}{{ statistics.official }}{% endraw %}</div>
<div class="overview-note">已同步的公众号账号</div>
</el-card>
</el-col>
</el-row>
<el-card class="workspace-card" shadow="hover">
<div slot="header" class="workspace-header">
<div>
<h3>联系人分类视图</h3>
<p>按群组、个人、公众号和公共好友切换查看;群组页已集成权限与群运营视图。</p>
</div>
</div>
<el-tabs v-model="activeTab" @tab-click="handleTabClick" class="contacts-tabs">
<el-tab-pane label="群组" name="groups">
<el-table :data="filteredGroups" style="width: 100%">
<el-table-column type="index" width="54"></el-table-column>
<el-table-column label="群组信息" min-width="320">
<template slot-scope="scope">
<div class="entity-cell">
<div class="entity-avatar entity-avatar--group">
<i class="el-icon-user-solid"></i>
</div>
<div class="entity-copy">
<div class="entity-title">{% raw %}{{ scope.row.name }}{% endraw %}</div>
<div class="entity-subtitle">{% raw %}{{ scope.row.wxid }}{% endraw %}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="机器人状态" width="120" align="center">
<template slot-scope="scope">
<el-tag :type="scope.row.robot_status === 'enabled' ? 'success' : 'info'">
{% raw %}{{ scope.row.robot_status === 'enabled' ? '已启用' : '未启用' }}{% endraw %}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="220">
<template slot-scope="scope">
<div class="action-row">
<el-button size="mini" type="primary" plain @click="viewGroupDetails(scope.row)">查看详情</el-button>
<el-button size="mini" type="success" @click="openChatDialog(scope.row)">聊天</el-button>
</div>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="个人联系人" name="personal">
<el-table :data="filteredPersonal" style="width: 100%">
<el-table-column type="index" width="54"></el-table-column>
<el-table-column label="联系人" min-width="320">
<template slot-scope="scope">
<div class="entity-cell">
<el-avatar size="small" :src="getHeadImage(scope.row.wxid)" @error="() => true">
<img src="/static/logo.png"/>
</el-avatar>
<div class="entity-copy">
<div class="entity-title">{% raw %}{{ scope.row.name }}{% endraw %}</div>
<div class="entity-subtitle">{% raw %}{{ scope.row.wxid }}{% endraw %}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="220">
<template slot-scope="scope">
<div class="action-row">
<el-button size="mini" type="primary" plain @click="viewUserDetails(scope.row)">查看详情</el-button>
<el-button size="mini" type="success" @click="openChatDialog(scope.row)">聊天</el-button>
</div>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="公众号" name="official">
<el-table :data="filteredOfficial" style="width: 100%">
<el-table-column type="index" width="54"></el-table-column>
<el-table-column label="公众号" min-width="320">
<template slot-scope="scope">
<div class="entity-cell">
<el-avatar size="small" :src="getHeadImage(scope.row.wxid)" @error="() => true">
<img src="/static/logo.png"/>
</el-avatar>
<div class="entity-copy">
<div class="entity-title">{% raw %}{{ scope.row.name }}{% endraw %}</div>
<div class="entity-subtitle">{% raw %}{{ scope.row.wxid }}{% endraw %}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="160">
<template slot-scope="scope">
<el-button size="mini" type="primary" plain @click="viewOfficialDetails(scope.row)">查看详情</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="公共好友" name="public">
<el-table :data="filteredPublic" style="width: 100%">
<el-table-column type="index" width="54"></el-table-column>
<el-table-column label="好友信息" min-width="320">
<template slot-scope="scope">
<div class="entity-cell">
<el-avatar size="small" :src="getHeadImage(scope.row.wxid)" @error="() => true">
<img src="/static/logo.png"/>
</el-avatar>
<div class="entity-copy">
<div class="entity-title">{% raw %}{{ scope.row.name }}{% endraw %}</div>
<div class="entity-subtitle">{% raw %}{{ scope.row.wxid }}{% endraw %}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="160">
<template slot-scope="scope">
<el-button size="mini" type="primary" plain @click="viewPublicDetails(scope.row)">查看详情</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<div class="pagination-container" v-if="activeTab === 'groups' && groupsList.length > 10">
<el-pagination @size-change="handleSizeChange" @current-change="handleCurrentChange" :current-page="currentPage" :page-sizes="[10, 20, 50, 100]" :page-size="pageSize" layout="total, sizes, prev, pager, next, jumper" :total="groupsList.length"></el-pagination>
</div>
<div class="pagination-container" v-if="activeTab === 'personal' && personalList.length > 10">
<el-pagination @size-change="handleSizeChange" @current-change="handleCurrentChange" :current-page="currentPage" :page-sizes="[10, 20, 50, 100]" :page-size="pageSize" layout="total, sizes, prev, pager, next, jumper" :total="personalList.length"></el-pagination>
</div>
<div class="pagination-container" v-if="activeTab === 'official' && officialList.length > 10">
<el-pagination @size-change="handleSizeChange" @current-change="handleCurrentChange" :current-page="currentPage" :page-sizes="[10, 20, 50, 100]" :page-size="pageSize" layout="total, sizes, prev, pager, next, jumper" :total="officialList.length"></el-pagination>
</div>
<div class="pagination-container" v-if="activeTab === 'public' && publicList.length > 10">
<el-pagination @size-change="handleSizeChange" @current-change="handleCurrentChange" :current-page="currentPage" :page-sizes="[10, 20, 50, 100]" :page-size="pageSize" layout="total, sizes, prev, pager, next, jumper" :total="publicList.length"></el-pagination>
</div>
</el-card>
<el-dialog title="群组管理详情" :visible.sync="groupDetailDialogVisible" width="84%" top="4vh">
<el-descriptions :column="1" border>
<el-descriptions-item label="群ID">{% raw %}{{ currentGroup.wxid }}{% endraw %}</el-descriptions-item>
<el-descriptions-item label="群名称">{% raw %}{{ currentGroup.name }}{% endraw %}</el-descriptions-item>
<el-descriptions-item label="机器人状态">
<el-tag :type="currentGroup.robot_status === 'enabled' ? 'success' : 'info'">
{% raw %}{{ currentGroup.robot_status === 'enabled' ? '已启用' : '未启用' }}{% endraw %}
</el-tag>
<el-button
size="mini"
:type="currentGroup.robot_status === 'enabled' ? 'danger' : 'success'"
class="inline-action-button"
@click="toggleCurrentGroupRobotStatus">
{% raw %}{{ currentGroup.robot_status === 'enabled' ? '关闭机器人' : '启用机器人' }}{% endraw %}
</el-button>
</el-descriptions-item>
</el-descriptions>
<div class="group-insight-section" v-loading="groupInsightLoading">
<template v-if="groupInsight">
<div class="detail-hero">
<div>
<div class="detail-health-label">群健康度</div>
<div class="detail-health-value">{% raw %}{{ groupInsight.health_score }}{% endraw %}</div>
<div class="detail-health-note">综合活跃、僵尸成员、消息量与插件调用生成</div>
</div>
<div class="detail-tags">
<el-tag type="success" effect="plain">启用功能 {% raw %}{{ groupInsight.permissions.enabled_count }}{% endraw %}</el-tag>
<el-tag type="info" effect="plain">近30天消息 {% raw %}{{ groupInsight.overview.message_count_30d }}{% endraw %}</el-tag>
<el-tag type="warning" effect="plain">僵尸成员 {% raw %}{{ groupInsight.overview.zombie_member_count }}{% endraw %}</el-tag>
<el-tag type="danger" effect="plain">插件调用 {% raw %}{{ groupInsight.overview.plugin_call_count_30d }}{% endraw %}</el-tag>
</div>
</div>
<el-row :gutter="16" class="overview-grid detail-overview-grid">
<el-col :span="6" v-for="item in groupInsightOverviewCards" :key="item.label">
<el-card class="overview-card" shadow="never">
<div class="overview-label">{% raw %}{{ item.label }}{% endraw %}</div>
<div class="overview-value overview-value--detail">{% raw %}{{ item.value }}{% endraw %}</div>
<div class="overview-note">{% raw %}{{ item.note }}{% endraw %}</div>
</el-card>
</el-col>
</el-row>
<div class="detail-section">
<div class="section-title">
<h3>运营建议</h3>
</div>
<div class="suggestion-list">
<el-alert
v-for="(item, index) in groupInsight.operation_suggestions"
:key="index"
:title="item.title"
:type="item.level"
:description="item.desc"
show-icon
:closable="false"
class="suggestion-item">
</el-alert>
</div>
</div>
<el-row :gutter="16" class="detail-panels">
<el-col :span="12">
<el-card class="detail-card" shadow="never">
<div slot="header" class="detail-card-header">
<span>发言结构</span>
<span class="detail-card-sub">近30天</span>
</div>
<el-table :data="groupInsight.message_summary.type_mix_30d" size="mini">
<el-table-column prop="label" label="类型"></el-table-column>
<el-table-column prop="count" label="数量" width="100"></el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :span="12">
<el-card class="detail-card" shadow="never">
<div slot="header" class="detail-card-header">
<span>启用功能</span>
<span class="detail-card-sub">当前权限</span>
</div>
<div class="feature-chip-list">
<el-tag
v-for="feature in groupInsight.permissions.enabled_features"
:key="feature"
size="small"
type="success"
effect="plain">
{% raw %}{{ feature }}{% endraw %}
</el-tag>
<span v-if="!groupInsight.permissions.enabled_features.length" class="empty-inline">暂无启用功能</span>
</div>
<div class="detail-inline-note">
最近一条消息:
<span v-if="groupInsight.message_summary.last_message">
{% raw %}{{ formatLastMessage(groupInsight.message_summary.last_message) }}{% endraw %}
</span>
<span v-else>暂无消息</span>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" class="detail-panels">
<el-col :span="12">
<el-card class="detail-card" shadow="never">
<div slot="header" class="detail-card-header">
<span>僵尸用户 / 潜水成员</span>
<span class="detail-card-sub">30天未活跃优先</span>
</div>
<el-table :data="groupInsight.inactive_members" size="mini" empty-text="暂无明显沉默成员">
<el-table-column prop="display_name" label="成员"></el-table-column>
<el-table-column prop="inactivity_days" label="未活跃天数" width="110"></el-table-column>
<el-table-column prop="latest_active_time" label="最后活跃时间" min-width="160"></el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :span="12">
<el-card class="detail-card" shadow="never">
<div slot="header" class="detail-card-header">
<span>发言排行</span>
<span class="detail-card-sub">近30天 Top 10</span>
</div>
<el-table :data="groupInsight.speaker_ranking" size="mini" empty-text="暂无发言数据">
<el-table-column prop="display_name" label="成员"></el-table-column>
<el-table-column prop="message_count" label="消息数" width="90"></el-table-column>
<el-table-column prop="last_message_time" label="最后发言" min-width="160"></el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" class="detail-panels">
<el-col :span="12">
<el-card class="detail-card" shadow="never">
<div slot="header" class="detail-card-header">
<span>活跃高峰时段</span>
<span class="detail-card-sub">近30天小时分布</span>
</div>
<div class="peak-hour-list">
<div v-for="item in groupInsight.message_summary.peak_hours_30d" :key="item.label" class="peak-hour-item">
<div class="peak-hour-rank">{% raw %}{{ item.label }}{% endraw %}</div>
<div class="peak-hour-count">{% raw %}{{ item.message_count }}{% endraw %} 条</div>
</div>
<div v-if="!groupInsight.message_summary.peak_hours_30d.length" class="empty-inline">暂无足够数据</div>
</div>
<div class="chart-shell chart-shell--compact chart-shell--mini">
<canvas id="contactsGroupHourlyChart" height="220"></canvas>
</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card class="detail-card" shadow="never">
<div slot="header" class="detail-card-header">
<span>插件调用情况</span>
<span class="detail-card-sub">近30天</span>
</div>
<el-table :data="groupInsight.plugin_stats" size="mini" empty-text="暂无插件调用">
<el-table-column prop="plugin_name" label="插件"></el-table-column>
<el-table-column prop="command" label="命令"></el-table-column>
<el-table-column prop="total_calls" label="调用" width="80"></el-table-column>
<el-table-column prop="last_used_at" label="最近调用" min-width="160"></el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" class="detail-panels">
<el-col :span="24">
<el-card class="detail-card" shadow="never">
<div slot="header" class="detail-card-header">
<span>消息趋势</span>
<span class="detail-card-sub">近14天</span>
</div>
<div class="chart-shell chart-shell--compact">
<canvas id="contactsGroupTrendChart" height="240"></canvas>
</div>
</el-card>
</el-col>
</el-row>
</template>
</div>
<div class="group-permission-section">
<div class="section-title">
<h3>群功能权限</h3>
<div class="section-actions">
<el-button size="mini" type="success" @click="updateAllGroupPermissions('enabled')">一键启用</el-button>
<el-button size="mini" type="danger" @click="updateAllGroupPermissions('disabled')">一键关闭</el-button>
</div>
</div>
<el-table :data="groupPermissions" style="width: 100%" v-loading="groupPermissionsLoading" size="mini">
<el-table-column prop="feature_id" label="功能ID" width="90"></el-table-column>
<el-table-column prop="feature_description" label="功能描述"></el-table-column>
<el-table-column label="当前状态" width="120" align="center">
<template slot-scope="scope">
<el-tag :type="scope.row.status === 'enabled' ? 'success' : 'info'">
{% raw %}{{ scope.row.status === 'enabled' ? '已启用' : '已关闭' }}{% endraw %}
</el-tag>
</template>
</el-table-column>
<el-table-column label="切换" width="120" align="center">
<template slot-scope="scope">
<el-switch
v-model="scope.row.statusBool"
active-color="#10b981"
inactive-color="#cbd5e1"
@change="toggleGroupPermission(scope.row)">
</el-switch>
</template>
</el-table-column>
</el-table>
</div>
<div class="group-members-section">
<div class="section-title">
<h3>群成员列表</h3>
<div class="section-actions">
<el-input placeholder="搜索群成员..." v-model="groupMemberSearchQuery" class="group-search" clearable></el-input>
<el-button size="mini" type="success" @click="refreshCurrentGroupContexts">刷新本群摘要</el-button>
</div>
</div>
<el-table :data="filteredGroupMembers" style="width: 100%" v-loading="groupMembersLoading">
<el-table-column type="index" width="54"></el-table-column>
<el-table-column label="成员" min-width="300">
<template slot-scope="scope">
<div class="entity-cell">
<el-avatar size="small" :src="getHeadImage(scope.row.wxid)" @error="() => true">
<img src="/static/logo.png"/>
</el-avatar>
<div class="entity-copy">
<div class="entity-title">{% raw %}{{ scope.row.name || '-' }}{% endraw %}</div>
<div class="entity-subtitle">{% raw %}{{ scope.row.wxid }}{% endraw %}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="display_name" label="群昵称" width="240"></el-table-column>
<el-table-column label="状态" width="100" align="center">
<template slot-scope="scope">
<el-tag size="mini" :type="scope.row.status === 1 ? 'success' : 'info'">
{% raw %}{{ scope.row.status === 1 ? '在群' : '已退群' }}{% endraw %}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="latest_active_time" label="活跃时间">
<template slot-scope="scope">{% raw %}{{ scope.row.latest_active_time || '-' }}{% endraw %}</template>
</el-table-column>
<el-table-column prop="activity_level" label="互动强度" width="110"></el-table-column>
<el-table-column label="回复建议" min-width="220" show-overflow-tooltip>
<template slot-scope="scope">{% raw %}{{ scope.row.response_style_hint || '-' }}{% endraw %}</template>
</el-table-column>
<el-table-column label="后台摘要" width="130" align="center">
<template slot-scope="scope">
<el-button size="mini" type="primary" plain @click="openMemberContextDialog(scope.row)">查看</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-container" v-if="groupMembersList.length > 10">
<el-pagination @size-change="handleGroupMembersSizeChange" @current-change="handleGroupMembersCurrentChange" :current-page="groupMembersCurrentPage" :page-sizes="[10, 20, 50, 100]" :page-size="groupMembersPageSize" layout="total, sizes, prev, pager, next, jumper" :total="groupMembersList.length"></el-pagination>
</div>
</div>
</el-dialog>
<el-dialog title="用户详情" :visible.sync="userDetailDialogVisible" width="50%">
<div class="detail-avatar-wrap">
<el-avatar size="large" :src="getHeadImage(currentUser.wxid)" @error="() => true" class="detail-avatar">
<img src="/static/logo.png"/>
</el-avatar>
</div>
<el-descriptions :column="1" border>
<el-descriptions-item label="微信ID">{% raw %}{{ currentUser.wxid }}{% endraw %}</el-descriptions-item>
<el-descriptions-item label="昵称">{% raw %}{{ currentUser.name }}{% endraw %}</el-descriptions-item>
</el-descriptions>
</el-dialog>
<el-dialog title="成员交互摘要" :visible.sync="memberContextDialogVisible" width="52%">
<div v-loading="memberContextLoading">
<el-alert
title="该摘要仅供后台调优参考,不会在群内对用户显式展示。"
type="info"
:closable="false"
show-icon>
</el-alert>
<div class="member-context-toolbar">
<div class="member-context-title">
<strong>{% raw %}{{ currentContextMember.name || currentContextMember.wxid || '成员' }}{% endraw %}</strong>
<span>{% raw %}{{ currentContextMember.wxid || '' }}{% endraw %}</span>
</div>
<el-button size="mini" type="success" @click="refreshMemberContext">刷新摘要</el-button>
</div>
<el-empty
v-if="!memberContextLoading && !memberContext"
description="暂无后台摘要,可手动刷新后稍等查看。">
</el-empty>
<el-descriptions :column="1" border v-if="memberContext">
<el-descriptions-item label="互动强度">{% raw %}{{ memberContext.activity_level || '-' }}{% endraw %}</el-descriptions-item>
<el-descriptions-item label="表达特征">{% raw %}{{ memberContext.message_pattern || '-' }}{% endraw %}</el-descriptions-item>
<el-descriptions-item label="互动风格">{% raw %}{{ memberContext.interaction_style || '-' }}{% endraw %}</el-descriptions-item>
<el-descriptions-item label="性格倾向(长期观察)">{% raw %}{{ ((memberContext.meta || {}).temperament_tendency) || '-' }}{% endraw %}</el-descriptions-item>
<el-descriptions-item label="回复建议">{% raw %}{{ memberContext.response_style_hint || '-' }}{% endraw %}</el-descriptions-item>
<el-descriptions-item label="观察跨度">
{% raw %}{{ ((memberContext.meta || {}).observation_days || 0) }}{% endraw %} 天
<el-tag
v-if="(memberContext.meta || {}).stable_ready"
size="mini"
type="success"
class="context-tag">
已进入长期画像
</el-tag>
<el-tag
v-else
size="mini"
type="info"
class="context-tag">
仍在积累
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="长期关注">
<el-tag v-for="item in (memberContext.topics_of_interest || [])" :key="item" size="mini" class="context-tag">{% raw %}{{ item }}{% endraw %}</el-tag>
<span v-if="!(memberContext.topics_of_interest || []).length">-</span>
</el-descriptions-item>
<el-descriptions-item label="身份线索">
<el-tag v-for="item in (((memberContext.meta || {}).identity_traits) || [])" :key="item" size="mini" type="info" class="context-tag">{% raw %}{{ item }}{% endraw %}</el-tag>
<span v-if="!((((memberContext.meta || {}).identity_traits) || []).length)">-</span>
</el-descriptions-item>
<el-descriptions-item label="技能画像">
<el-tag v-for="item in (((memberContext.meta || {}).skill_profile) || [])" :key="item" size="mini" type="success" class="context-tag">{% raw %}{{ item }}{% endraw %}</el-tag>
<span v-if="!((((memberContext.meta || {}).skill_profile) || []).length)">-</span>
</el-descriptions-item>
<el-descriptions-item label="家庭线索">
<el-tag v-for="item in (((memberContext.meta || {}).family_profile) || [])" :key="item" size="mini" type="warning" class="context-tag">{% raw %}{{ item }}{% endraw %}</el-tag>
<span v-if="!((((memberContext.meta || {}).family_profile) || []).length)">-</span>
</el-descriptions-item>
<el-descriptions-item label="人生阶段线索">
<el-tag v-for="item in (((memberContext.meta || {}).life_stage_profile) || [])" :key="item" size="mini" type="warning" class="context-tag">{% raw %}{{ item }}{% endraw %}</el-tag>
<span v-if="!((((memberContext.meta || {}).life_stage_profile) || []).length)">-</span>
</el-descriptions-item>
<el-descriptions-item label="价值偏好">
<el-tag v-for="item in (((memberContext.meta || {}).value_profile) || [])" :key="item" size="mini" type="primary" class="context-tag">{% raw %}{{ item }}{% endraw %}</el-tag>
<span v-if="!((((memberContext.meta || {}).value_profile) || []).length)">-</span>
</el-descriptions-item>
<el-descriptions-item label="长期特征">
<el-tag v-for="item in (((memberContext.meta || {}).stable_traits) || [])" :key="item" size="mini" type="warning" class="context-tag">{% raw %}{{ item }}{% endraw %}</el-tag>
<span v-if="!((((memberContext.meta || {}).stable_traits) || []).length)">-</span>
</el-descriptions-item>
<el-descriptions-item label="习惯模式">
<el-tag v-for="item in (((memberContext.meta || {}).habit_patterns) || [])" :key="item" size="mini" type="info" class="context-tag">{% raw %}{{ item }}{% endraw %}</el-tag>
<span v-if="!((((memberContext.meta || {}).habit_patterns) || []).length)">-</span>
</el-descriptions-item>
<el-descriptions-item label="长期回复偏好">
<el-tag v-for="item in (((memberContext.meta || {}).long_term_reply_preferences) || [])" :key="item" size="mini" type="success" class="context-tag">{% raw %}{{ item }}{% endraw %}</el-tag>
<span v-if="!((((memberContext.meta || {}).long_term_reply_preferences) || []).length)">-</span>
</el-descriptions-item>
<el-descriptions-item label="群中角色">{% raw %}{{ ((memberContext.meta || {}).group_role) || '-' }}{% endraw %}</el-descriptions-item>
<el-descriptions-item label="决策风格">{% raw %}{{ ((memberContext.meta || {}).decision_profile) || '-' }}{% endraw %}</el-descriptions-item>
<el-descriptions-item label="近期话题">
<el-tag v-for="item in (memberContext.recent_focus || [])" :key="item" size="mini" type="success" class="context-tag">{% raw %}{{ item }}{% endraw %}</el-tag>
<span v-if="!(memberContext.recent_focus || []).length">-</span>
</el-descriptions-item>
<el-descriptions-item label="近期状态">
<el-tag v-for="item in (((memberContext.meta || {}).recent_state) || [])" :key="item" size="mini" type="primary" class="context-tag">{% raw %}{{ item }}{% endraw %}</el-tag>
<span v-if="!((((memberContext.meta || {}).recent_state) || []).length)">-</span>
</el-descriptions-item>
<el-descriptions-item label="显著特征">
<el-tag v-for="item in ((memberContext.meta || {}).engagement_traits || [])" :key="item" size="mini" type="warning" class="context-tag">{% raw %}{{ item }}{% endraw %}</el-tag>
<span v-if="!(((memberContext.meta || {}).engagement_traits || []).length)">-</span>
</el-descriptions-item>
<el-descriptions-item label="回复避坑">
<el-tag v-for="item in ((memberContext.meta || {}).reply_taboos || [])" :key="item" size="mini" type="danger" class="context-tag">{% raw %}{{ item }}{% endraw %}</el-tag>
<span v-if="!(((memberContext.meta || {}).reply_taboos || []).length)">-</span>
</el-descriptions-item>
<el-descriptions-item label="摘要说明">{% raw %}{{ memberContext.summary_text || '-' }}{% endraw %}</el-descriptions-item>
<el-descriptions-item label="样本消息">{% raw %}{{ memberContext.source_message_count || 0 }}{% endraw %}</el-descriptions-item>
<el-descriptions-item label="历史样本">{% raw %}{{ ((memberContext.meta || {}).history_message_count) || 0 }}{% endraw %}</el-descriptions-item>
<el-descriptions-item label="摘要层级">
日 {% raw %}{{ ((memberContext.meta || {}).digest_daily_count) || 0 }}{% endraw %}
/ 周 {% raw %}{{ ((memberContext.meta || {}).digest_weekly_count) || 0 }}{% endraw %}
/ 月 {% raw %}{{ ((memberContext.meta || {}).digest_monthly_count) || 0 }}{% endraw %}
</el-descriptions-item>
<el-descriptions-item label="累计刷新">{% raw %}{{ ((memberContext.meta || {}).profile_iterations) || 0 }}{% endraw %}</el-descriptions-item>
<el-descriptions-item label="最后更新">{% raw %}{{ memberContext.last_profiled_at || '-' }}{% endraw %}</el-descriptions-item>
</el-descriptions>
</div>
</el-dialog>
<el-dialog title="公众号详情" :visible.sync="officialDetailDialogVisible" width="50%">
<div class="detail-avatar-wrap">
<el-avatar size="large" :src="getHeadImage(currentOfficial.wxid)" @error="() => true" class="detail-avatar">
<img src="/static/logo.png"/>
</el-avatar>
</div>
<el-descriptions :column="1" border>
<el-descriptions-item label="公众号ID">{% raw %}{{ currentOfficial.wxid }}{% endraw %}</el-descriptions-item>
<el-descriptions-item label="公众号名称">{% raw %}{{ currentOfficial.name }}{% endraw %}</el-descriptions-item>
</el-descriptions>
</el-dialog>
<el-dialog title="公共好友详情" :visible.sync="publicDetailDialogVisible" width="50%">
<div class="detail-avatar-wrap">
<el-avatar size="large" :src="currentPublic.small_head_img_url" @error="() => true" class="detail-avatar">
<img src="/static/logo.png"/>
</el-avatar>
</div>
<el-descriptions :column="1" border>
<el-descriptions-item label="ID">{% raw %}{{ currentPublic.wxid }}{% endraw %}</el-descriptions-item>
<el-descriptions-item label="名称">{% raw %}{{ currentPublic.name }}{% endraw %}</el-descriptions-item>
</el-descriptions>
</el-dialog>
<el-dialog title="聊天" :visible.sync="chatDialogVisible" width="72%" :close-on-click-modal="true">
<div class="chat-container">
{% raw %}
<div class="chat-header-card" v-if="currentChatUser">
<div class="chat-header-main">
<div class="chat-header-title">{{ currentChatUser.name || currentChatUser.wxid }}</div>
<div class="chat-header-subtitle">
<span>{{ chatType === 'group' ? '群聊会话' : '私聊会话' }}</span>
<span>·</span>
<span>{{ currentChatUser.wxid }}</span>
</div>
</div>
<div class="chat-header-actions">
<div class="chat-history-tip">{{ chatHistoryTip }}</div>
<el-button size="mini" :loading="chatLoading" icon="el-icon-refresh" @click="loadRecentMessages">刷新</el-button>
</div>
</div>
{% endraw %}
<div v-loading="chatLoading" class="message-list" ref="messageList">
{% raw %}
<div v-if="!chatMessages.length" class="chat-empty-state">
<i class="el-icon-chat-line-square"></i>
<p>当前没有可展示的归档消息</p>
<span>你仍然可以直接使用下方工具发送文本、图片、语音、视频或链接。</span>
</div>
<div
v-for="(msg, index) in chatMessages"
:key="`${msg.messageId || 'local'}-${index}`"
class="message-item"
:class="{'message-self': msg.isSelf, 'message-system': msg.displayType === 'system'}"
>
<div v-if="msg.displayType !== 'system'" class="message-meta">
<span class="message-sender">{{ msg.senderName || msg.sender || '未知发送者' }}</span>
<span class="message-time">{{ msg.time }}</span>
</div>
<div class="message-content">
<div v-if="msg.displayType === 'text'" class="message-text" v-text="msg.content"></div>
<div v-else-if="msg.displayType === 'image'" class="message-media">
<img v-if="msg.mediaUrl" :src="msg.mediaUrl" class="message-image" />
<div v-else class="message-file-chip">图片消息</div>
<div v-if="msg.content && msg.content !== '[图片]'" class="message-caption" v-text="msg.content"></div>
</div>
<div v-else-if="msg.displayType === 'voice'" class="message-media">
<audio v-if="msg.mediaUrl" controls :src="msg.mediaUrl" class="message-audio"></audio>
<div v-else class="message-file-chip">语音消息</div>
<div class="message-caption" v-text="msg.content || '已通过 iPad 通道发送语音'"></div>
</div>
<div v-else-if="msg.displayType === 'video'" class="message-media">
<video v-if="msg.mediaUrl" controls :src="msg.mediaUrl" class="message-video"></video>
<div v-else class="message-file-chip">视频消息</div>
<div class="message-caption" v-text="msg.content || '已通过 iPad 通道发送视频'"></div>
</div>
<div v-else-if="msg.displayType === 'link'" class="message-link-card">
<a class="message-link-title" :href="msg.linkUrl || '#'" target="_blank" rel="noopener noreferrer">
{{ msg.linkTitle || msg.content || '链接消息' }}
</a>
<div v-if="msg.linkDescription" class="message-link-description" v-text="msg.linkDescription"></div>
<div v-if="msg.linkUrl" class="message-link-url" v-text="msg.linkUrl"></div>
</div>
<div v-else class="message-system-bubble">
<div class="message-system-tags">
<span class="message-system-tag">系统</span>
<span v-if="msg.sysmsgType === 'revokemsg'" class="message-system-tag is-revoke">撤回</span>
</div>
<div class="message-system-text" v-text="msg.content"></div>
</div>
</div>
</div>
{% endraw %}
</div>
<div class="input-area">
<el-input
type="textarea"
:rows="4"
placeholder="输入要发送的文本Enter 发送Shift + Enter 换行"
v-model="messageInput"
@keydown.native="handleChatInputKeydown"
></el-input>
<div class="toolbar">
<el-upload class="upload-demo" action="#" :http-request="uploadImage" :show-file-list="false" accept="image/*">
<el-button size="small" type="primary" plain icon="el-icon-picture-outline">图片</el-button>
</el-upload>
<el-upload class="upload-demo" action="#" :http-request="uploadVoice" :show-file-list="false" accept=".mp3,.wav,audio/*">
<el-button size="small" type="primary" plain icon="el-icon-microphone">语音</el-button>
</el-upload>
<el-upload class="upload-demo" action="#" :http-request="uploadVideo" :show-file-list="false" accept="video/*">
<el-button size="small" type="primary" plain icon="el-icon-video-camera">视频</el-button>
</el-upload>
<el-button size="small" type="primary" plain icon="el-icon-link" @click="showLinkDialog">链接</el-button>
<el-button size="small" type="success" icon="el-icon-position" :loading="chatSending" @click="sendTextMessage">发送文本</el-button>
</div>
</div>
</div>
</el-dialog>
<el-dialog title="发送链接" :visible.sync="linkDialogVisible" width="34%">
<el-form :model="linkForm" label-width="80px">
<el-form-item label="链接"><el-input v-model="linkForm.url" placeholder="请输入链接"></el-input></el-form-item>
<el-form-item label="标题"><el-input v-model="linkForm.title" placeholder="请输入标题"></el-input></el-form-item>
<el-form-item label="描述"><el-input type="textarea" v-model="linkForm.description" placeholder="请输入描述"></el-input></el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="linkDialogVisible = false">取消</el-button>
<el-button type="primary" @click="sendLinkMessage">发送</el-button>
</span>
</el-dialog>
</div>
{% endblock %}
{% block scripts %}
<script>
new Vue({
el: '#app',
mixins: [baseApp],
data() {
return {
activeTab: 'groups',
searchQuery: '',
currentPage: 1,
pageSize: 10,
groupsList: [],
personalList: [],
officialList: [],
publicList: [],
headImages: {},
statistics: { total: 0, groups: 0, personal: 0, official: 0, public: 0 },
groupDetailDialogVisible: false,
userDetailDialogVisible: false,
officialDetailDialogVisible: false,
publicDetailDialogVisible: false,
currentGroup: {}, currentUser: {}, currentOfficial: {}, currentPublic: {},
managedGroupMap: {},
groupPermissions: [],
groupPermissionsLoading: false,
groupInsight: null,
groupInsightLoading: false,
groupMembersList: [], groupMembersCurrentPage: 1, groupMembersPageSize: 10, groupMemberSearchQuery: '', groupMembersLoading: false,
memberContextDialogVisible: false, memberContextLoading: false, memberContext: null, currentContextMember: {},
memberContextEnabled: false,
chatDialogVisible: false, currentChatUser: null, messageInput: '', chatMessages: [], chatLoading: false, chatSending: false,
chatType: 'personal', chatHistoryTip: '最近 20 条消息',
linkDialogVisible: false,
linkForm: { url: '', title: '', description: '' }
};
},
computed: {
filteredGroups() {
const query = this.searchQuery.toLowerCase();
const list = !query ? this.groupsList : this.groupsList.filter(group => group.wxid.toLowerCase().includes(query) || group.name.toLowerCase().includes(query));
return list.slice((this.currentPage - 1) * this.pageSize, this.currentPage * this.pageSize);
},
filteredPersonal() {
const query = this.searchQuery.toLowerCase();
const list = !query ? this.personalList : this.personalList.filter(user => user.wxid.toLowerCase().includes(query) || user.name.toLowerCase().includes(query));
return list.slice((this.currentPage - 1) * this.pageSize, this.currentPage * this.pageSize);
},
filteredOfficial() {
const query = this.searchQuery.toLowerCase();
const list = !query ? this.officialList : this.officialList.filter(official => official.wxid.toLowerCase().includes(query) || official.name.toLowerCase().includes(query));
return list.slice((this.currentPage - 1) * this.pageSize, this.currentPage * this.pageSize);
},
filteredPublic() {
const query = this.searchQuery.toLowerCase();
const list = !query ? this.publicList : this.publicList.filter(item => item.wxid.toLowerCase().includes(query) || item.name.toLowerCase().includes(query));
return list.slice((this.currentPage - 1) * this.pageSize, this.currentPage * this.pageSize);
},
filteredGroupMembers() {
const query = this.groupMemberSearchQuery.toLowerCase();
const list = !query ? this.groupMembersList : this.groupMembersList.filter(member => member.wxid.toLowerCase().includes(query) || (member.name && member.name.toLowerCase().includes(query)));
return list.slice((this.groupMembersCurrentPage - 1) * this.groupMembersPageSize, this.groupMembersCurrentPage * this.groupMembersPageSize);
},
groupInsightOverviewCards() {
if (!this.groupInsight) return [];
const overview = this.groupInsight.overview || {};
return [
{ label: '群成员数', value: overview.member_count || 0, note: '当前群成员规模' },
{ label: '7天活跃成员', value: overview.active_member_count_7d || 0, note: '近7天至少发言一次' },
{ label: '僵尸成员数', value: overview.zombie_member_count || 0, note: '30天未发言或从未发言' },
{ label: '从未发言', value: overview.never_spoken_count || 0, note: '未记录到活跃痕迹' },
{ label: '24h消息量', value: overview.message_count_24h || 0, note: '用于判断短期热度' },
{ label: '30天消息量', value: overview.message_count_30d || 0, note: '用于判断持续热度' },
{ label: '插件调用', value: overview.plugin_call_count_30d || 0, note: '近30天插件总触发次数' },
{ label: '插件种类', value: overview.plugin_count_30d || 0, note: '本群真实使用到的插件数' }
];
}
},
mounted() {
this.currentView = '10';
this.loadContactsData();
},
methods: {
loadContactsData() {
axios.get('/contacts/api/statistics').then(response => { if (response.data.success) this.statistics = response.data.data; }).catch(error => { console.error('加载联系人统计数据失败:', error); this.$message.error('加载联系人统计数据失败'); });
Promise.all([
axios.get('/robot/api/groups').catch(error => {
console.error('加载群组管理状态失败:', error);
return { data: { success: false, data: [] } };
}),
axios.get('/contacts/api/groups')
]).then(([robotResponse, contactsResponse]) => {
const managedList = robotResponse.data.success ? (robotResponse.data.data || []) : [];
this.managedGroupMap = managedList.reduce((acc, item) => {
acc[item.group_id] = item;
return acc;
}, {});
if (contactsResponse.data.success) {
const groups = contactsResponse.data.data.groups;
this.groupsList = Object.entries(groups).map(([wxid, name]) => {
const managed = this.managedGroupMap[wxid] || {};
return {
wxid,
name,
robot_status: managed.robot_status || 'disabled'
};
});
} else {
this.$message.error('加载群组数据失败');
}
}).catch(error => {
console.error('加载群组数据失败:', error);
this.$message.error('加载群组数据失败');
});
axios.get('/contacts/api/personal').then(response => { if (response.data.success) { const personal = response.data.data.personal; this.personalList = Object.entries(personal).map(([wxid, name]) => ({ wxid, name })); } }).catch(error => { console.error('加载个人联系人数据失败:', error); this.$message.error('加载个人联系人数据失败'); });
axios.get('/contacts/api/official').then(response => { if (response.data.success) { const official = response.data.data.official; this.officialList = Object.entries(official).map(([wxid, name]) => ({ wxid, name })); } }).catch(error => { console.error('加载公众号数据失败:', error); this.$message.error('加载公众号数据失败'); });
axios.get('/contacts/api/public').then(response => { if (response.data.success) { const publicFriends = response.data.data.public; this.publicList = Object.entries(publicFriends).map(([wxid, name]) => ({ wxid, name })); } }).catch(error => { console.error('加载公共好友数据失败:', error); this.$message.error('加载公共好友数据失败'); });
axios.get('/contacts/api/head_images').then(response => { if (response.data.success) this.headImages = response.data.data.head_images; }).catch(error => { console.error('加载联系人头像数据失败:', error); this.$message.error('加载联系人头像数据失败'); });
},
updateContacts() {
this.$message.info('正在更新通讯录...');
axios.post('/contacts/api/update').then(res => { if (res.data.success) { this.$message.success('通讯录更新成功!'); this.refreshContacts(); } else { this.$message.error(res.data.message || '通讯录更新失败'); } }).catch(() => { this.$message.error('通讯录更新请求失败'); });
},
refreshContacts() { this.loadContactsData(); this.$message.success('联系人数据已刷新'); },
handleTabClick() { this.currentPage = 1; },
getHeadImage(wxid) { return this.headImages[wxid] || ''; },
handleSizeChange(size) { this.pageSize = size; },
handleCurrentChange(page) { this.currentPage = page; },
viewGroupDetails(group) {
this.currentGroup = { ...group };
this.groupDetailDialogVisible = true;
this.loadGroupMembers(group.wxid);
this.loadGroupPermissions(group.wxid);
this.loadGroupInsights(group.wxid);
},
viewUserDetails(user) { this.currentUser = user; this.userDetailDialogVisible = true; },
viewOfficialDetails(official) { this.currentOfficial = official; this.officialDetailDialogVisible = true; },
viewPublicDetails(publicFriend) { this.currentPublic = publicFriend; this.publicDetailDialogVisible = true; },
loadGroupPermissions(groupId) {
this.groupPermissionsLoading = true;
this.groupPermissions = [];
axios.get(`/robot/api/group/${groupId}/permissions`)
.then(response => {
if (response.data.success) {
const permissions = response.data.data || [];
this.groupPermissions = permissions.map(item => ({
...item,
statusBool: item.status === 'enabled'
}));
} else {
this.$message.error('加载群权限失败');
}
})
.catch(error => {
console.error('加载群权限失败:', error);
this.$message.error('加载群权限失败');
})
.finally(() => { this.groupPermissionsLoading = false; });
},
loadGroupInsights(groupId) {
this.groupInsightLoading = true;
this.groupInsight = null;
axios.get(`/robot/api/group/${groupId}/detail`)
.then(response => {
if (response.data.success) {
this.groupInsight = response.data.data || null;
this.$nextTick(() => {
this.renderContactsGroupTrendChart();
this.renderContactsGroupHourlyChart();
});
} else {
this.$message.error('加载群诊断失败');
}
})
.catch(error => {
console.error('加载群诊断失败:', error);
this.$message.error('加载群诊断失败');
})
.finally(() => { this.groupInsightLoading = false; });
},
toggleGroupPermission(permission) {
const newStatus = permission.statusBool ? 'enabled' : 'disabled';
axios.post(`/robot/api/group/${this.currentGroup.wxid}/permissions`, {
feature_id: permission.feature_id,
status: newStatus
}).then(response => {
if (response.data.success) {
permission.status = newStatus;
if (permission.feature_name === 'ROBOT') {
this.currentGroup.robot_status = newStatus;
this.syncGroupRobotStatus(this.currentGroup.wxid, newStatus);
}
this.loadGroupInsights(this.currentGroup.wxid);
this.$message.success('权限更新成功');
} else {
permission.statusBool = !permission.statusBool;
this.$message.error('权限更新失败');
}
}).catch(error => {
permission.statusBool = !permission.statusBool;
console.error('权限更新失败:', error);
this.$message.error('权限更新失败');
});
},
updateAllGroupPermissions(status) {
this.groupPermissionsLoading = true;
const tasks = this.groupPermissions.map(item => axios.post(`/robot/api/group/${this.currentGroup.wxid}/permissions`, {
feature_id: item.feature_id,
status: status
}));
Promise.all(tasks).then(() => {
this.groupPermissions.forEach(item => {
item.status = status;
item.statusBool = status === 'enabled';
});
const robotPermission = this.groupPermissions.find(item => item.feature_name === 'ROBOT');
if (robotPermission) {
this.currentGroup.robot_status = status;
this.syncGroupRobotStatus(this.currentGroup.wxid, status);
}
this.loadGroupInsights(this.currentGroup.wxid);
this.$message.success('群权限批量更新成功');
}).catch(error => {
console.error('群权限批量更新失败:', error);
this.$message.error('群权限批量更新失败');
}).finally(() => { this.groupPermissionsLoading = false; });
},
toggleCurrentGroupRobotStatus() {
const newStatus = this.currentGroup.robot_status === 'enabled' ? 'disabled' : 'enabled';
axios.post(`/robot/api/group/${this.currentGroup.wxid}/status`, {
status: newStatus
}).then(response => {
if (response.data.success) {
this.currentGroup.robot_status = newStatus;
this.syncGroupRobotStatus(this.currentGroup.wxid, newStatus);
this.loadGroupPermissions(this.currentGroup.wxid);
this.loadGroupInsights(this.currentGroup.wxid);
this.$message.success('机器人状态更新成功');
} else {
this.$message.error('机器人状态更新失败');
}
}).catch(error => {
console.error('机器人状态更新失败:', error);
this.$message.error('机器人状态更新失败');
});
},
syncGroupRobotStatus(groupId, status) {
this.groupsList = this.groupsList.map(item => item.wxid === groupId ? { ...item, robot_status: status } : item);
if (this.managedGroupMap[groupId]) {
this.managedGroupMap[groupId].robot_status = status;
} else {
this.managedGroupMap[groupId] = { group_id: groupId, robot_status: status };
}
},
loadGroupMembers(roomid) {
this.groupMembersLoading = true; this.groupMembersList = []; this.groupMembersCurrentPage = 1;
axios.get(`/contacts/api/group_members/${roomid}`).then(response => {
if (response.data.success) {
const members = response.data.data.members;
this.memberContextEnabled = !!response.data.data.member_context_enabled;
this.groupMembersList = members.map(item => ({ wxid: item.wxid, name: item.nick_name, display_name: item.display_name, status: item.status, latest_active_time: item.latest_active_time, small_head_img_url: item.small_head_img_url, activity_level: item.activity_level, response_style_hint: item.response_style_hint, summary_text: item.summary_text, last_profiled_at: item.last_profiled_at }));
} else { this.$message.error('获取群成员失败'); }
}).catch(error => { console.error('加载群成员数据失败:', error); this.$message.error('加载群成员数据失败'); }).finally(() => { this.groupMembersLoading = false; });
},
handleGroupMembersSizeChange(size) { this.groupMembersPageSize = size; },
handleGroupMembersCurrentChange(page) { this.groupMembersCurrentPage = page; },
refreshCurrentGroupContexts() {
if (!this.currentGroup.wxid) return;
if (!this.memberContextEnabled) {
this.$message.warning('该群未启用成员交互摘要功能');
return;
}
this.groupMembersLoading = true;
axios.post('/contacts/api/group_member_context/refresh', { roomid: this.currentGroup.wxid })
.then(response => {
if (response.data.success) {
this.$message.success(response.data.message || '本群成员交互摘要刷新任务已提交');
setTimeout(() => this.loadGroupMembers(this.currentGroup.wxid), 2500);
} else {
this.$message.error('刷新本群成员交互摘要失败');
}
})
.catch(error => {
console.error('刷新本群成员交互摘要失败:', error);
this.$message.error('刷新本群成员交互摘要失败');
})
.finally(() => { this.groupMembersLoading = false; });
},
openMemberContextDialog(member) {
if (!this.memberContextEnabled) {
this.$message.warning('该群未启用成员交互摘要功能');
return;
}
this.currentContextMember = member;
this.memberContextDialogVisible = true;
this.loadMemberContext();
},
loadMemberContext() {
if (!this.currentGroup.wxid || !this.currentContextMember.wxid) return;
this.memberContextLoading = true;
this.memberContext = null;
axios.get(`/contacts/api/group_member_context/${this.currentGroup.wxid}/${this.currentContextMember.wxid}`)
.then(response => {
if (response.data.success) {
this.memberContext = response.data.data.context;
} else {
this.$message.error('加载成员交互摘要失败');
}
})
.catch(error => {
console.error('加载成员交互摘要失败:', error);
this.$message.error('加载成员交互摘要失败');
})
.finally(() => { this.memberContextLoading = false; });
},
refreshMemberContext() {
if (!this.currentGroup.wxid || !this.currentContextMember.wxid) return;
this.memberContextLoading = true;
axios.post('/contacts/api/group_member_context/refresh', {
roomid: this.currentGroup.wxid,
wxid: this.currentContextMember.wxid
}).then(response => {
if (response.data.success) {
this.$message.success(response.data.message || '成员交互摘要刷新任务已提交');
setTimeout(() => this.loadMemberContext(), 2500);
} else {
this.$message.error('刷新成员交互摘要失败');
}
}).catch(error => {
console.error('刷新成员交互摘要失败:', error);
this.$message.error('刷新成员交互摘要失败');
}).finally(() => { this.memberContextLoading = false; });
},
renderContactsGroupTrendChart() {
if (!this.groupInsight || !this.groupInsight.message_summary) return;
const canvas = document.getElementById('contactsGroupTrendChart');
if (!canvas) return;
const trendData = this.groupInsight.message_summary.trend_14d || { dates: [], counts: [] };
const ctx = canvas.getContext('2d');
if (this.charts && this.charts.contactsGroupTrendChart) {
this.charts.contactsGroupTrendChart.destroy();
}
if (!this.charts) {
this.charts = {};
}
this.charts.contactsGroupTrendChart = new Chart(ctx, {
type: 'bar',
data: {
labels: trendData.dates,
datasets: [{
label: '消息数量',
data: trendData.counts,
backgroundColor: 'rgba(249, 115, 22, 0.72)',
borderRadius: 8
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: { beginAtZero: true, grid: { color: 'rgba(148,163,184,0.12)' } },
x: { grid: { display: false } }
},
plugins: { legend: { display: false } }
}
});
},
renderContactsGroupHourlyChart() {
if (!this.groupInsight || !this.groupInsight.message_summary) return;
const canvas = document.getElementById('contactsGroupHourlyChart');
if (!canvas) return;
const raw = this.groupInsight.message_summary.hourly_distribution_30d || [];
const hourMap = {};
raw.forEach(item => {
hourMap[item.hour] = item.message_count;
});
const labels = [];
const counts = [];
for (let i = 0; i < 24; i++) {
labels.push(`${String(i).padStart(2, '0')}:00`);
counts.push(hourMap[i] || 0);
}
const ctx = canvas.getContext('2d');
if (this.charts && this.charts.contactsGroupHourlyChart) {
this.charts.contactsGroupHourlyChart.destroy();
}
if (!this.charts) {
this.charts = {};
}
this.charts.contactsGroupHourlyChart = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [{
label: '小时消息量',
data: counts,
fill: true,
backgroundColor: 'rgba(14, 165, 233, 0.12)',
borderColor: 'rgba(14, 165, 233, 1)',
tension: 0.32,
borderWidth: 2,
pointRadius: 1.5
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: { beginAtZero: true, grid: { color: 'rgba(148,163,184,0.12)' } },
x: { grid: { display: false } }
},
plugins: { legend: { display: false } }
}
});
},
formatLastMessage(lastMessage) {
if (!lastMessage) return '暂无消息';
const sender = lastMessage.sender || '未知成员';
const time = lastMessage.timestamp || '';
const content = lastMessage.content || '[非文本消息]';
return `${sender} · ${time} · ${content}`;
},
openChatDialog(user) {
this.currentChatUser = user;
this.chatType = user && user.wxid && user.wxid.endsWith('@chatroom') ? 'group' : 'personal';
this.chatDialogVisible = true;
this.messageInput = '';
this.chatMessages = [];
this.chatHistoryTip = this.chatType === 'group' ? '最近 20 条群消息' : '最近 20 条已归档消息(私聊历史可能不完整)';
this.loadRecentMessages();
},
async loadRecentMessages() {
if (!this.currentChatUser || !this.currentChatUser.wxid) return;
this.chatLoading = true;
try {
const response = await axios.get('/contacts/api/recent_messages', {
params: {
wxid: this.currentChatUser.wxid,
chat_type: this.chatType,
limit: 20
}
});
if (response.data.success) {
const data = response.data.data || {};
this.chatHistoryTip = data.history_tip || this.chatHistoryTip;
const messages = Array.isArray(data.messages) ? data.messages : [];
this.chatMessages = messages.map(item => this.normalizeChatMessage(item));
this.$nextTick(() => { this.scrollToBottom(); });
} else {
this.$message.error(response.data.message || '加载聊天记录失败');
}
} catch (error) {
console.error('加载聊天记录失败:', error);
this.$message.error('加载聊天记录失败');
} finally {
this.chatLoading = false;
}
},
normalizeChatMessage(item) {
const linkPayload = item.link_payload || {};
return {
messageId: item.message_id || '',
sender: item.sender || '',
senderName: item.sender_name || '',
displayType: item.display_type || 'text',
content: item.display_content || item.content || '',
rawContent: item.content || '',
time: item.timestamp || '',
isSelf: !!item.is_self,
sysmsgType: item.sysmsg_type || '',
mediaUrl: this.getChatMediaUrl(item.media_url || item.image_path || item.attachment_url || item.message_thumb || ''),
linkTitle: linkPayload.title || '',
linkDescription: linkPayload.description || '',
linkUrl: linkPayload.url || ''
};
},
getChatMediaUrl(url) {
if (!url) return '';
if (url.startsWith('blob:') || url.startsWith('data:') || url.startsWith('/static/') || url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
if (url.startsWith('static/')) {
return `/${url}`;
}
if (url.includes('static/images') || url.includes('static\\images')) {
return `/${url.replace(/\\/g, '/').replace(/^\/+/, '')}`;
}
return `/api/messages/media_proxy?url=${encodeURIComponent(url)}`;
},
createLocalChatMessage(payload = {}) {
return {
messageId: `local-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
sender: '',
senderName: '我',
displayType: payload.displayType || 'text',
content: payload.content || '',
rawContent: payload.rawContent || payload.content || '',
time: new Date().toLocaleString(),
isSelf: true,
mediaUrl: payload.mediaUrl || '',
linkTitle: payload.linkTitle || '',
linkDescription: payload.linkDescription || '',
linkUrl: payload.linkUrl || ''
};
},
appendLocalChatMessage(payload) {
const localMessage = this.createLocalChatMessage(payload);
this.chatMessages.push(localMessage);
this.$nextTick(() => { this.scrollToBottom(); });
return localMessage;
},
async sendChatPayload(payload, successMessage, localMessageId = '') {
this.chatSending = true;
try {
const response = await axios.post('/contacts/api/send_message', payload);
if (!response.data.success) {
throw new Error(response.data.message || '发送失败');
}
if (successMessage) {
this.$message.success(successMessage);
}
setTimeout(() => { this.loadRecentMessages(); }, 800);
return true;
} catch (error) {
console.error('发送消息失败:', error);
if (localMessageId) {
this.chatMessages = this.chatMessages.filter(item => item.messageId !== localMessageId);
}
this.$message.error(error.message || '发送消息失败');
return false;
} finally {
this.chatSending = false;
}
},
async sendTextMessage() {
if (!this.currentChatUser || !this.currentChatUser.wxid) return;
const text = this.messageInput.trim();
if (!text) return;
const localMessage = this.appendLocalChatMessage({ displayType: 'text', content: text });
this.messageInput = '';
await this.sendChatPayload({
wxid: this.currentChatUser.wxid,
type: 'text',
content: text
}, '文本消息已提交到 iPad 通道', localMessage.messageId);
},
async uploadImage(options) {
if (!this.currentChatUser || !this.currentChatUser.wxid) return;
const formData = new FormData();
formData.append('file', options.file);
formData.append('wxid', this.currentChatUser.wxid);
formData.append('type', 'image');
const localMessage = this.appendLocalChatMessage({
displayType: 'image',
content: options.file.name || '[图片]',
mediaUrl: URL.createObjectURL(options.file)
});
await this.sendChatPayload(formData, '图片消息已提交到 iPad 通道', localMessage.messageId);
},
async uploadVoice(options) {
if (!this.currentChatUser || !this.currentChatUser.wxid) return;
const formData = new FormData();
formData.append('file', options.file);
formData.append('wxid', this.currentChatUser.wxid);
formData.append('type', 'voice');
const localMessage = this.appendLocalChatMessage({
displayType: 'voice',
content: options.file.name || '[语音]',
mediaUrl: URL.createObjectURL(options.file)
});
await this.sendChatPayload(formData, '语音消息已提交到 iPad 通道', localMessage.messageId);
},
async uploadVideo(options) {
if (!this.currentChatUser || !this.currentChatUser.wxid) return;
const formData = new FormData();
formData.append('file', options.file);
formData.append('wxid', this.currentChatUser.wxid);
formData.append('type', 'video');
const localMessage = this.appendLocalChatMessage({
displayType: 'video',
content: options.file.name || '[视频]',
mediaUrl: URL.createObjectURL(options.file)
});
await this.sendChatPayload(formData, '视频消息已提交到 iPad 通道', localMessage.messageId);
},
showLinkDialog() { this.linkForm = { url: '', title: '', description: '' }; this.linkDialogVisible = true; },
async sendLinkMessage() {
if (!this.currentChatUser || !this.currentChatUser.wxid) return;
if (!this.linkForm.url) { this.$message.warning('请输入链接'); return; }
const localMessage = this.appendLocalChatMessage({
displayType: 'link',
content: this.linkForm.title || this.linkForm.url,
linkTitle: this.linkForm.title || this.linkForm.url,
linkDescription: this.linkForm.description || '',
linkUrl: this.linkForm.url
});
const success = await this.sendChatPayload({
wxid: this.currentChatUser.wxid,
type: 'link',
content: this.linkForm
}, '链接消息已提交到 iPad 通道', localMessage.messageId);
if (success) {
this.linkDialogVisible = false;
}
},
handleChatInputKeydown(event) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
this.sendTextMessage();
}
},
scrollToBottom() { const messageList = this.$refs.messageList; if (messageList) messageList.scrollTop = messageList.scrollHeight; }
}
});
</script>
<style>
.page-shell { display: flex; flex-direction: column; gap: 16px; }
.page-hero {
display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; padding: 24px 26px; border-radius: 24px;
background: linear-gradient(135deg, rgba(79,70,229,0.10), rgba(59,130,246,0.08), rgba(255,255,255,0.9));
border: 1px solid rgba(148, 163, 184, 0.16); box-shadow: 0 18px 40px rgba(15, 23, 42, 0.06);
}
.page-hero-actions { display: flex; align-items: center; gap: 12px; }
.hero-search { width: 260px; }
.page-eyebrow { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: #6366f1; font-weight: 700; margin-bottom: 8px; }
.page-hero-copy h1 { font-size: 30px; line-height: 1.1; margin-bottom: 10px; color: #0f172a; }
.page-hero-copy p { color: #64748b; font-size: 14px; }
.overview-grid .el-col { margin-bottom: 16px; }
.overview-card { min-height: 112px; }
.overview-card--primary { background: linear-gradient(180deg, rgba(79,70,229,0.10), rgba(255,255,255,0.94)) !important; }
.overview-card--soft { background: linear-gradient(180deg, rgba(59,130,246,0.08), rgba(255,255,255,0.94)) !important; }
.overview-label { font-size: 13px; color: #64748b; margin-bottom: 14px; }
.overview-value { font-size: 30px; font-weight: 700; color: #0f172a; margin-bottom: 10px; }
.overview-note { font-size: 12px; color: #94a3b8; }
.workspace-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.workspace-header h3 { font-size: 18px; margin-bottom: 4px; }
.workspace-header p { font-size: 13px; color: #64748b; }
.entity-cell { display: flex; align-items: center; gap: 12px; }
.entity-avatar {
width: 32px; height: 32px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
background: rgba(79,70,229,0.10); color: #4f46e5; font-size: 14px; flex-shrink: 0;
}
.entity-avatar--group { background: rgba(16,185,129,0.12); color: #10b981; }
.entity-title { font-size: 14px; font-weight: 600; color: #0f172a; }
.entity-subtitle { margin-top: 4px; font-size: 12px; color: #94a3b8; }
.action-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.pagination-container { margin-top: 20px; text-align: right; }
.group-insight-section { margin-top: 20px; }
.group-permission-section { margin-top: 20px; }
.group-members-section { margin-top: 20px; }
.section-title {
margin: 20px 0 15px 0; border-bottom: 1px solid rgba(148,163,184,0.12); padding-bottom: 10px;
display: flex; justify-content: space-between; align-items: center;
}
.section-actions { display: flex; align-items: center; gap: 10px; }
.inline-action-button { margin-left: 10px; }
.section-title h3 { margin: 0; font-size: 18px; color: #0f172a; }
.group-search { width: 220px; }
.detail-avatar-wrap { text-align: center; margin-bottom: 20px; }
.detail-avatar { width: 100px; height: 100px; }
.detail-hero {
display: flex; align-items: flex-start; justify-content: space-between; gap: 20px;
padding: 18px 20px; margin-bottom: 16px; border-radius: 22px;
background: linear-gradient(135deg, rgba(249,115,22,0.10), rgba(14,165,233,0.08), rgba(255,255,255,0.96));
border: 1px solid rgba(148,163,184,0.14);
}
.detail-health-label { font-size: 13px; color: #9a3412; margin-bottom: 8px; }
.detail-health-value { font-size: 38px; line-height: 1; font-weight: 700; color: #111827; margin-bottom: 10px; }
.detail-health-note { font-size: 13px; color: #64748b; }
.detail-tags { display: flex; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
.detail-overview-grid { margin-bottom: 16px; }
.overview-value--detail { font-size: 24px; }
.detail-section { margin-bottom: 16px; }
.diagnosis-grid {
display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px;
}
.detail-panels .el-col { margin-bottom: 16px; }
.detail-card { border-radius: 18px; }
.detail-card-header {
display: flex; align-items: center; justify-content: space-between; font-weight: 600; color: #0f172a;
}
.detail-card-sub { font-size: 12px; color: #94a3b8; font-weight: 500; }
.feature-chip-list { display: flex; gap: 8px; flex-wrap: wrap; min-height: 60px; align-items: flex-start; }
.empty-inline, .detail-inline-note { font-size: 12px; color: #64748b; }
.detail-inline-note { margin-top: 12px; line-height: 1.6; }
.suggestion-list { display: flex; flex-direction: column; gap: 12px; }
.suggestion-item { border-radius: 14px; }
.peak-hour-list { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; }
.peak-hour-item {
min-width: 132px; padding: 12px 14px; border-radius: 14px;
background: linear-gradient(180deg, rgba(14,165,233,0.08), rgba(255,255,255,0.96));
border: 1px solid rgba(148,163,184,0.12);
}
.peak-hour-rank { font-size: 13px; color: #0f172a; font-weight: 600; margin-bottom: 6px; }
.peak-hour-count { font-size: 12px; color: #64748b; }
.chart-shell {
padding: 16px; border-radius: 18px;
background: linear-gradient(180deg, rgba(248,250,252,0.78), rgba(255,255,255,0.96));
border: 1px solid rgba(148,163,184,0.12);
}
.chart-shell--compact { min-height: 260px; }
.chart-shell--mini { min-height: 220px; }
.member-context-toolbar {
display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 16px 0;
}
.member-context-title { display: flex; flex-direction: column; gap: 4px; color: #475569; }
.context-tag { margin-right: 8px; margin-bottom: 8px; }
.chat-container { display: flex; flex-direction: column; gap: 14px; min-height: 620px; }
.chat-header-card {
display: flex; align-items: center; justify-content: space-between; gap: 16px;
padding: 18px 20px; border-radius: 18px;
background: linear-gradient(135deg, rgba(14,165,233,0.10), rgba(16,185,129,0.08), rgba(255,255,255,0.96));
border: 1px solid rgba(148,163,184,0.14);
}
.chat-header-main { min-width: 0; }
.chat-header-title { font-size: 20px; font-weight: 700; color: #0f172a; }
.chat-header-subtitle {
margin-top: 6px; font-size: 13px; color: #64748b;
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
.chat-header-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
.chat-history-tip {
padding: 8px 12px; border-radius: 999px; font-size: 12px; color: #0f766e;
background: rgba(20, 184, 166, 0.10); border: 1px solid rgba(20, 184, 166, 0.18);
}
.message-list {
flex: 1; overflow-y: auto; padding: 20px; background: rgba(248,250,252,0.82); border: 1px solid rgba(148,163,184,0.12);
border-radius: 18px;
}
.chat-empty-state {
min-height: 280px; display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #94a3b8; text-align: center; gap: 10px;
}
.chat-empty-state i { font-size: 34px; color: #38bdf8; }
.chat-empty-state p { font-size: 16px; color: #334155; margin: 0; }
.chat-empty-state span { max-width: 380px; line-height: 1.6; }
.message-item { margin-bottom: 18px; display: flex; flex-direction: column; gap: 6px; }
.message-self { align-items: flex-end; }
.message-system { align-items: center; }
.message-meta { display: flex; align-items: center; gap: 8px; color: #94a3b8; font-size: 12px; }
.message-sender { color: #475569; font-weight: 600; }
.message-content {
max-width: 75%; padding: 12px 14px; border-radius: 14px; background: rgba(255,255,255,0.92); color: #0f172a;
border: 1px solid rgba(148,163,184,0.12); box-shadow: 0 8px 20px rgba(15, 23, 42, 0.05);
}
.message-self .message-content { background: linear-gradient(135deg, #4f46e5, #6366f1); color: #ffffff; }
.message-system .message-content {
max-width: 90%; background: rgba(241,245,249,0.92); color: #475569; border-style: dashed;
text-align: center; box-shadow: none;
}
.message-system-bubble { display: flex; flex-direction: column; align-items: center; gap: 8px; }
.message-system-tags { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: center; }
.message-system-tag {
display: inline-flex; align-items: center; justify-content: center; min-height: 24px; padding: 0 10px;
border-radius: 999px; font-size: 12px; font-weight: 700; color: #475569;
background: rgba(255,255,255,0.76); border: 1px solid rgba(148,163,184,0.18);
}
.message-system-tag.is-revoke { color: #9f1239; background: rgba(255,241,242,0.92); border-color: rgba(244,114,182,0.2); }
.message-text, .message-system-text { white-space: pre-wrap; word-break: break-word; line-height: 1.7; }
.message-media { display: flex; flex-direction: column; gap: 10px; }
.message-image, .message-video {
max-width: 260px; max-height: 240px; border-radius: 14px;
background: rgba(15,23,42,0.06); object-fit: cover;
}
.message-audio { width: 260px; max-width: 100%; }
.message-caption { font-size: 12px; line-height: 1.6; opacity: 0.88; }
.message-file-chip {
display: inline-flex; align-items: center; width: fit-content; padding: 8px 12px;
border-radius: 999px; background: rgba(148,163,184,0.12); font-size: 12px;
}
.message-link-card { display: flex; flex-direction: column; gap: 8px; }
.message-link-title { font-size: 14px; font-weight: 700; color: inherit; text-decoration: none; }
.message-link-description { font-size: 12px; line-height: 1.6; opacity: 0.92; }
.message-link-url { font-size: 12px; opacity: 0.72; word-break: break-all; }
.message-self .message-meta { justify-content: flex-end; }
.message-self .message-sender, .message-self .message-time, .message-self .message-link-title { color: #ffffff; }
.input-area { padding: 20px 0 0; }
.toolbar { margin-top: 12px; display: flex; gap: 10px; flex-wrap: wrap; }
@media (max-width: 1200px) {
.diagnosis-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 768px) {
.detail-hero, .page-hero, .workspace-header, .section-title { flex-direction: column; align-items: stretch; }
.page-hero-actions, .detail-tags { justify-content: flex-start; }
.hero-search, .group-search { width: 100%; }
.diagnosis-grid { grid-template-columns: 1fr; }
.chat-header-card { flex-direction: column; align-items: flex-start; }
.chat-header-actions { justify-content: flex-start; }
.message-content { max-width: 92%; }
.message-image, .message-video, .message-audio { max-width: 100%; width: 100%; }
}
</style>
{% endblock %}