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

1223 lines
76 KiB
HTML

{% 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="60%" :close-on-click-modal="true">
<div class="chat-container">
<div class="message-list" ref="messageList">
{% raw %}
<div v-for="(msg, index) in chatMessages" :key="index" class="message-item" :class="{'message-self': msg.isSelf}">
<div class="message-content">
<div v-if="msg.type === 'text'" v-text="msg.content"></div>
<div v-else-if="msg.type === 'image'"><img :src="msg.content" style="max-width: 200px; max-height: 200px;"></div>
<div v-else-if="msg.type === 'voice'"><audio controls :src="msg.content"></audio></div>
<div v-else-if="msg.type === 'video'"><video controls :src="msg.content" style="max-width: 200px;"></video></div>
<div v-else-if="msg.type === 'link'"><a :href="msg.content.url" target="_blank" v-text="msg.content.title"></a><p v-text="msg.content.description"></p></div>
</div>
<div class="message-time" v-text="msg.time"></div>
</div>
{% endraw %}
</div>
<div class="input-area">
<el-input type="textarea" :rows="3" placeholder="请输入消息..." v-model="messageInput" @keyup.enter.native="sendTextMessage"></el-input>
<div class="toolbar">
<el-upload class="upload-demo" action="#" :http-request="uploadImage" :show-file-list="false"><el-button size="small" type="primary">图片</el-button></el-upload>
<el-upload class="upload-demo" action="#" :http-request="uploadVoice" :show-file-list="false"><el-button size="small" type="primary">语音</el-button></el-upload>
<el-upload class="upload-demo" action="#" :http-request="uploadVideo" :show-file-list="false"><el-button size="small" type="primary">视频</el-button></el-upload>
<el-button size="small" type="primary" @click="showLinkDialog">链接</el-button>
<el-button size="small" type="success" @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: [],
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.chatDialogVisible = true; this.chatMessages = []; },
async sendTextMessage() {
if (!this.messageInput.trim()) return;
try {
const response = await axios.post('/contacts/api/send_message', { wxid: this.currentChatUser.wxid, type: 'text', content: this.messageInput });
if (response.data.success) { this.chatMessages.push({ type: 'text', content: this.messageInput, isSelf: true, time: new Date().toLocaleTimeString() }); this.messageInput = ''; this.$nextTick(() => { this.scrollToBottom(); }); }
} catch (error) { this.$message.error('发送消息失败'); }
},
async uploadImage(options) {
const formData = new FormData(); formData.append('file', options.file); formData.append('wxid', this.currentChatUser.wxid); formData.append('type', 'image');
try { const response = await axios.post('/contacts/api/send_message', formData); if (response.data.success) { this.chatMessages.push({ type: 'image', content: response.data.url, isSelf: true, time: new Date().toLocaleTimeString() }); this.$nextTick(() => { this.scrollToBottom(); }); } } catch (error) { this.$message.error('发送图片失败'); }
},
async uploadVoice(options) {
const formData = new FormData(); formData.append('file', options.file); formData.append('wxid', this.currentChatUser.wxid); formData.append('type', 'voice');
try { const response = await axios.post('/contacts/api/send_message', formData); if (response.data.success) { this.chatMessages.push({ type: 'voice', content: response.data.url, isSelf: true, time: new Date().toLocaleTimeString() }); this.$nextTick(() => { this.scrollToBottom(); }); } } catch (error) { this.$message.error('发送语音失败'); }
},
async uploadVideo(options) {
const formData = new FormData(); formData.append('file', options.file); formData.append('wxid', this.currentChatUser.wxid); formData.append('type', 'video');
try { const response = await axios.post('/contacts/api/send_message', formData); if (response.data.success) { this.chatMessages.push({ type: 'video', content: response.data.url, isSelf: true, time: new Date().toLocaleTimeString() }); this.$nextTick(() => { this.scrollToBottom(); }); } } catch (error) { this.$message.error('发送视频失败'); }
},
showLinkDialog() { this.linkForm = { url: '', title: '', description: '' }; this.linkDialogVisible = true; },
async sendLinkMessage() {
if (!this.linkForm.url) { this.$message.warning('请输入链接'); return; }
try {
const response = await axios.post('/contacts/api/send_message', { wxid: this.currentChatUser.wxid, type: 'link', content: this.linkForm });
if (response.data.success) { this.chatMessages.push({ type: 'link', content: this.linkForm, isSelf: true, time: new Date().toLocaleTimeString() }); this.linkDialogVisible = false; this.$nextTick(() => { this.scrollToBottom(); }); }
} catch (error) { this.$message.error('发送链接失败'); }
},
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; height: 500px; }
.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;
}
.message-item { margin-bottom: 15px; display: flex; flex-direction: column; }
.message-self { align-items: flex-end; }
.message-content {
max-width: 70%; padding: 10px 12px; 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-time { font-size: 12px; color: #94a3b8; margin-top: 5px; }
.input-area { padding: 20px 0 0; }
.toolbar { margin-top: 10px; 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; }
}
</style>
{% endblock %}