变更项: 1. 新增群列表层批量运营速览接口,输出健康度、核心成员、待激活成员和简短运营摘要。 2. 在通讯录管理的群组列表中增加运营速览列,把成员分层摘要信号前置到列表层。 3. 保留详情页原有完整群洞察内容,不将完整成员明细直接搬到列表层,继续采用列表筛群、详情看明细的结构。
2323 lines
134 KiB
HTML
2323 lines
134 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="运营速览" min-width="340">
|
||
<template slot-scope="scope">
|
||
<div class="group-preview-cell" v-if="scope.row.ops_preview">
|
||
<div class="group-preview-tags">
|
||
<el-tag size="mini" type="warning" effect="plain">健康度 {% raw %}{{ scope.row.ops_preview.health_score }}{% endraw %}</el-tag>
|
||
<el-tag size="mini" type="success" effect="plain">核心成员 {% raw %}{{ (scope.row.ops_preview.core_member_names || []).length }}{% endraw %}</el-tag>
|
||
<el-tag size="mini" type="info" effect="plain">待激活 {% raw %}{{ scope.row.ops_preview.activation_candidate_count || 0 }}{% endraw %}</el-tag>
|
||
</div>
|
||
<div class="group-preview-members" v-if="(scope.row.ops_preview.core_member_names || []).length">
|
||
核心成员:{% raw %}{{ scope.row.ops_preview.core_member_names.join('、') }}{% endraw %}
|
||
</div>
|
||
<div class="group-preview-members" v-else>
|
||
核心成员:暂无稳定核心成员信号
|
||
</div>
|
||
<div class="group-preview-members" v-if="(scope.row.ops_preview.activation_candidate_names || []).length">
|
||
待激活:{% raw %}{{ scope.row.ops_preview.activation_candidate_names.join('、') }}{% endraw %}
|
||
</div>
|
||
<div class="group-preview-summary">{% raw %}{{ scope.row.ops_preview.summary_excerpt || '暂无运营摘要' }}{% endraw %}</div>
|
||
</div>
|
||
<div v-else class="group-preview-empty">
|
||
暂无运营速览数据
|
||
</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="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>
|
||
|
||
<!--
|
||
群运营分析 2.0 先走“加法增强”:
|
||
1. 老的健康度、趋势、排行继续保留;
|
||
2. 新增群画像摘要,用更接近运营语言的方式解释群状态;
|
||
3. 这样即使后面要调整 2.0 结构,也不会影响旧洞察面板。
|
||
-->
|
||
<div class="detail-section">
|
||
<div class="section-title">
|
||
<h3>群画像摘要</h3>
|
||
</div>
|
||
<el-row :gutter="16" class="detail-panels">
|
||
<el-col :span="14">
|
||
<el-card class="detail-card detail-card--profile" shadow="never">
|
||
<div slot="header" class="detail-card-header">
|
||
<span>群定位与讨论风格</span>
|
||
<span class="detail-card-sub">2.0 增强摘要</span>
|
||
</div>
|
||
<div class="ops-profile-title">{% raw %}{{ groupInsight.ops_profile.group_identity || '综合交流型群' }}{% endraw %}</div>
|
||
<div class="feature-chip-list">
|
||
<el-tag
|
||
v-for="tag in (groupInsight.ops_profile.profile_tags || [])"
|
||
:key="tag"
|
||
size="small"
|
||
type="info"
|
||
effect="plain">
|
||
{% raw %}{{ tag }}{% endraw %}
|
||
</el-tag>
|
||
<span v-if="!(groupInsight.ops_profile.profile_tags || []).length" class="empty-inline">暂无群画像标签</span>
|
||
</div>
|
||
<div class="ops-profile-summary">{% raw %}{{ groupInsight.ops_profile.summary_text || '暂无群画像摘要' }}{% endraw %}</div>
|
||
<div class="ops-topic-block">
|
||
<div class="ops-subtitle">高频主题</div>
|
||
<div class="feature-chip-list">
|
||
<el-tag
|
||
v-for="topic in (groupInsight.ops_profile.focus_topics || [])"
|
||
:key="topic"
|
||
size="small"
|
||
type="success"
|
||
effect="plain">
|
||
{% raw %}{{ topic }}{% endraw %}
|
||
</el-tag>
|
||
<span v-if="!(groupInsight.ops_profile.focus_topics || []).length" class="empty-inline">暂无高频主题</span>
|
||
</div>
|
||
</div>
|
||
<div class="ops-topic-block" v-if="(groupInsight.ops_profile.unresolved_points || []).length">
|
||
<div class="ops-subtitle">待跟进问题</div>
|
||
<div class="ops-bullet-list">
|
||
<div v-for="item in groupInsight.ops_profile.unresolved_points" :key="item" class="ops-bullet-item">
|
||
{% raw %}{{ item }}{% endraw %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :span="10">
|
||
<el-card class="detail-card" shadow="never">
|
||
<div slot="header" class="detail-card-header">
|
||
<span>最近群总结</span>
|
||
<span class="detail-card-sub">复用现有 message_summary</span>
|
||
</div>
|
||
<div class="ops-summary-timeline" v-if="(groupInsight.recent_summaries || []).length">
|
||
<div v-for="item in groupInsight.recent_summaries" :key="`${item.summary_type}-${item.period_key}`" class="ops-summary-item">
|
||
<div class="ops-summary-head">
|
||
<span class="ops-summary-period">{% raw %}{{ item.period_key || '-' }}{% endraw %}</span>
|
||
<span class="ops-summary-meta">{% raw %}{{ item.source_message_count || 0 }}{% endraw %} 条消息</span>
|
||
</div>
|
||
<div class="ops-summary-excerpt">{% raw %}{{ item.summary_excerpt || '暂无摘要内容' }}{% endraw %}</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="empty-inline">当前还没有可展示的群总结记录</div>
|
||
</el-card>
|
||
</el-col>
|
||
</el-row>
|
||
</div>
|
||
|
||
<!--
|
||
成员分层主要服务“下一步看谁”:
|
||
1. 核心成员强调谁在带讨论;
|
||
2. 高价值成员强调谁值得重点关注;
|
||
3. 待激活成员强调谁最适合做轻量召回。
|
||
-->
|
||
<div class="detail-section">
|
||
<div class="section-title">
|
||
<h3>成员分层</h3>
|
||
</div>
|
||
<el-row :gutter="16" class="detail-panels detail-panels--triple">
|
||
<el-col :span="8">
|
||
<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="ops-member-list" v-if="(groupInsight.ops_members.core_members || []).length">
|
||
<div v-for="item in groupInsight.ops_members.core_members" :key="item.wxid" class="ops-member-item">
|
||
<div class="ops-member-name">{% raw %}{{ item.display_name }}{% endraw %}</div>
|
||
<div class="ops-member-meta">消息数 {% raw %}{{ item.message_count }}{% endraw %} · {% raw %}{{ item.activity_level || '未分层' }}{% endraw %}</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="empty-inline">暂无核心成员数据</div>
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :span="8">
|
||
<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="ops-member-list" v-if="(groupInsight.ops_members.value_rank_members || []).length">
|
||
<div v-for="item in groupInsight.ops_members.value_rank_members" :key="item.wxid" class="ops-member-item">
|
||
<div class="ops-member-name">{% raw %}{{ item.display_name }}{% endraw %}</div>
|
||
<div class="ops-member-meta">#{% raw %}{{ item.rank_no }}{% endraw %} · {% raw %}{{ item.title || '成员' }}{% endraw %} · 分值 {% raw %}{{ item.score }}{% endraw %}</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="empty-inline">暂无高价值成员快照</div>
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :span="8">
|
||
<el-card class="detail-card" shadow="never">
|
||
<div slot="header" class="detail-card-header">
|
||
<span>待激活成员</span>
|
||
<span class="detail-card-sub">近7~30天边缘活跃</span>
|
||
</div>
|
||
<div class="ops-member-list" v-if="(groupInsight.ops_members.activation_candidates || []).length">
|
||
<div v-for="item in groupInsight.ops_members.activation_candidates" :key="item.wxid" class="ops-member-item">
|
||
<div class="ops-member-name">{% raw %}{{ item.display_name }}{% endraw %}</div>
|
||
<div class="ops-member-meta">距今 {% raw %}{{ item.inactivity_days }}{% endraw %} 天未发言</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="empty-inline">暂无待激活成员</div>
|
||
</el-card>
|
||
</el-col>
|
||
</el-row>
|
||
</div>
|
||
|
||
<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>
|
||
|
||
<!--
|
||
旧版运营建议更偏诊断提示;
|
||
新增的动作建议更偏“下一步怎么做”。
|
||
两层同时保留,便于后续比较哪种信息对你更有用。
|
||
-->
|
||
<div class="detail-section">
|
||
<div class="section-title">
|
||
<h3>可执行动作建议</h3>
|
||
</div>
|
||
<el-row :gutter="16" class="detail-panels detail-panels--double">
|
||
<el-col :span="12" v-for="(item, index) in (groupInsight.ops_actions || [])" :key="`${item.type}-${index}`">
|
||
<el-card class="detail-card action-card" shadow="never">
|
||
<div class="action-card-type">{% raw %}{{ item.type || 'action' }}{% endraw %}</div>
|
||
<div class="action-card-title">{% raw %}{{ item.title }}{% endraw %}</div>
|
||
<div class="action-card-summary">{% raw %}{{ item.summary }}{% endraw %}</div>
|
||
<div class="action-card-detail">{% raw %}{{ item.detail }}{% endraw %}</div>
|
||
</el-card>
|
||
</el-col>
|
||
</el-row>
|
||
</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" @click="groupPermissionsCollapsed = !groupPermissionsCollapsed">
|
||
{% raw %}{{ groupPermissionsCollapsed ? '展开查看' : '收起' }}{% endraw %}
|
||
</el-button>
|
||
<template v-if="!groupPermissionsCollapsed">
|
||
<el-button size="mini" type="success" @click="updateAllGroupPermissions('enabled')">一键启用</el-button>
|
||
<el-button size="mini" type="danger" @click="updateAllGroupPermissions('disabled')">一键关闭</el-button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<div v-if="groupPermissionsCollapsed" class="collapsed-placeholder">
|
||
群功能权限默认折叠,点击“展开查看”后再进行配置。
|
||
</div>
|
||
<el-table v-else :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>
|
||
|
||
<el-card class="detail-card welcome-config-card" shadow="never" v-loading="groupWelcomeConfigLoading">
|
||
<div slot="header" class="detail-card-header">
|
||
<span>进群欢迎配置(群级差异化)</span>
|
||
<div class="detail-card-header-actions">
|
||
<span class="detail-card-sub">当前群:{% raw %}{{ currentGroup.name || currentGroup.wxid || '-' }}{% endraw %}</span>
|
||
<el-button size="mini" @click="groupWelcomeCollapsed = !groupWelcomeCollapsed">
|
||
{% raw %}{{ groupWelcomeCollapsed ? '展开查看' : '收起' }}{% endraw %}
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
<div v-if="groupWelcomeCollapsed" class="collapsed-placeholder">
|
||
进群欢迎配置默认折叠,点击“展开查看”后再编辑当前群的欢迎文案。
|
||
</div>
|
||
<el-form v-else label-width="110px" size="mini">
|
||
<el-form-item label="文本欢迎开关">
|
||
<el-switch v-model="groupWelcomeConfig.welcome_text_enabled"></el-switch>
|
||
</el-form-item>
|
||
<el-form-item label="文本模板">
|
||
<el-input v-model="groupWelcomeConfig.welcome_text_template"></el-input>
|
||
</el-form-item>
|
||
<el-form-item label="卡片开关">
|
||
<el-switch v-model="groupWelcomeConfig.welcome_card_enabled"></el-switch>
|
||
</el-form-item>
|
||
<el-form-item label="卡片标题">
|
||
<el-input v-model="groupWelcomeConfig.card_title_template"></el-input>
|
||
</el-form-item>
|
||
<el-form-item label="卡片描述">
|
||
<el-input v-model="groupWelcomeConfig.card_desc_template"></el-input>
|
||
</el-form-item>
|
||
<el-form-item label="卡片URL">
|
||
<el-input v-model="groupWelcomeConfig.card_url"></el-input>
|
||
</el-form-item>
|
||
<el-form-item label="缩略图URL">
|
||
<el-input v-model="groupWelcomeConfig.card_thumb_url"></el-input>
|
||
</el-form-item>
|
||
<el-form-item label="变量提示">
|
||
<div class="form-tip">
|
||
<code>{nickname}</code><code>{wxid}</code><code>{group_id}</code><code>{now}</code><code>{head_url}</code>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="预览">
|
||
<div class="preview-box">
|
||
<p><strong>文本:</strong>{% raw %}{{ previewGroupWelcomeText }}{% endraw %}</p>
|
||
<p><strong>标题:</strong>{% raw %}{{ previewGroupWelcomeTitle }}{% endraw %}</p>
|
||
<p><strong>描述:</strong>{% raw %}{{ previewGroupWelcomeDesc }}{% endraw %}</p>
|
||
<p><strong>URL:</strong>{% raw %}{{ previewGroupWelcomeUrl }}{% endraw %}</p>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" size="mini" @click="saveGroupWelcomeConfig">保存欢迎配置</el-button>
|
||
<el-button size="mini" @click="resetGroupWelcomeConfig">恢复默认</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
</el-card>
|
||
</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="getHeadImage(currentPublic.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 %}{{ 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 v-if="item.semantic_text" class="emoji-semantic">{{ item.semantic_text }}</div>
|
||
<div v-if="item.semantic_aliases && item.semantic_aliases.length > 1" class="emoji-aliases">{{ item.semantic_aliases.join(' / ') }}</div>
|
||
<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 },
|
||
// 群列表层只展示“成员分层摘要信号”,不把完整成员明细直接塞到列表里。
|
||
// 这样能先帮助筛群,再通过详情页看完整洞察。
|
||
groupOpsPreviewMap: {},
|
||
groupDetailDialogVisible: false,
|
||
userDetailDialogVisible: false,
|
||
officialDetailDialogVisible: false,
|
||
publicDetailDialogVisible: false,
|
||
currentGroup: {}, currentUser: {}, currentOfficial: {}, currentPublic: {},
|
||
managedGroupMap: {},
|
||
groupPermissions: [],
|
||
groupPermissionsLoading: false,
|
||
// 群权限区域默认折叠,避免详情弹窗一打开就被大量开关占满视线。
|
||
groupPermissionsCollapsed: true,
|
||
groupWelcomeConfigLoading: false,
|
||
// 欢迎配置表单默认折叠,需要用户主动展开后再查看或编辑。
|
||
groupWelcomeCollapsed: true,
|
||
groupWelcomeConfig: {
|
||
welcome_text_enabled: true,
|
||
welcome_text_template: '👏欢迎 {nickname} 加入群聊!🎉',
|
||
welcome_card_enabled: true,
|
||
card_title_template: '👏欢迎 {nickname} 加入群聊!🎉',
|
||
card_desc_template: '⌚时间:{now}',
|
||
card_url: 'https://newsnow.busiyi.world/',
|
||
card_thumb_url: '{head_url}'
|
||
},
|
||
// 当前群基础资料:用于展示群主、群公告、管理员、成员数等信息。
|
||
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 => {
|
||
// 表情库后续要服务 AI 自动回复,因此这里除了 md5,也支持按主语义和别名检索。
|
||
// 这样人工整理映射时,可以直接搜“哈哈/害/难道”之类语义词,不需要反复记 md5。
|
||
const md5 = (item.md5 || '').toLowerCase();
|
||
const semanticText = (item.semantic_text || '').toLowerCase();
|
||
const aliases = Array.isArray(item.semantic_aliases) ? item.semantic_aliases.join(' ').toLowerCase() : '';
|
||
return md5.includes(keyword) || semanticText.includes(keyword) || aliases.includes(keyword);
|
||
});
|
||
},
|
||
previewGroupWelcomeText() {
|
||
return this.renderWelcomeTemplate(this.groupWelcomeConfig.welcome_text_template);
|
||
},
|
||
previewGroupWelcomeTitle() {
|
||
return this.renderWelcomeTemplate(this.groupWelcomeConfig.card_title_template);
|
||
},
|
||
previewGroupWelcomeDesc() {
|
||
return this.renderWelcomeTemplate(this.groupWelcomeConfig.card_desc_template);
|
||
},
|
||
previewGroupWelcomeUrl() {
|
||
return this.renderWelcomeTemplate(this.groupWelcomeConfig.card_url);
|
||
}
|
||
},
|
||
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',
|
||
ops_preview: null
|
||
};
|
||
});
|
||
this.loadGroupOpsPreview(this.groupsList.map(item => item.wxid));
|
||
} 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('加载联系人头像数据失败'); });
|
||
},
|
||
loadGroupOpsPreview(groupIds) {
|
||
const cleanIds = Array.isArray(groupIds) ? groupIds.filter(Boolean).slice(0, 30) : [];
|
||
if (!cleanIds.length) {
|
||
this.groupOpsPreviewMap = {};
|
||
return;
|
||
}
|
||
axios.post('/robot/api/groups/ops_preview', { group_ids: cleanIds }).then(response => {
|
||
if (!response.data.success) {
|
||
return;
|
||
}
|
||
const previewMap = (response.data.data && response.data.data.preview_map) || {};
|
||
this.groupOpsPreviewMap = previewMap;
|
||
this.groupsList = this.groupsList.map(item => ({
|
||
...item,
|
||
ops_preview: previewMap[item.wxid] || null
|
||
}));
|
||
}).catch(error => {
|
||
console.error('加载群运营速览失败:', error);
|
||
});
|
||
},
|
||
updateContacts() {
|
||
this.$message.info('正在更新通讯录...');
|
||
// 通讯录刷新已改成后台异步任务:
|
||
// 1. 点击后只提交任务,不再阻塞等待全部联系人与头像同步完成;
|
||
// 2. 因为结果是异步落库,这里不再立刻 refreshContacts,避免用户误以为没生效;
|
||
// 3. 任务完成后用户手动点“刷新数据”即可看到最新结果。
|
||
axios.post('/contacts/api/update').then(res => { if (res.data.success) { this.$message.success(res.data.message || '通讯录更新任务已提交'); } else { this.$message.error(res.data.message || '通讯录更新失败'); } }).catch(() => { this.$message.error('通讯录更新请求失败'); });
|
||
},
|
||
refreshContacts() { this.loadContactsData(); this.$message.success('联系人数据已刷新'); },
|
||
handleTabClick() { this.currentPage = 1; },
|
||
// 通讯录头像统一走后台代理接口:
|
||
// 1. 优先命中服务端已缓存的本地头像;
|
||
// 2. 头像更新后会附带版本参数,浏览器不会一直吃旧图;
|
||
// 3. 代理接口兜底远端地址,因此这里保持简单读取即可。
|
||
getHeadImage(wxid) { return this.headImages[wxid] || ''; },
|
||
handleSizeChange(size) { this.pageSize = size; },
|
||
handleCurrentChange(page) { this.currentPage = page; },
|
||
viewGroupDetails(group) {
|
||
this.currentGroup = { ...group };
|
||
// 切换群时恢复默认折叠态,保持“手动展开再看”的交互习惯。
|
||
this.groupPermissionsCollapsed = true;
|
||
this.groupWelcomeCollapsed = true;
|
||
this.groupDetailDialogVisible = true;
|
||
// 进入群详情时先加载群资料,保证群主/公告/管理员信息第一时间可见。
|
||
this.loadGroupProfile(group.wxid);
|
||
this.loadGroupMembers(group.wxid);
|
||
this.loadGroupPermissions(group.wxid);
|
||
this.loadGroupInsights(group.wxid);
|
||
this.loadGroupWelcomeConfig(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; },
|
||
getDefaultWelcomeConfig() {
|
||
return {
|
||
welcome_text_enabled: true,
|
||
welcome_text_template: '👏欢迎 {nickname} 加入群聊!🎉',
|
||
welcome_card_enabled: true,
|
||
card_title_template: '👏欢迎 {nickname} 加入群聊!🎉',
|
||
card_desc_template: '⌚时间:{now}',
|
||
card_url: 'https://newsnow.busiyi.world/',
|
||
card_thumb_url: '{head_url}'
|
||
};
|
||
},
|
||
normalizeWelcomeConfig(raw) {
|
||
const base = this.getDefaultWelcomeConfig();
|
||
const cfg = raw && typeof raw === 'object' ? raw : {};
|
||
return {
|
||
welcome_text_enabled: cfg.welcome_text_enabled !== undefined ? !!cfg.welcome_text_enabled : base.welcome_text_enabled,
|
||
welcome_text_template: String(cfg.welcome_text_template || base.welcome_text_template),
|
||
welcome_card_enabled: cfg.welcome_card_enabled !== undefined ? !!cfg.welcome_card_enabled : base.welcome_card_enabled,
|
||
card_title_template: String(cfg.card_title_template || base.card_title_template),
|
||
card_desc_template: String(cfg.card_desc_template || base.card_desc_template),
|
||
card_url: String(cfg.card_url || base.card_url),
|
||
card_thumb_url: String(cfg.card_thumb_url || base.card_thumb_url)
|
||
};
|
||
},
|
||
renderWelcomeTemplate(template) {
|
||
const vars = {
|
||
nickname: '张三',
|
||
wxid: 'wxid_demo_123',
|
||
group_id: (this.currentGroup && this.currentGroup.wxid) || '123456@chatroom',
|
||
now: '2026-04-20 12:00:00',
|
||
head_url: 'https://example.com/avatar.png'
|
||
};
|
||
let text = String(template || '');
|
||
Object.entries(vars).forEach(([k, v]) => {
|
||
text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v));
|
||
});
|
||
return text;
|
||
},
|
||
loadGroupWelcomeConfig(groupId) {
|
||
if (!groupId) return;
|
||
this.groupWelcomeConfigLoading = true;
|
||
this.groupWelcomeConfig = this.getDefaultWelcomeConfig();
|
||
axios.get('/group_plugin_config/api/list', {
|
||
params: { group_id: groupId, plugin_name: '群成员变更监控' }
|
||
}).then(response => {
|
||
if (!response.data || !response.data.success) {
|
||
this.$message.warning('加载欢迎配置失败,已使用默认值');
|
||
return;
|
||
}
|
||
const rows = response.data.data || [];
|
||
const row = rows.find(item => String(item.config_key || '') === 'welcome');
|
||
if (row && row.config_json) {
|
||
this.groupWelcomeConfig = this.normalizeWelcomeConfig(row.config_json);
|
||
}
|
||
}).catch(error => {
|
||
console.error('加载群欢迎配置失败:', error);
|
||
this.$message.warning('加载欢迎配置失败,已使用默认值');
|
||
}).finally(() => { this.groupWelcomeConfigLoading = false; });
|
||
},
|
||
resetGroupWelcomeConfig() {
|
||
this.groupWelcomeConfig = this.getDefaultWelcomeConfig();
|
||
this.$message.success('已恢复默认欢迎配置');
|
||
},
|
||
saveGroupWelcomeConfig() {
|
||
if (!this.currentGroup || !this.currentGroup.wxid) return;
|
||
const renderedUrl = this.renderWelcomeTemplate(this.groupWelcomeConfig.card_url);
|
||
if (!/^https?:\/\//i.test(renderedUrl)) {
|
||
this.$message.error('卡片URL必须是 http 或 https 开头');
|
||
return;
|
||
}
|
||
this.groupWelcomeConfigLoading = true;
|
||
axios.post('/group_plugin_config/api/upsert', {
|
||
group_id: this.currentGroup.wxid,
|
||
plugin_name: '群成员变更监控',
|
||
config_key: 'welcome',
|
||
enabled: true,
|
||
config_json: this.groupWelcomeConfig,
|
||
updated_by: 'dashboard'
|
||
}).then(response => {
|
||
if (response.data && response.data.success) {
|
||
this.$message.success('群欢迎配置保存成功');
|
||
} else {
|
||
this.$message.error((response.data && response.data.message) || '群欢迎配置保存失败');
|
||
}
|
||
}).catch(error => {
|
||
console.error('保存群欢迎配置失败:', error);
|
||
this.$message.error('保存群欢迎配置失败');
|
||
}).finally(() => { this.groupWelcomeConfigLoading = false; });
|
||
},
|
||
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 = this.getCompactLastMessageContent(lastMessage);
|
||
return `${sender} · ${time} · ${content}`;
|
||
},
|
||
getCompactLastMessageContent(lastMessage) {
|
||
// 群详情顶部只需要紧凑预览,不需要把 XML、链接卡片原文或媒体原始内容完整展开。
|
||
const messageType = String((lastMessage && lastMessage.message_type) || '').trim();
|
||
const rawContent = String((lastMessage && lastMessage.content) || '').trim();
|
||
const compactMap = {
|
||
'3': '[图片]',
|
||
'43': '[视频]',
|
||
'62': '[视频]',
|
||
'47': '[表情]',
|
||
'1048625': '[表情]',
|
||
'1090519089': '[表情]',
|
||
'49': '[链接]'
|
||
};
|
||
if (compactMap[messageType]) {
|
||
return compactMap[messageType];
|
||
}
|
||
if (rawContent.startsWith('<sysmsg')) {
|
||
return '[系统消息]';
|
||
}
|
||
if (rawContent.startsWith('<') || /<[^>]+>/.test(rawContent)) {
|
||
return '[XML消息]';
|
||
}
|
||
if (!rawContent) {
|
||
return '[非文本消息]';
|
||
}
|
||
const singleLineContent = rawContent.replace(/\s+/g, ' ').trim();
|
||
return singleLineContent.length > 60 ? `${singleLineContent.slice(0, 60)}...` : singleLineContent;
|
||
},
|
||
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(/&/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; }
|
||
.group-preview-cell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
padding: 8px 0;
|
||
}
|
||
.group-preview-tags {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.group-preview-members {
|
||
font-size: 12px;
|
||
color: #475569;
|
||
line-height: 1.6;
|
||
}
|
||
.group-preview-summary {
|
||
font-size: 12px;
|
||
color: #64748b;
|
||
line-height: 1.7;
|
||
}
|
||
.group-preview-empty {
|
||
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; }
|
||
.welcome-config-card { margin-top: 14px; }
|
||
.group-members-section { margin-top: 20px; }
|
||
.detail-card-header-actions { display: flex; align-items: center; gap: 10px; }
|
||
.collapsed-placeholder {
|
||
padding: 14px 16px; border-radius: 12px; background: #f8fafc; color: #64748b;
|
||
border: 1px dashed rgba(148, 163, 184, 0.45); font-size: 13px;
|
||
}
|
||
.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-panels--triple .el-col,
|
||
.detail-panels--double .el-col { margin-bottom: 16px; }
|
||
.detail-card { border-radius: 18px; }
|
||
.detail-card--profile {
|
||
background: linear-gradient(180deg, rgba(14,165,233,0.08), rgba(255,255,255,0.98));
|
||
}
|
||
.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; }
|
||
.ops-profile-title {
|
||
font-size: 22px;
|
||
font-weight: 700;
|
||
color: #0f172a;
|
||
margin-bottom: 12px;
|
||
}
|
||
.ops-profile-summary {
|
||
margin-top: 14px;
|
||
padding: 14px 16px;
|
||
border-radius: 14px;
|
||
background: rgba(255,255,255,0.78);
|
||
border: 1px solid rgba(148,163,184,0.12);
|
||
color: #334155;
|
||
line-height: 1.8;
|
||
font-size: 13px;
|
||
}
|
||
.ops-topic-block {
|
||
margin-top: 16px;
|
||
}
|
||
.ops-subtitle {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: #475569;
|
||
margin-bottom: 10px;
|
||
}
|
||
.ops-bullet-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.ops-bullet-item {
|
||
padding: 10px 12px;
|
||
border-radius: 12px;
|
||
background: rgba(248,250,252,0.9);
|
||
color: #475569;
|
||
border: 1px solid rgba(148,163,184,0.12);
|
||
line-height: 1.6;
|
||
font-size: 12px;
|
||
}
|
||
.ops-summary-timeline {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
.ops-summary-item {
|
||
padding: 14px 14px 12px;
|
||
border-radius: 14px;
|
||
background: linear-gradient(180deg, rgba(248,250,252,0.9), rgba(255,255,255,0.98));
|
||
border: 1px solid rgba(148,163,184,0.12);
|
||
}
|
||
.ops-summary-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
margin-bottom: 8px;
|
||
}
|
||
.ops-summary-period {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: #0f172a;
|
||
}
|
||
.ops-summary-meta {
|
||
font-size: 11px;
|
||
color: #94a3b8;
|
||
}
|
||
.ops-summary-excerpt {
|
||
font-size: 12px;
|
||
color: #475569;
|
||
line-height: 1.7;
|
||
}
|
||
.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; }
|
||
.ops-member-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
.ops-member-item {
|
||
padding: 12px 14px;
|
||
border-radius: 14px;
|
||
background: rgba(248,250,252,0.9);
|
||
border: 1px solid rgba(148,163,184,0.12);
|
||
}
|
||
.ops-member-name {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: #0f172a;
|
||
margin-bottom: 6px;
|
||
}
|
||
.ops-member-meta {
|
||
font-size: 12px;
|
||
color: #64748b;
|
||
line-height: 1.6;
|
||
}
|
||
.action-card {
|
||
min-height: 180px;
|
||
background: linear-gradient(180deg, rgba(249,115,22,0.06), rgba(255,255,255,0.98));
|
||
}
|
||
.action-card-type {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 24px;
|
||
padding: 0 10px;
|
||
border-radius: 999px;
|
||
background: rgba(249,115,22,0.12);
|
||
color: #c2410c;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: .04em;
|
||
margin-bottom: 10px;
|
||
}
|
||
.action-card-title {
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
color: #0f172a;
|
||
margin-bottom: 10px;
|
||
line-height: 1.5;
|
||
}
|
||
.action-card-summary {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #334155;
|
||
line-height: 1.7;
|
||
margin-bottom: 10px;
|
||
}
|
||
.action-card-detail {
|
||
font-size: 12px;
|
||
color: #64748b;
|
||
line-height: 1.8;
|
||
}
|
||
.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;
|
||
}
|
||
.form-tip{
|
||
padding:10px 12px;border-radius:10px;background:#f8fbff;border:1px solid #d9e8f8;color:#4a6179
|
||
}
|
||
.form-tip code{
|
||
display:inline-block;margin-right:6px;background:#eef6ff;border:1px solid #d2e6ff;color:#12539a;padding:1px 6px;border-radius:6px;font-size:12px
|
||
}
|
||
.preview-box{
|
||
padding:12px;border:1px dashed #c7d8ea;background:#f8fbff;border-radius:10px;color:#3f5c77;line-height:1.7
|
||
}
|
||
.preview-box p{margin:0 0 4px 0}
|
||
.emoji-thumb { width: 72px; height: 72px; object-fit: contain; border-radius: 8px; background: rgba(148,163,184,0.08); }
|
||
.emoji-semantic { font-size: 13px; font-weight: 600; color: #0f172a; text-align: center; min-height: 18px; }
|
||
.emoji-aliases { font-size: 11px; color: #475569; text-align: center; line-height: 1.45; max-width: 100%; word-break: break-word; min-height: 16px; }
|
||
.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; }
|
||
.detail-panels--triple .el-col,
|
||
.detail-panels--double .el-col { width: 100%; }
|
||
.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 %}
|