大家好!我是CSDN的Python新手博主~ 上一篇我们完成了看板的跨系统联动与可视化任务编排,解决了多系统数据互通与自动化办公需求,但甲方客户反馈两大核心痛点:① 可视化体验单一,现有图表多为静态展示,无法实现数据钻取、联动筛选、自定义查看,数据分析效率低;② 不支持移动端适配,员工外出时无法通过手机查看看板数据、处理待办任务,只能依赖电脑,灵活性不足;③ 多终端数据不同步,电脑端修改的看板配置、查看的历史数据,手机端无法同步,操作体验割裂。今天就带来超落地的新手实战项目------办公看板升级交互式可视化+移动端适配+多终端同步!
本次基于之前的"跨系统联动看板"代码,新增3大核心功能:① 交互式可视化升级(ECharts进阶,实现图表联动、数据钻取、自定义筛选、主题切换,支持拖拽调整图表位置);② 移动端全面适配(基于Bootstrap响应式布局,优化看板组件、适配触摸操作,支持手机/平板横屏/竖屏切换);③ 多终端数据同步(基于Redis缓存+Token身份校验,实现看板配置、查看记录、待办任务、操作日志多终端实时同步)。全程基于现有技术栈(Flask+MySQL+ECharts+Bootstrap+Redis),新增交互式组件、响应式布局、多终端同步引擎,代码注释详细,新手只需配置适配参数、复制代码,就能完成升级,让看板兼顾数据分析效率与移动办公灵活性~
一、本次学习目标
-
掌握ECharts交互式可视化技巧,实现图表联动、数据钻取、自定义筛选、主题切换,提升数据分析体验,新手也能快速上手进阶配置;
-
学会Bootstrap响应式布局开发,优化看板组件结构,适配不同终端(电脑、手机、平板),解决移动端显示错乱、操作不便的问题;
-
理解多终端同步原理,基于Redis缓存与Token校验,实现看板配置、查看记录、待办任务等数据的实时同步,确保多终端操作一致性;
-
实现交互式可视化、移动端适配、多终端同步的功能闭环,支持员工随时随地查看数据、处理任务,提升办公灵活性;
-
适配企业移动办公场景,支持移动端快捷操作(一键刷新、快速筛选、待办处理),兼顾电脑端数据分析与移动端便捷性。
二、前期准备
- 安装核心依赖库
安装核心依赖(Redis缓存、响应式组件、ECharts进阶适配)
pip3 install redis flask-cors flask-bootstrap5 flask-wtf -i https://pypi.tuna.tsinghua.edu.cn/simple
确保已有依赖正常(Flask、ECharts、跨系统联动等)
pip3 install --upgrade flask flask-login flask-sqlalchemy requests celery pymysql openpyxl -i https://pypi.tuna.tsinghua.edu.cn/simple
说明:多终端同步用Redis实现数据缓存(存储看板配置、查看记录等);移动端适配用Bootstrap5响应式框架,无需额外开发移动端页面;交互式可视化基于ECharts进阶API,复用现有图表基础,只需新增配置。
- 第三方服务与配置准备
-
交互式可视化配置:定义图表联动规则(如点击柱状图某一项,折线图同步显示对应数据)、数据钻取层级(如从部门数据钻取到个人数据)、筛选条件(时间范围、数据类型、部门筛选)、可选主题(浅色/深色/自定义配色);
-
移动端适配配置:设置不同终端的看板布局(电脑端多列布局、手机端单列布局)、组件适配参数(按钮大小、字体大小、图表尺寸)、触摸操作规则(滑动切换看板、长按筛选);
-
多终端同步配置:确定同步数据范围(看板配置、查看记录、待办任务、操作日志)、缓存过期时间(默认24小时,关键数据永久缓存)、Token校验规则(同一账号多终端登录,Token同步生效);
-
环境准备:确保Redis服务正常启动(复用之前Celery使用的Redis,无需新增),云服务器开放移动端访问端口,配置跨域(支持手机端访问后端接口)。
- 数据库表优化与创建
-- 连接MySQL数据库(替换为你的数据库信息)
mysql -u office_user -p -h 47.108.xxx.xxx office_data
-- 创建可视化配置表(visual_config)
CREATE TABLE visual_config (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL COMMENT '用户ID(0表示全局配置)',
dashboard_id INT NOT NULL COMMENT '看板ID',
chart_config TEXT NOT NULL COMMENT '图表配置(JSON格式,含ECharts参数、位置、大小)',
filter_config TEXT NOT NULL COMMENT '筛选配置(JSON格式,含默认筛选条件、可选筛选项)',
theme VARCHAR(20) DEFAULT 'light' COMMENT '可视化主题(light/dark/custom)',
theme_config TEXT NULL COMMENT '自定义主题配置(JSON格式,配色方案)',
is_default TINYINT(1) DEFAULT 0 COMMENT '是否为默认配置(1-是,0-否)',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
KEY idx_user_dashboard (user_id, dashboard_id),
FOREIGN KEY (dashboard_id) REFERENCES dashboard_info(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='交互式可视化配置表';
-- 创建终端同步记录表(terminal_sync)
CREATE TABLE terminal_sync (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL COMMENT '用户ID',
token VARCHAR(100) NOT NULL COMMENT '用户登录Token',
terminal_type ENUM('pc', 'mobile', 'pad') NOT NULL COMMENT '终端类型',
sync_data TEXT NOT NULL COMMENT '同步数据(JSON格式,看板配置、查看记录等)',
sync_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '同步时间',
last_operate_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '最后操作时间',
KEY idx_user_token (user_id, token),
KEY idx_terminal_type (terminal_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='多终端同步记录表';
-- 创建用户看板偏好表(user_dashboard_prefer)
CREATE TABLE user_dashboard_prefer (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL COMMENT '用户ID',
dashboard_id INT NOT NULL COMMENT '看板ID',
prefer_config TEXT NOT NULL COMMENT '偏好配置(JSON格式,默认查看页面、快捷操作、排序方式)',
recent_view TEXT NULL COMMENT '最近查看记录(JSON格式,图表ID、查看时间)',
mobile_layout TINYINT(1) DEFAULT 1 COMMENT '移动端布局(1-单列,2-双列)',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY uk_user_dashboard (user_id, dashboard_id),
FOREIGN KEY (dashboard_id) REFERENCES dashboard_info(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户看板偏好表(适配多终端)';
-- 初始化全局可视化配置示例
INSERT INTO visual_config (user_id, dashboard_id, chart_config, filter_config, theme, is_default)
VALUES (
0,
1, -- 假设看板ID为1(主看板)
'{"charts":[{"id":1,"type":"bar","position":{"x":0,"y":0,"width":50,"height":40},"echarts_config":{"title":{"text":"每日订单量"},"xAxis":{"type":"category"},"yAxis":{"type":"value"}}},{"id":2,"type":"line","position":{"x":50,"y":0,"width":50,"height":40},"echarts_config":{"title":{"text":"每日销售额"}}}]}',
'{"default_filter":{"start_date":"2026-01-01","end_date":"2026-01-31","dept":"all"},"optional_filters":["dept","product","status"]}',
'light',
1
);
-- 初始化用户看板偏好示例
INSERT INTO user_dashboard_prefer (user_id, dashboard_id, prefer_config, recent_view, mobile_layout)
VALUES (
1, -- 管理员用户ID
1,
'{"default_page":"data_overview","quick_operate":["refresh","export","filter"],"sort_type":"time_desc"}',
'[{"chart_id":1,"view_time":"2026-01-26 10:30:00"},{"chart_id":2,"view_time":"2026-01-26 10:35:00"}]',
1
);
三、实战:交互式可视化+移动端适配+多终端同步升级
- 第一步:交互式可视化升级,实现图表联动与数据钻取
-- coding: utf-8 --
visual_upgrade.py 交互式可视化升级模块
import json
from flask import Blueprint, request, jsonify, g
from flask_login import login_required, current_user
from models import db, VisualConfig, DashboardInfo, UserDashboardPrefer
from audit_engine import audit_log
from dotenv import load_dotenv
import os
import redis
加载环境变量
load_dotenv()
初始化Redis连接(用于缓存可视化配置,提升响应速度)
redis_client = redis.Redis(
host=os.getenv("REDIS_HOST", "localhost"),
port=int(os.getenv("REDIS_PORT", 6379)),
db=int(os.getenv("REDIS_DB", 0)),
decode_responses=True
)
visual_bp = Blueprint("visual", name)
====================== 核心功能:获取交互式可视化配置 ======================
def get_visual_config(user_id, dashboard_id):
"""
获取用户的看板可视化配置(优先用户自定义,无则用全局配置)
:param user_id: 用户ID
:param dashboard_id: 看板ID
:return: 可视化配置字典
"""
先从Redis缓存获取,提升速度
cache_key = f"visual_config:{user_id}:{dashboard_id}"
cache_config = redis_client.get(cache_key)
if cache_config:
return json.loads(cache_config)
# 从数据库获取:优先用户自定义配置
visual_config = VisualConfig.query.filter_by(
user_id=user_id, dashboard_id=dashboard_id
).order_by(VisualConfig.is_default.desc()).first()
# 无用户配置,获取全局配置(user_id=0)
if not visual_config:
visual_config = VisualConfig.query.filter_by(
user_id=0, dashboard_id=dashboard_id, is_default=1
).first()
if not visual_config:
raise Exception(f"未找到看板{dashboard_id}的可视化配置")
# 格式化配置数据
config_data = {
"chart_config": json.loads(visual_config.chart_config),
"filter_config": json.loads(visual_config.filter_config),
"theme": visual_config.theme,
"theme_config": json.loads(visual_config.theme_config) if visual_config.theme_config else None
}
# 存入Redis缓存(过期时间24小时)
redis_client.setex(cache_key, 86400, json.dumps(config_data))
return config_data
====================== 核心功能:图表联动与数据钻取逻辑 ======================
def get_linked_chart_data(chart_id, drill_down_params=None):
"""
获取联动图表数据、钻取数据
:param chart_id: 触发联动的图表ID
:param drill_down_params: 数据钻取参数(如部门ID、时间范围)
:return: 联动图表数据字典
"""
模拟数据(实际项目中从数据库/跨系统接口获取)
base_data = {
1: {"name": "每日订单量", "type": "bar", "data": [120, 180, 150, 200, 220, 250]},
2: {"name": "每日销售额", "type": "line", "data": [12000, 18000, 15000, 20000, 22000, 25000]},
3: {"name": "订单来源分布", "type": "pie", "data": [{"name": "线上", "value": 600}, {"name": "线下", "value": 300}]}
}
# 图表联动:点击图表1(订单量),联动图表2(销售额)显示对应数据
if chart_id == 1 and not drill_down_params:
return {
"trigger_chart": base_data[1],
"linked_charts": [base_data[2]] # 联动图表2
}
# 数据钻取:点击图表1的某一项(如200),钻取显示该日各部门订单量
if chart_id == 1 and drill_down_params:
day = drill_down_params.get("day", 3) # 假设点击第3天(索引3,数据200)
drill_data = {
"name": f"第{day+1}天各部门订单量",
"type": "bar",
"data": [{"name": "销售部", "value": 80}, {"name": "市场部", "value": 60}, {"name": "客服部", "value": 60}]
}
return {
"trigger_chart": base_data[1],
"drill_down_data": drill_data # 钻取数据
}
# 默认返回所有图表数据
return {"all_charts": base_data}
====================== 接口:交互式可视化配置管理 ======================
@visual_bp.route("/visual/config/int:dashboard_id", methods=["GET"])
@login_required
def get_visual_config_api(dashboard_id):
"""获取看板交互式可视化配置"""
try:
config_data = get_visual_config(current_user.id, dashboard_id)
return jsonify({"success": True, "data": config_data})
except Exception as e:
return jsonify({"success": False, "error": str(e)})
@visual_bp.route("/visual/config/save/int:dashboard_id", methods=["POST"])
@login_required
@audit_log("update")
def save_visual_config(dashboard_id):
"""保存用户自定义可视化配置(图表位置、筛选条件、主题)"""
data = request.get_json()
required_fields = ["chart_config", "filter_config"]
for field in required_fields:
if not data.get(field):
return jsonify({"success": False, "error": f"缺少必填字段:{field}"})
try:
# 验证JSON格式
json.loads(data["chart_config"])
json.loads(data["filter_config"])
if data.get("theme_config"):
json.loads(data["theme_config"])
except json.JSONDecodeError:
return jsonify({"success": False, "error": "配置字段格式错误(需JSON)"})
# 检查是否已有配置,有则更新,无则新增
visual_config = VisualConfig.query.filter_by(
user_id=current_user.id, dashboard_id=dashboard_id
).first()
if visual_config:
visual_config.chart_config = data["chart_config"]
visual_config.filter_config = data["filter_config"]
visual_config.theme = data.get("theme", "light")
visual_config.theme_config = data.get("theme_config")
else:
visual_config = VisualConfig(
user_id=current_user.id,
dashboard_id=dashboard_id,
chart_config=data["chart_config"],
filter_config=data["filter_config"],
theme=data.get("theme", "light"),
theme_config=data.get("theme_config"),
is_default=0
)
db.session.add(visual_config)
db.session.commit()
# 更新Redis缓存
cache_key = f"visual_config:{current_user.id}:{dashboard_id}"
config_data = {
"chart_config": json.loads(data["chart_config"]),
"filter_config": json.loads(data["filter_config"]),
"theme": data.get("theme", "light"),
"theme_config": json.loads(data["theme_config"]) if data.get("theme_config") else None
}
redis_client.setex(cache_key, 86400, json.dumps(config_data))
return jsonify({"success": True, "msg": "可视化配置保存成功"})
====================== 接口:图表联动与数据钻取 ======================
@visual_bp.route("/visual/chart/link", methods=["POST"])
@login_required
def chart_link_api():
"""图表联动与数据钻取接口"""
data = request.get_json()
chart_id = data.get("chart_id")
drill_down_params = data.get("drill_down_params")
if not chart_id:
return jsonify({"success": False, "error": "缺少图表ID"})
try:
chart_data = get_linked_chart_data(chart_id, drill_down_params)
return jsonify({"success": True, "data": chart_data})
except Exception as e:
return jsonify({"success": False, "error": str(e)})
====================== 接口:主题切换 ======================
@visual_bp.route("/visual/theme/switch/int:dashboard_id", methods=["POST"])
@login_required
def switch_theme(dashboard_id):
"""切换看板可视化主题(浅色/深色/自定义)"""
data = request.get_json()
theme = data.get("theme")
if not theme or theme not in ["light", "dark", "custom"]:
return jsonify({"success": False, "error": "无效的主题类型(仅支持light/dark/custom)"})
# 更新数据库配置
visual_config = VisualConfig.query.filter_by(
user_id=current_user.id, dashboard_id=dashboard_id
).first()
if not visual_config:
# 无用户自定义配置,新增配置
visual_config = VisualConfig(
user_id=current_user.id,
dashboard_id=dashboard_id,
chart_config=json.dumps({"charts": []}),
filter_config=json.dumps({"default_filter": {}, "optional_filters": []}),
theme=theme,
theme_config=data.get("theme_config"),
is_default=0
)
db.session.add(visual_config)
else:
visual_config.theme = theme
if theme == "custom":
if not data.get("theme_config"):
return jsonify({"success": False, "error": "自定义主题需传入theme_config配置"})
visual_config.theme_config = data["theme_config"]
else:
visual_config.theme_config = None
db.session.commit()
# 更新Redis缓存
cache_key = f"visual_config:{current_user.id}:{dashboard_id}"
config_data = get_visual_config(current_user.id, dashboard_id)
redis_client.setex(cache_key, 86400, json.dumps(config_data))
return jsonify({"success": True, "msg": f"主题切换为{theme}成功"})
{% extends "base.html" %}
{% block content %}
交互式数据看板
<!-- 筛选条件 -->
<div class="d-flex flex-wrap align-items-center gap-2" id="filterBar">
<select class="form-select form-select-sm" id="deptFilter">
<option value="all" selected>全部部门</option>
<option value="sales">销售部</option>
<option value="market">市场部</option>
<option value="service">客服部</option>
</select>
<input type="date" class="form-control form-control-sm" id="startDate" value="2026-01-01">
<span>至</span>
<input type="date" class="form-control form-control-sm" id="endDate" value="2026-01-31">
<button class="btn btn-sm btn-primary" id="filterBtn">筛选</button>
</div>
<!-- 主题切换与刷新 -->
<div class="d-flex gap-2">
<select class="form-select form-select-sm" id="themeSwitch">
<option value="light" selected>浅色主题</option>
<option value="dark">深色主题</option>
<option value="custom">自定义主题</option>
</select>
<button class="btn btn-sm btn-secondary" id="refreshBtn"><i class="bi bi-arrow-clockwise"></i> 刷新</button>
<button class="btn btn-sm btn-success" id="saveConfigBtn"><i class="bi bi-save"></i> 保存配置</button>
</div>
</div>
</div>
每日订单量
点击图表联动销售额,点击数据项钻取详情
<!-- 图表2:每日销售额(折线图,支持联动) -->
<div class="col-12 col-md-6 chart-item" data-chart-id="2">
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0">每日销售额</h5>
</div>
<div class="card-body p-0">
<div id="chart2" class="chart-dom" style="width:100%;height:300px;"></div>
</div>
</div>
</div>
<!-- 图表3:订单来源分布(饼图) -->
<div class="col-12 chart-item" data-chart-id="3">
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0">订单来源分布</h5>
</div>
<div class="card-body p-0">
<div id="chart3" class="chart-dom" style="width:100%;height:300px;"></div>
</div>
</div>
</div>
<!-- 钻取数据显示区域(默认隐藏) -->
<div class="col-12" id="drillDownArea" style="display:none;">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0" id="drillDownTitle">数据钻取详情</h5>
<button class="btn btn-sm btn-danger" id="closeDrillBtn">关闭</button>
</div>
<div class="card-body p-0">
<div id="drillDownChart" style="width:100%;height:300px;"></div>
</div>
</div>
</div>
{% endblock %}
- 第二步:移动端响应式适配,优化移动办公体验
-- coding: utf-8 --
mobile_adapt.py 移动端适配模块
import json
from flask import Blueprint, request, jsonify, render_template
from flask_login import login_required, current_user
from models import UserDashboardPrefer, db
from audit_engine import audit_log
from dotenv import load_dotenv
import os
加载环境变量
load_dotenv()
mobile_bp = Blueprint("mobile", name)
====================== 核心功能:判断终端类型 ======================
def get_terminal_type():
"""
判断终端类型(pc/mobile/pad)
:return: 终端类型字符串
"""
user_agent = request.headers.get("User-Agent", "").lower()
判断移动端(手机)
if any(keyword in user_agent for keyword in ["mobile", "android", "iphone", "ios"]):
return "mobile"
判断平板
elif any(keyword in user_agent for keyword in ["ipad", "tablet"]):
return "pad"
默认PC端
else:
return "pc"
====================== 核心功能:移动端数据压缩(提升加载速度) ======================
def compress_mobile_data(data):
"""
移动端数据压缩:简化图表数据、减少字段,提升加载速度
:param data: 原始数据
:return: 压缩后的数据
"""
if not isinstance(data, dict):
return data
# 简化图表数据(保留核心字段,减少数据点)
if "chart_config" in data and "charts" in data["chart_config"]:
for chart in data["chart_config"]["charts"]:
if "echarts_config" in chart and "series" in chart["echarts_config"]:
for series in chart["echarts_config"]["series"]:
# 数据点数量减半(移动端无需过多细节)
if isinstance(series["data"], list) and len(series["data"]) > 10:
series["data"] = series["data"][::2] # 每隔一个取一个数据点
# 移除不必要的样式配置
if "itemStyle" in series:
series["itemStyle"] = {"color": series["itemStyle"].get("color", "#165DFF")}
if "lineStyle" in series:
series["lineStyle"] = {"width": series["lineStyle"].get("width", 2)}
# 简化筛选配置(移动端只保留常用筛选项)
if "filter_config" in data:
filter_config = data["filter_config"]
if "optional_filters" in filter_config:
# 保留前3个常用筛选项
filter_config["optional_filters"] = filter_config["optional_filters"][:3]
return data