前言
Class Widgets是一款桌面课表软件。前几天,我加入了这个软件的交流群,发现群主RinLit搭建了一个网站,叫RinLit似了吗?
因为我从没有开发过网站,也没有体验过其网站中将自己活动公开
的感觉,所以这个网站令我起了兴趣。于是我跃跃欲试,打算仿制一个。
开发
一开始我打算使用易语言来完成全部的开发任务,但很快我就被劝退了——易语言兼容性极差,现已无法胜任。我忽然想用Python练练手,毕竟Python的代码逻辑和易语言差不多,我能简单写上几句(吗?)。
程序的大体逻辑是:分为服务器端和客户端。服务器端负责接收数据和渲染网页,客户端负责收集PC当前的活动名称并发送至服务器。服务器端用Python编写,客户端用易语言编写。
拟好程序的大体逻辑,写好易语言客户端的例程,紧接着在DS的部分协助下,我得到了以下代码:
(1.0版本代码,仅供研究使用,我会在RStatus相对完善时进行开源。)
from flask import Flask, render_template_string
from flask_cors import CORS
import socket
import threading
import logging
import ctypes
# 配置日志记录
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
app = Flask(__name__)
CORS(app) # 启用 CORS
# 存储 TCP 服务器接收到的消息
received_message = ""
# 创建线程锁
lock = threading.Lock()
# 存储当前是否有客户端连接
has_connection = False
# HTML模板,包含现代化CSS样式和动效以及最新更新时间
HTML_TEMPLATE = '''
<!DOCTYPE html>
<html>
<head>
<title>Rsvの状态</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <!-- 添加viewport元标签以适配手机端 -->
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: url('http://virelyx.com/wp-content/uploads/2025/01/6a22be2e4b3d370c76774ddaa58c0893.webp') center/cover no-repeat;
margin: 0;
padding: 20px;
min-height: calc(100vh - 40px);
display: flex;
justify-content: center;
align-items: center;
}
.container {
background: rgba(255, 255, 255, 0.8);
/* 提高背景透明度 */
backdrop-filter: blur(10px);
/* 添加毛玻璃效果 */
border-radius: 15px;
padding: 30px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
text-align: center;
max-width: 600px;
width: 90%;
transition: all 0.3s ease;
/* 添加过渡效果 */
}
.container:hover {
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
/* 增强阴影 */
}
h1 {
color: #222;
/* 加深标题颜色 */
margin-bottom: 20px;
position: relative;
}
#window-title,
#server-title,
.info-module {
font-size: 1.2em;
color: #333;
/* 加深文字颜色 */
padding: 15px;
background: rgba(255, 255, 255, 0.8);
/* 提高背景透明度 */
backdrop-filter: blur(1px);
/* 添加毛玻璃效果 */
border-radius: 8px;
margin: 10px 0;
display: flex;
justify-content: space-between;
align-items: center;
}
#window-title .left-label,
#server-title .left-label {
color: #222;
/* 加深标签颜色 */
}
#window-title .right-content,
#server-title .right-content {
text-align: right;
cursor: pointer; /* 鼠标指针变为手型,表示可点击 */
}
#update-time {
font-size: 0.9em;
color: #555;
/* 加深时间文字颜色 */
margin-top: 5px;
}
.pinyin {
font-size: 0.6em;
position: absolute;
top: -0.8em;
left: 50%;
transform: translateX(-50%);
color: #444;
/* 加深拼音颜色 */
}
.avatar-nickname {
display: flex;
align-items: center;
/* 垂直居中对齐 */
justify-content: flex-start;
/* 左对齐 */
flex-grow: 1;
/* 让这个容器占据剩余空间 */
}
.avatar {
width: 50px;
height: 50px;
border-radius: 50%;
margin-right: 10px;
}
.nickname-container {
display: flex;
flex-direction: column;
align-items: flex-start;
/* 确保昵称和副标题左对齐 */
margin-left: 10px;
/* 添加一些左边距,避免和头像重叠 */
}
.nickname {
font-size: 1.2em;
color: #222;
/* 加深昵称颜色 */
}
.sub-title {
font-size: 0.8em;
/* 减小副标题字体大小 */
color: #444;
/* 加深副标题颜色 */
display: block;
/* 确保副标题换行 */
}
.status-indicator {
padding: 5px 10px;
border-radius: 8px;
/* 整体呈圆角矩形 */
color: black;
/* 文本颜色为黑色 */
background: rgba(246, 211, 101, 0.5);
/* 背景颜色与整个网页的背景颜色差不多 */
border: 2px solid transparent;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
display: flex;
align-items: center;
}
.status-alive {
border-color: green;
/* 当状态为“在线中”时,边框颜色为绿色 */
}
.status-offline {
border-color: red;
/* 当状态为“离线了”时,边框颜色为红色 */
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 5px;
display: inline-block;
/* 确保始终显示 */
}
.status-dot-alive {
background-color: green;
}
.status-dot-offline {
background-color: red;
}
/* 手机端适配样式 */
@media (max-width: 600px) {
body {
padding: 10px;
}
.container {
padding: 20px;
}
.info-module {
flex-direction: column;
/* 垂直排列 */
}
.avatar-nickname {
flex-direction: column;
/* 垂直排列 */
align-items: center;
margin-bottom: 10px;
}
.nickname-container {
align-items: center;
margin-left: 0;
}
.status-indicator {
margin-top: 10px;
}
#window-title,
#server-title {
flex-direction: column;
/* 垂直排列 */
}
#window-title .left-label,
#server-title .left-label {
margin-bottom: 5px;
}
#window-title .right-content,
#server-title .right-content {
text-align: center;
}
}
</style>
<link rel="icon" type="image/webp" href="http://virelyx.com/wp-content/uploads/2024/12/65a799ce09060f728193a3146c6d0f15.webp">
</head>
<body>
<div class="container">
<h1>Riseforever在线监测</h1>
<div class="info-module">
<div class="avatar-nickname">
<img class="avatar" src="https://cravatar.cn/avatar/302380667bdaf4e1390800e62494d4af?s=400&d=mp" alt="Avatar">
<div class="nickname-container">
<span class="nickname">Riseforever</span>
<span class="sub-title" id="sub-title">目前离线,有事请留言。</span> <!-- 添加副标题 -->
</div>
</div>
<div id="status-indicator" class="status-offline">
<div class="status-dot status-dot-offline"></div>
离线了
</div>
</div>
<div id="window-title">
<span class="left-label">💻RsvのLaptop</span>
<span class="right-content" id="window-title-content" onclick="showFullContent(this)">加载中...</span>
</div>
<div id="server-title">
<span class="left-label">💻RsvのServer</span>
<span class="right-content" id="server-title-content" onclick="showFullContent(this)">加载中...</span>
</div>
<div id="update-time">更新时间:暂无</div>
</div>
<script>
function updateWindowTitle() {
fetch('/get_window')
.then(response => response.text())
.then(data => {
const windowTitleContent = document.querySelector('#window-title .right-content');
if (data.length > 20) { // 修改为 20 字符折叠
windowTitleContent.dataset.fullContent = data;
windowTitleContent.innerText = data.substring(0, 20) + '...';
} else {
windowTitleContent.dataset.fullContent = data;
windowTitleContent.innerText = data;
}
// 根据窗口名称更新状态指示器
const indicator = document.getElementById('status-indicator');
const dot = document.querySelector('.status-dot');
const subTitle = document.getElementById('sub-title');
if (data === '暂未使用') {
indicator.classList.remove('status-alive');
indicator.classList.add('status-offline');
indicator.innerHTML = '<div class="status-dot status-dot-offline"></div>离线了';
subTitle.innerText = '目前离线,有事请留言。';
} else {
indicator.classList.remove('status-offline');
indicator.classList.add('status-alive');
indicator.innerHTML = '<div class="status-dot status-dot-alive"></div>在线中';
subTitle.innerText = '目前在线,可以通过任何可用的联系方式联系本人。';
}
updateServerTitle();
});
}
function updateServerTitle() {
fetch('/get_server_window')
.then(response => response.text())
.then(data => {
const serverTitleContent = document.querySelector('#server-title .right-content');
if (data.length > 20) { // 修改为 20 字符折叠
serverTitleContent.dataset.fullContent = data;
serverTitleContent.innerText = data.substring(0, 20) + '...';
} else {
serverTitleContent.dataset.fullContent = data;
serverTitleContent.innerText = data;
}
// 获取当前时间并格式化为年月日时分秒
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const updateTime = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
document.getElementById('update-time').innerText = `更新时间:${updateTime}`;
});
}
function showFullContent(element) {
const fullContent = element.dataset.fullContent;
alert(`${fullContent}`);
}
// 每3秒更新一次
setInterval(updateWindowTitle, 3000);
updateWindowTitle(); // 立即执行一次
</script>
</body>
</html>
'''
def handle_tcp_connection():
"""处理 TCP 连接,接收特定格式的消息并更新全局变量"""
global received_message, has_connection
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.bind(('0.0.0.0', 19198))
server_socket.listen(1)
logging.info("TCP server started on port 19198")
while True:
try:
conn, addr = server_socket.accept()
has_connection = True
logging.info(f"Connected by {addr}")
try:
data = conn.recv(1024)
try:
# 使用 UTF - 8 编码进行解码
decoded_data = data.decode('utf-8')
except UnicodeDecodeError:
logging.error("Failed to decode data using UTF-8. Skipping...")
continue
if decoded_data.startswith("NewForm{}"):
message_content = decoded_data[9:]
with lock:
received_message = message_content
logging.info(f"Received message: {received_message}")
except Exception as e:
logging.error(f"Error handling TCP connection: {e}")
finally:
conn.close()
has_connection = False
except Exception as e:
logging.error(f"Error accepting connection: {e}")
@app.route('/')
def home():
"""主页路由"""
return render_template_string(HTML_TEMPLATE)
@app.route('/get_window')
def get_window():
"""返回 TCP 服务器接收到的消息的 API"""
with lock:
return received_message
@app.route('/get_status')
def get_status():
"""返回 TCP 服务器连接状态的 API"""
global has_connection
return 'alive' if has_connection else 'offline'
def get_active_window_title():
"""获取当前活动窗口的标题"""
user32 = ctypes.windll.user32
h_wnd = user32.GetForegroundWindow()
length = user32.GetWindowTextLengthW(h_wnd)
buff = ctypes.create_unicode_buffer(length + 1)
user32.GetWindowTextW(h_wnd, buff, length + 1)
title = buff.value
return title if title else "暂未使用"
@app.route('/get_server_window')
def get_server_window():
"""返回当前最前方应用程序的窗口名称的 API"""
try:
window_title = get_active_window_title()
return window_title
except Exception as e:
logging.error(f"Error getting server window title: {e}")
return "获取失败"
if __name__ == '__main__':
# 启动 TCP 服务器线程
tcp_thread = threading.Thread(target=handle_tcp_connection, daemon=True)
tcp_thread.start()
# 启动 Flask 服务器
app.run(host='0.0.0.0', port=5000, debug=False)
调试
不得不说,调试这一过程是真的艰巨。在开发过程中,我遇到了包括但不限于编码不一致、端口号填错、HTML标签写错等低级错误。多亏了DS,毫不嫌弃地帮助我(虽然它不会嫌弃),使我在2天之内将这个项目调试完并成功部署上线。
功能介绍
RStatus主要以网页端为主。
网页端
访问项目网址,你会发现其整体为一个窗口。上方是站长的个人信息(头像,昵称)以及在线状态,下方是站长在线的设备以及当前的活动。最下方是更新时间,每3秒会刷新一次列表。

值得一提的是,这个列表在1.0版本中是静态的,只能固定接收名为“RsvのLaptop”的活动名称;在2.0,列表全新升级为动态列表,支持在服务器的可承载范围内无限增加监控设备数。

在1.0版本中,如果列表项目过多则会使整个窗口硬生生被拖长,影响美观;在2.0版本中,为列表加入了进度条,所有内容都在窗口内,避免了因页面整体滚动造成的不美观。
客户端
客户端的作用是检测当前设备的顶端窗口名称并上报给服务器端。其界面是这样的:

至于各个控件的作用,都是与服务器端一一对应的,这里我就不过多解释了。
体验
即将开源,敬请期待!
后记
原本,我一直在构思:自己应该做一个什么项目好。没想到,一次意外想法竟然造就了我的第一个项目。真是一次奇妙的旅程。
参与讨论