Files
abot/admin/dashboard/templates/contacts_management.html
liuwei 09eff21761 通讯录群详情新增手动同步群公告按钮
变更项:1) 新增POST接口用于手动同步群公告,仅在手动触发时调用Group/GetChatRoomInfoDetail。2) 同步逻辑采用基础群信息与Detail信息合并后再落库,确保公告可更新且不破坏原有群资料。3) 群详情页公告区域新增同步按钮和加载态,避免重复点击。4) 同步成功后自动刷新当前群资料。5) 补充中文注释说明手动同步链路。
2026-04-16 17:24:49 +08:00

1699 lines
101 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="群主">
<span>{% raw %}{{ currentGroupProfile.owner_name || currentGroupProfile.owner_wxid || '未知' }}{% endraw %}</span>
<span class="detail-inline-note" v-if="currentGroupProfile.owner_wxid && currentGroupProfile.owner_name && currentGroupProfile.owner_name !== currentGroupProfile.owner_wxid">
({% raw %}{{ currentGroupProfile.owner_wxid }}{% endraw %})
</span>
</el-descriptions-item>
<el-descriptions-item label="群成员数">
{% raw %}{{ currentGroupProfile.member_count || 0 }}{% endraw %}
</el-descriptions-item>
<el-descriptions-item label="群管理">
<div class="feature-chip-list">
<el-tag
v-for="admin in (currentGroupProfile.admins || [])"
:key="admin.wxid"
size="small"
type="warning"
effect="plain">
{% raw %}{{ admin.display_name }}{% endraw %}
</el-tag>
<span v-if="!(currentGroupProfile.admins || []).length" class="empty-inline">暂无管理员数据</span>
</div>
</el-descriptions-item>
<el-descriptions-item label="群公告">
<div class="group-announcement-wrap">
<span class="group-announcement">{% raw %}{{ currentGroupProfile.announcement || '暂无群公告' }}{% endraw %}</span>
<el-button
size="mini"
type="primary"
plain
:loading="groupAnnouncementSyncing"
@click="syncCurrentGroupAnnouncement">
同步群公告
</el-button>
</div>
</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-button size="small" type="primary" plain icon="el-icon-star-off" @click="openEmojiDialog">表情</el-button>
<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>
<el-dialog title="表情库" :visible.sync="emojiDialogVisible" width="52%">
<div class="emoji-toolbar">
<el-input v-model="emojiKeyword" clearable placeholder="搜索 md5..." size="small"></el-input>
<el-button size="small" icon="el-icon-refresh" :loading="emojiLibraryLoading" @click="loadEmojiLibrary">刷新</el-button>
</div>
<div class="emoji-grid" v-loading="emojiLibraryLoading">
{% raw %}
<div v-if="!filteredEmojiLibrary.length" class="emoji-empty">暂无可用表情,先在群里让媒体下载插件抓取几条表情。</div>
<div v-for="item in filteredEmojiLibrary" :key="item.md5" class="emoji-card">
<img class="emoji-thumb" :src="getChatMediaUrl(item.preview_url)" />
<div class="emoji-md5">{{ item.md5 }}</div>
<div class="emoji-actions">
<el-button type="primary" size="mini" @click="sendEmojiItem(item)">发送</el-button>
</div>
</div>
{% endraw %}
</div>
</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,
// 当前群基础资料:用于展示群主、群公告、管理员、成员数等信息。
currentGroupProfile: { owner_wxid: '', owner_name: '', announcement: '', member_count: 0, admin_count: 0, admins: [] },
// 群公告手动同步按钮的加载态,避免重复点击触发多次请求。
groupAnnouncementSyncing: 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: '' },
emojiDialogVisible: false,
emojiLibraryLoading: false,
emojiLibrary: [],
emojiKeyword: ''
};
},
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: '本群真实使用到的插件数' }
];
},
filteredEmojiLibrary() {
const keyword = (this.emojiKeyword || '').trim().toLowerCase();
if (!keyword) return this.emojiLibrary;
return this.emojiLibrary.filter(item => (item.md5 || '').toLowerCase().includes(keyword));
}
},
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.loadGroupProfile(group.wxid);
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; });
},
loadGroupProfile(groupId) {
// 每次进入详情都拉最新资料,避免“更新通讯录后页面仍是旧数据”的错觉。
this.currentGroupProfile = { owner_wxid: '', owner_name: '', announcement: '', member_count: 0, admin_count: 0, admins: [] };
axios.get(`/contacts/api/group_profile/${groupId}`)
.then(response => {
if (response.data.success) {
this.currentGroupProfile = response.data.data || this.currentGroupProfile;
} else {
this.$message.error('加载群资料失败');
}
})
.catch(error => {
console.error('加载群资料失败:', error);
this.$message.error('加载群资料失败');
});
},
syncCurrentGroupAnnouncement() {
if (!this.currentGroup || !this.currentGroup.wxid) return;
this.groupAnnouncementSyncing = true;
// 手动触发群公告同步:调用后端 Detail 接口并落库,再刷新当前群资料展示。
axios.post(`/contacts/api/group_profile/${this.currentGroup.wxid}/sync_announcement`)
.then(response => {
if (response.data.success) {
this.$message.success(response.data.message || '群公告同步成功');
this.loadGroupProfile(this.currentGroup.wxid);
} else {
this.$message.error(response.data.error || '群公告同步失败');
}
})
.catch(error => {
console.error('同步群公告失败:', error);
this.$message.error('同步群公告失败');
})
.finally(() => {
this.groupAnnouncementSyncing = 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.trim().startsWith('<')) {
const parsed = this.extractXmlMediaUrl(url);
if (!parsed) return '';
return `/api/messages/media_proxy?url=${encodeURIComponent(parsed)}`;
}
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)}`;
},
extractXmlMediaUrl(xmlText) {
if (!xmlText) return '';
const patterns = [
/cdnurl\s*=\s*["']([^"']+)["']/i,
/encrypturl\s*=\s*["']([^"']+)["']/i,
/externurl\s*=\s*["']([^"']+)["']/i,
/cdnmidimgurl\s*=\s*["']([^"']+)["']/i,
/cdnthumburl\s*=\s*["']([^"']+)["']/i
];
for (const pattern of patterns) {
const match = pattern.exec(xmlText);
if (match && match[1]) {
return match[1].replace(/&amp;/g, '&').trim();
}
}
return '';
},
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;
}
},
openEmojiDialog() {
if (!this.currentChatUser || !this.currentChatUser.wxid) return;
this.emojiDialogVisible = true;
this.loadEmojiLibrary();
},
async loadEmojiLibrary() {
this.emojiLibraryLoading = true;
try {
const response = await axios.get('/contacts/api/emojis', { params: { limit: 300 } });
if (response.data.success) {
const list = (response.data.data && response.data.data.emojis) || [];
this.emojiLibrary = Array.isArray(list) ? list : [];
} else {
this.$message.error(response.data.message || '加载表情库失败');
}
} catch (error) {
console.error('加载表情库失败:', error);
this.$message.error('加载表情库失败');
} finally {
this.emojiLibraryLoading = false;
}
},
async sendEmojiItem(item) {
if (!this.currentChatUser || !this.currentChatUser.wxid) return;
const md5 = item && item.md5;
const totalLength = item && item.total_length;
if (!md5 || !totalLength) {
this.$message.error('该表情缺少发送参数');
return;
}
const localMessage = this.appendLocalChatMessage({
displayType: 'image',
content: '[表情]',
mediaUrl: this.getChatMediaUrl(item.preview_url || '')
});
await this.sendChatPayload({
wxid: this.currentChatUser.wxid,
type: 'emoji',
content: {
md5: md5,
total_length: totalLength
}
}, '表情消息已提交到 iPad 通道', localMessage.messageId);
},
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; }
.group-announcement-wrap { display: flex; gap: 12px; align-items: flex-start; justify-content: space-between; }
.group-announcement { white-space: pre-wrap; line-height: 1.7; color: #334155; }
.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; }
.emoji-toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
.emoji-grid {
min-height: 280px; max-height: 520px; overflow-y: auto;
display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 12px;
}
.emoji-card {
border: 1px solid rgba(148,163,184,0.16); border-radius: 12px; padding: 8px;
display: flex; flex-direction: column; gap: 8px; align-items: center; background: #fff;
}
.emoji-thumb { width: 72px; height: 72px; object-fit: contain; border-radius: 8px; background: rgba(148,163,184,0.08); }
.emoji-md5 { font-size: 11px; color: #64748b; word-break: break-all; text-align: center; min-height: 30px; }
.emoji-actions { width: 100%; display: flex; justify-content: center; }
.emoji-empty { color: #94a3b8; padding: 12px; }
@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 %}