Files
astrbot_bdzxb_bill/tools/zhangdan_flask.py
T
bdzxb ac548ae29d modified: main.py
modified:   tools/zhangdan_flask.py
2026-05-17 08:39:01 +08:00

1134 lines
32 KiB
Python

#!/usr/bin/env python3
"""
账单查询服务 - Flask版
从 zhangdan.db 读取数据,提供网页展示和API接口
"""
import sqlite3
import os
import sys
import signal
import hashlib
import secrets
import threading
import time
from urllib.request import urlopen
from urllib.error import URLError
from flask import Flask, render_template_string, jsonify, request, session, redirect, url_for
from flask_cors import CORS
# ===== 🔧 常量 =====
PORT = 5001
PID_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".flask.pid")
# ===== 🔧 全局状态 =====
_server = None # werkzeug server 实例
_server_thread = None # 服务线程
_server_lock = threading.Event()
# 启动
def runwebui(web_port,web_passwd,datadb):
global AUTH_PASSWORD,DB_PATH,AUTH_COOKIE_MAX_AGE
try:
AUTH_PASSWORD = web_passwd
AUTH_COOKIE_MAX_AGE = 86400 * 1 # 1天免登录
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), datadb)
start_server(host="0.0.0.0", port=web_port, debug=False )
except EOFError as e:
print(e)
def start_server(host="0.0.0.0", port=PORT, debug=False):
"""在子线程中启动 Flask 服务,主线程不受影响"""
global _server, _server_thread
if _server_thread and _server_thread.is_alive():
print("⚠️ 服务已在运行中")
return _server
from werkzeug.serving import make_server
_server = make_server(host, port, app, threaded=True)
_server_thread = threading.Thread(target=_server.serve_forever, daemon=True)
_server_thread.start()
_server_lock.set()
# 写 PID
with open(PID_FILE, "w") as f:
f.write(str(os.getpid()))
print(f"🚀 服务已启动 (子线程) → http://{host}:{port}")
return _server
def stop_server():
"""只关闭 Flask 子线程,主线程继续运行"""
global _server, _server_thread
if _server:
try:
_server.shutdown() # 优雅关闭 werkzeug server
except Exception:
pass
_server = None
if _server_thread and _server_thread.is_alive():
_server_thread.join(timeout=3)
_server_thread = None
_server_lock.clear()
# 清理 PID 文件
if os.path.exists(PID_FILE):
try:
os.remove(PID_FILE)
except OSError:
pass
print("🛑 Flask 服务已关闭(主线程仍在运行)")
def server_status():
"""检查 Flask 子线程是否在运行"""
alive = _server_thread is not None and _server_thread.is_alive()
return {"running": alive, "port": PORT}
app = Flask(__name__)
app.secret_key = secrets.token_hex(32)
CORS(app)
def check_auth():
"""检查是否已登录"""
return session.get("authenticated") is True
def login_required(f):
"""登录装饰器"""
from functools import wraps
@wraps(f)
def decorated(*args, **kwargs):
if not check_auth():
return redirect(url_for("login_page"))
return f(*args, **kwargs)
return decorated
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
@app.route("/login", methods=["GET", "POST"])
def login_page():
error = None
if request.method == "POST":
pwd = request.form.get("password", "")
if pwd == AUTH_PASSWORD:
session["authenticated"] = True
session.permanent = True
app.permanent_session_lifetime = __import__("datetime").timedelta(seconds=AUTH_COOKIE_MAX_AGE)
return redirect(url_for("index"))
error = "密码不对哦,再试试 🤔"
return render_template_string(LOGIN_TEMPLATE, error=error)
@app.route("/logout")
def logout():
session.clear()
return redirect(url_for("login_page"))
# ===== 🔴 关机密令 =====
SHUTDOWN_TOKEN = "shutdown_2026"
@app.route("/shutdown")
def shutdown_server():
"""通过密令关闭服务,访问 /shutdown?token=xxx 即可关机"""
token = request.args.get("token", "")
if token != SHUTDOWN_TOKEN:
return "❌ 无效密令", 403
# 在新线程中关闭,避免阻塞当前请求
threading.Thread(target=stop_server, daemon=True).start()
return "🛑 服务已关闭"
@app.route("/")
@login_required
def index():
return render_template_string(HTML_TEMPLATE, shutdown_token=SHUTDOWN_TOKEN)
@app.route("/api/bills")
@login_required
def api_bills():
"""返回所有账单数据,支持排序和筛选"""
conn = get_db()
cur = conn.cursor()
# 筛选参数
payto = request.args.get("payto") # 收入/支出
payqd = request.args.get("payqd") # 渠道
paybox = request.args.get("paybox") # 钱包
paytype = request.args.get("paytype") # 交易类型
keyword = request.args.get("keyword") # 搜索关键词
date_from = request.args.get("date_from") # 起始日期
date_to = request.args.get("date_to") # 截止日期
# 排序参数
sort = request.args.get("sort", "paydate")
order = request.args.get("order", "DESC")
allowed_sorts = {
"paydate", "paysize", "paytype", "payname",
"payto", "paystu", "paybox", "payqd", "id"
}
if sort not in allowed_sorts:
sort = "paydate"
order = "ASC" if order.upper() == "ASC" else "DESC"
query = "SELECT * FROM zhangdan WHERE 1=1"
params = []
if payto:
query += " AND payto = ?"
params.append(payto)
if payqd:
query += " AND payqd = ?"
params.append(payqd)
if paybox:
query += " AND paybox = ?"
params.append(paybox)
if paytype:
query += " AND paytype = ?"
params.append(paytype)
if keyword:
query += " AND (payname LIKE ? OR paytype LIKE ?)"
params.extend([f"%{keyword}%", f"%{keyword}%"])
if date_from:
query += " AND paydate >= ?"
params.append(date_from)
if date_to:
query += " AND paydate <= ?"
params.append(date_to + " 23:59:59")
# paysize 存的是文本,需要特殊处理排序
if sort == "paysize":
query += f" ORDER BY CAST({sort} AS REAL) {order}"
else:
query += f" ORDER BY {sort} {order}"
cur.execute(query, params)
rows = [dict(r) for r in cur.fetchall()]
conn.close()
return jsonify(rows)
@app.route("/api/summary")
@login_required
def api_summary():
"""汇总统计 — 支持与 /api/bills 相同的筛选参数"""
conn = get_db()
cur = conn.cursor()
# 收集筛选条件(与 /api/bills 一致)
payto = request.args.get("payto")
payqd = request.args.get("payqd")
paybox = request.args.get("paybox")
paytype = request.args.get("paytype")
keyword = request.args.get("keyword")
date_from = request.args.get("date_from")
date_to = request.args.get("date_to")
where = "WHERE 1=1"
params = []
if payto:
where += " AND payto = ?"
params.append(payto)
if payqd:
where += " AND payqd = ?"
params.append(payqd)
if paybox:
where += " AND paybox = ?"
params.append(paybox)
if paytype:
where += " AND paytype = ?"
params.append(paytype)
if keyword:
where += " AND (payname LIKE ? OR paytype LIKE ?)"
params.extend([f"%{keyword}%", f"%{keyword}%"])
if date_from:
where += " AND paydate >= ?"
params.append(date_from)
if date_to:
where += " AND paydate <= ?"
params.append(date_to + " 23:59:59")
# 总收入/支出
cur.execute(f"""
SELECT payto,
COUNT(*) as count,
SUM(CAST(paysize AS REAL)) as total
FROM zhangdan {where}
GROUP BY payto
""", params)
overview = [dict(r) for r in cur.fetchall()]
# 按类型汇总
cur.execute(f"""
SELECT paytype, payto,
COUNT(*) as count,
SUM(CAST(paysize AS REAL)) as total
FROM zhangdan {where}
GROUP BY paytype, payto
ORDER BY total DESC
""", params)
by_type = [dict(r) for r in cur.fetchall()]
# 按渠道汇总
cur.execute(f"""
SELECT payqd, payto,
COUNT(*) as count,
SUM(CAST(paysize AS REAL)) as total
FROM zhangdan {where}
GROUP BY payqd, payto
ORDER BY total DESC
""", params)
by_channel = [dict(r) for r in cur.fetchall()]
# 按钱包汇总
cur.execute(f"""
SELECT paybox, payto,
COUNT(*) as count,
SUM(CAST(paysize AS REAL)) as total
FROM zhangdan {where}
GROUP BY paybox, payto
ORDER BY total DESC
""", params)
by_wallet = [dict(r) for r in cur.fetchall()]
# 按交易名称汇总
cur.execute(f"""
SELECT payname, payto,
COUNT(*) as count,
SUM(CAST(paysize AS REAL)) as total
FROM zhangdan {where}
GROUP BY payname, payto
ORDER BY total DESC
""", params)
by_name = [dict(r) for r in cur.fetchall()]
conn.close()
return jsonify({
"overview": overview,
"by_type": by_type,
"by_channel": by_channel,
"by_wallet": by_wallet,
"by_name": by_name,
})
@app.route("/api/filters")
@login_required
def api_filters():
"""获取所有可选的筛选值"""
conn = get_db()
cur = conn.cursor()
result = {}
for col in ["payto", "payqd", "paybox", "paytype"]:
cur.execute(f"SELECT DISTINCT {col} FROM zhangdan ORDER BY {col}")
result[col] = [r[0] for r in cur.fetchall()]
conn.close()
return jsonify(result)
LOGIN_TEMPLATE = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>🔐 验证身份</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #0f0c29, #302b63, #24243e);
color: #e0e0e0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-box {
width: 90%;
max-width: 380px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 20px;
padding: 40px 30px;
text-align: center;
backdrop-filter: blur(12px);
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
}
.lock-icon {
font-size: 3em;
margin-bottom: 16px;
animation: bounce 2s infinite;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
h2 {
font-size: 1.3em;
margin-bottom: 6px;
background: linear-gradient(90deg, #f093fb, #f5576c);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle { font-size: 0.85em; color: #888; margin-bottom: 28px; }
.input-group {
position: relative;
margin-bottom: 20px;
}
.input-group input {
width: 100%;
padding: 14px 44px 14px 16px;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 12px;
color: #e0e0e0;
font-size: 16px; /* 防 iOS 缩放 */
outline: none;
transition: border-color 0.2s;
}
.input-group input:focus {
border-color: #f5576c;
}
.input-group input::placeholder { color: #666; }
.eye-btn {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
font-size: 1.2em;
cursor: pointer;
opacity: 0.5;
transition: opacity 0.2s;
}
.eye-btn:hover { opacity: 1; }
.submit-btn {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #f093fb, #f5576c);
border: none;
border-radius: 12px;
color: #fff;
font-size: 1em;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
letter-spacing: 1px;
}
.submit-btn:hover { opacity: 0.85; transform: translateY(-1px); }
.submit-btn:active { transform: translateY(0); }
.error {
color: #f87171;
font-size: 0.85em;
margin-top: -12px;
margin-bottom: 16px;
animation: shake 0.4s;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-6px); }
75% { transform: translateX(6px); }
}
.footer-note {
margin-top: 24px;
font-size: 0.72em;
color: #555;
}
@media (max-width: 480px) {
.login-box { padding: 30px 20px; }
.lock-icon { font-size: 2.5em; }
}
</style>
</head>
<body>
<div class="login-box">
<div class="lock-icon">🔒</div>
<h2>账单查询</h2>
<p class="subtitle">请输入密码以继续</p>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<form method="POST" action="/login">
<div class="input-group">
<input type="password" id="pwd" name="password" placeholder="输入密码" autofocus autocomplete="off">
<button type="button" class="eye-btn" onclick="togglePwd()">👁</button>
</div>
<button type="submit" class="submit-btn">进入 →</button>
</form>
<p class="footer-note">数据已加密保护 🔐</p>
</div>
<script>
function togglePwd() {
const inp = document.getElementById("pwd");
inp.type = inp.type === "password" ? "text" : "password";
}
// 回车提交
document.getElementById("pwd").addEventListener("keydown", e => {
if (e.key === "Enter") e.target.closest("form").submit();
});
</script>
</body>
</html>
"""
HTML_TEMPLATE = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>💰 账单查询</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #0f0c29, #302b63, #24243e);
color: #e0e0e0;
min-height: 100vh;
}
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
/* 头部 */
header {
text-align: center;
padding: 30px 0 20px;
}
header h1 {
font-size: 2em;
background: linear-gradient(90deg, #f093fb, #f5576c, #fda085);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 8px;
}
header p { color: #aaa; font-size: 0.95em; }
/* 概览卡片 */
.overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin: 24px 0;
}
.card {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 14px;
padding: 20px;
text-align: center;
backdrop-filter: blur(10px);
transition: transform 0.2s;
}
.card:hover { transform: translateY(-3px); }
.card .label { font-size: 0.85em; color: #aaa; margin-bottom: 6px; }
.card .value { font-size: 1.6em; font-weight: 700; }
.card .value.income { color: #4ade80; }
.card .value.expense { color: #f87171; }
.card .value.count { color: #60a5fa; }
/* 筛选栏 */
.filters {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 20px 0;
padding: 16px;
background: rgba(255,255,255,0.04);
border-radius: 12px;
align-items: flex-end;
}
.filter-group { display: flex; flex-direction: column; gap: 4px; }
.filter-group label { font-size: 0.78em; color: #aaa; }
.filter-group select, .filter-group input {
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15);
color: #e0e0e0;
padding: 8px 12px;
border-radius: 8px;
font-size: 0.9em;
outline: none;
min-width: 130px;
}
.filter-group select:focus, .filter-group input:focus {
border-color: #f5576c;
}
.btn {
padding: 8px 18px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 0.9em;
font-weight: 600;
transition: all 0.2s;
}
.btn-primary { background: linear-gradient(135deg, #f093fb, #f5576c); color: #fff; }
.btn-primary:hover { opacity: 0.85; }
.btn-secondary { background: rgba(255,255,255,0.1); color: #ccc; }
.btn-secondary:hover { background: rgba(255,255,255,0.18); }
/* 汇总标签页 */
.tabs {
display: flex;
gap: 8px;
margin: 24px 0 12px;
}
.tab {
padding: 8px 20px;
border-radius: 20px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
color: #aaa;
cursor: pointer;
font-size: 0.88em;
transition: all 0.2s;
}
.tab.active {
background: linear-gradient(135deg, #f093fb, #f5576c);
color: #fff;
border-color: transparent;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.summary-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: rgba(255,255,255,0.05);
border-radius: 10px;
border-left: 3px solid;
}
.summary-item.income { border-left-color: #4ade80; }
.summary-item.expense { border-left-color: #f87171; }
.summary-item .s-left { flex: 1; min-width: 0; }
.summary-item .s-right { text-align: right; flex-shrink: 0; margin-left: 12px; }
.summary-item .s-label { font-size: 0.9em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.summary-item .s-meta { font-size: 0.75em; color: #888; }
.summary-item .s-count { font-size: 1.1em; font-weight: 700; color: #60a5fa; }
.summary-item .s-count span { font-size: 0.7em; font-weight: 400; color: #888; }
.summary-item .s-value { font-weight: 700; font-size: 1.05em; }
/* 表格 */
.table-wrapper {
overflow-x: auto;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.08);
margin-top: 16px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.88em;
}
th {
background: rgba(255,255,255,0.08);
padding: 12px 14px;
text-align: left;
cursor: pointer;
white-space: nowrap;
user-select: none;
position: sticky;
top: 0;
}
th:hover { background: rgba(255,255,255,0.12); }
th .sort-arrow { margin-left: 4px; font-size: 0.8em; opacity: 0.5; }
th.sorted .sort-arrow { opacity: 1; color: #f5576c; }
td {
padding: 10px 14px;
border-top: 1px solid rgba(255,255,255,0.05);
}
tr:hover td { background: rgba(255,255,255,0.03); }
.amount { font-weight: 600; font-variant-numeric: tabular-nums; }
.amount.income { color: #4ade80; }
.amount.expense { color: #f87171; }
.tag {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
font-size: 0.82em;
font-weight: 500;
}
.tag-income { background: rgba(74,222,128,0.15); color: #4ade80; }
.tag-expense { background: rgba(248,113,113,0.15); color: #f87171; }
.empty { text-align: center; padding: 40px; color: #666; }
.footer { text-align: center; padding: 30px 0 20px; color: #555; font-size: 0.82em; }
/* ===== 响应式:手机端适配 ===== */
@media (max-width: 768px) {
.container { padding: 12px; }
/* 头部 */
header { padding: 18px 0 12px; }
header h1 { font-size: 1.4em; }
header p { font-size: 0.82em; }
/* 概览卡片:两列 */
.overview {
grid-template-columns: 1fr 1fr;
gap: 10px;
margin: 14px 0;
}
.card { padding: 14px 10px; border-radius: 10px; }
.card .label { font-size: 0.78em; }
.card .value { font-size: 1.3em; }
/* 筛选栏 */
.filters {
padding: 12px;
gap: 8px;
margin: 14px 0;
border-radius: 10px;
}
.filter-group { flex: 1 1 calc(50% - 8px); min-width: 0; }
.filter-group label { font-size: 0.72em; }
.filter-group select,
.filter-group input {
padding: 10px 10px;
font-size: 14px; /* 防止 iOS 缩放 */
min-width: 0;
width: 100%;
border-radius: 8px;
-webkit-appearance: none;
appearance: none;
}
.filter-group select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23aaa'%3E%3Cpath d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 28px;
}
.btn {
flex: 1 1 calc(50% - 8px);
padding: 12px;
font-size: 0.9em;
border-radius: 10px;
}
.btn-primary, .btn-secondary { text-align: center; }
/* 标签页 */
.tabs {
gap: 6px;
margin: 16px 0 10px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.tabs::-webkit-scrollbar { display: none; }
.tab {
padding: 8px 14px;
font-size: 0.82em;
white-space: nowrap;
flex-shrink: 0;
border-radius: 16px;
}
/* 汇总网格:单列 */
.summary-grid {
grid-template-columns: 1fr;
gap: 8px;
}
.summary-item { padding: 10px 12px; border-radius: 8px; }
.summary-item .s-label { font-size: 0.85em; }
.summary-item .s-count { font-size: 0.95em; }
.summary-item .s-value { font-size: 0.95em; }
/* 表格 */
.table-wrapper {
border-radius: 10px;
margin-top: 12px;
-webkit-overflow-scrolling: touch;
}
table { font-size: 0.8em; }
th { padding: 10px 10px; font-size: 0.88em; }
td { padding: 8px 10px; }
.tag { font-size: 0.75em; padding: 2px 6px; }
/* 空状态 & 页脚 */
.empty { padding: 30px 20px; font-size: 0.9em; }
.footer { padding: 20px 0 16px; }
}
/* 超小屏 (< 380px) */
@media (max-width: 380px) {
header h1 { font-size: 1.2em; }
.overview { grid-template-columns: 1fr; }
.card .value { font-size: 1.15em; }
.filter-group { flex: 1 1 100%; }
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>💰 账单查询</h1>
<p>数据来源: zhangdan.db · Flask API 驱动</p>
</header>
<div class="overview" id="overview"></div>
<div class="tabs" id="summaryTabs">
<div class="tab active" data-tab="type" onclick="switchTab('type')">按类型</div>
<div class="tab" data-tab="channel" onclick="switchTab('channel')">按渠道</div>
<div class="tab" data-tab="wallet" onclick="switchTab('wallet')">按钱包</div>
<div class="tab" data-tab="name" onclick="switchTab('name')">按名称</div>
</div>
<div class="summary-grid" id="summaryGrid"></div>
<div class="filters">
<div class="filter-group">
<label>🔍 关键词</label>
<input type="text" id="fKeyword" placeholder="搜索名称/类型...">
</div>
<div class="filter-group">
<label>📊 类型</label>
<select id="fPayto"><option value="">全部</option></select>
</div>
<div class="filter-group">
<label>📱 渠道</label>
<select id="fQd"><option value="">全部</option></select>
</div>
<div class="filter-group">
<label>👛 钱包</label>
<select id="fBox"><option value="">全部</option></select>
</div>
<div class="filter-group">
<label>💳 交易方式</label>
<select id="fType"><option value="">全部</option></select>
</div>
<div class="filter-group">
<label>📅 起始</label>
<input type="date" id="fDateFrom">
</div>
<div class="filter-group">
<label>📅 截止</label>
<input type="date" id="fDateTo">
</div>
<button class="btn btn-primary" onclick="loadBills()">查询</button>
<button class="btn btn-secondary" onclick="resetFilters()">重置</button>
</div>
<div class="table-wrapper">
<table>
<thead id="thead"></thead>
<tbody id="tbody"></tbody>
</table>
</div>
<div class="empty" id="emptyMsg" style="display:none;">暂无数据 🤷</div>
<div class="footer">
· Flask + SQLite · <span id="totalCount">0</span> 条记录
· <a href="/logout" style="color:#f5576c;text-decoration:none;">退出登录 🔒</a>
· <a href="javascript:shutdown()" style="color:#f87171;text-decoration:none;font-weight:600;">关闭服务 ⏻</a>
</div>
</div>
<script>
const COLUMNS = [
{ key: "paydate", label: "日期" },
{ key: "paytype", label: "交易类型" },
{ key: "payname", label: "交易名称" },
{ key: "payto", label: "收支" },
{ key: "paysize", label: "金额" },
{ key: "paystu", label: "状态" },
{ key: "paybox", label: "钱包" },
{ key: "payqd", label: "渠道" },
];
let currentSort = "paydate";
let currentOrder = "DESC";
let summaryData = {};
let activeTab = "type";
// 初始化
async function init() {
await loadFilters();
await loadBills(); // 内部已联动 loadSummary
}
async function loadFilters() {
const res = await fetch("/api/filters");
const data = await res.json();
fillSelect("fPayto", data.payto);
fillSelect("fQd", data.payqd);
fillSelect("fBox", data.paybox);
fillSelect("fType", data.paytype);
}
function fillSelect(id, options) {
const sel = document.getElementById(id);
const first = sel.options[0];
sel.innerHTML = "";
sel.appendChild(first);
options.forEach(o => {
const opt = document.createElement("option");
opt.value = o;
opt.textContent = o;
sel.appendChild(opt);
});
}
// 提取当前筛选参数(共用)
function getFilterParams() {
const params = new URLSearchParams();
const kw = document.getElementById("fKeyword").value.trim();
const payto = document.getElementById("fPayto").value;
const qd = document.getElementById("fQd").value;
const box = document.getElementById("fBox").value;
const ptype = document.getElementById("fType").value;
const dFrom = document.getElementById("fDateFrom").value;
const dTo = document.getElementById("fDateTo").value;
if (kw) params.set("keyword", kw);
if (payto) params.set("payto", payto);
if (qd) params.set("payqd", qd);
if (box) params.set("paybox", box);
if (ptype) params.set("paytype", ptype);
if (dFrom) params.set("date_from", dFrom);
if (dTo) params.set("date_to", dTo);
return params;
}
async function loadBills() {
const params = getFilterParams();
params.set("sort", currentSort);
params.set("order", currentOrder);
const res = await fetch("/api/bills?" + params.toString());
const rows = await res.json();
renderTable(rows);
// 联动刷新汇总
await loadSummary();
}
function renderTable(rows) {
// 表头
const thead = document.getElementById("thead");
thead.innerHTML = "<tr>" + COLUMNS.map(c => {
const arrow = currentSort === c.key
? (currentOrder === "ASC" ? "▲" : "▼")
: "⇅";
const cls = currentSort === c.key ? " class='sorted'" : "";
return `<th${cls} onclick="sortBy('${c.key}')">${c.label} <span class="sort-arrow">${arrow}</span></th>`;
}).join("") + "</tr>";
// 表体
const tbody = document.getElementById("tbody");
const empty = document.getElementById("emptyMsg");
document.getElementById("totalCount").textContent = rows.length;
if (rows.length === 0) {
tbody.innerHTML = "";
empty.style.display = "block";
return;
}
empty.style.display = "none";
tbody.innerHTML = rows.map(r => {
const isIn = r.payto === "收入";
const amtClass = isIn ? "income" : "expense";
const tagClass = isIn ? "tag-income" : "tag-expense";
return `<tr>
<td>${r.paydate || ""}</td>
<td>${r.paytype || ""}</td>
<td title="${r.payname || ""}">${truncate(r.payname || "", 30)}</td>
<td><span class="tag ${tagClass}">${r.payto || ""}</span></td>
<td class="amount ${amtClass}">¥${Number(r.paysize || 0).toFixed(2)}</td>
<td>${r.paystu || ""}</td>
<td>${r.paybox || ""}</td>
<td>${r.payqd || ""}</td>
</tr>`;
}).join("");
}
function truncate(s, n) {
return s.length > n ? s.slice(0, n) + "…" : s;
}
function sortBy(key) {
if (currentSort === key) {
currentOrder = currentOrder === "ASC" ? "DESC" : "ASC";
} else {
currentSort = key;
currentOrder = key === "paydate" ? "DESC" : "ASC";
}
loadBills();
}
function resetFilters() {
document.getElementById("fKeyword").value = "";
document.getElementById("fPayto").value = "";
document.getElementById("fQd").value = "";
document.getElementById("fBox").value = "";
document.getElementById("fType").value = "";
document.getElementById("fDateFrom").value = "";
document.getElementById("fDateTo").value = "";
loadBills();
}
// 汇总
async function loadSummary() {
const params = getFilterParams();
const res = await fetch("/api/summary?" + params.toString());
summaryData = await res.json();
renderOverview();
renderSummary(activeTab);
}
function renderOverview() {
const ov = summaryData.overview || [];
let income = 0, expense = 0, incCount = 0, expCount = 0;
ov.forEach(r => {
if (r.payto === "收入") { income = r.total; incCount = r.count; }
if (r.payto === "支出") { expense = r.total; expCount = r.count; }
});
document.getElementById("overview").innerHTML = `
<div class="card">
<div class="label">总收入</div>
<div class="value income">¥${income.toFixed(2)}</div>
<div class="label">${incCount} 笔</div>
</div>
<div class="card">
<div class="label">总支出</div>
<div class="value expense">¥${expense.toFixed(2)}</div>
<div class="label">${expCount} 笔</div>
</div>
<div class="card">
<div class="label">净额</div>
<div class="value" style="color:${income - expense >= 0 ? '#4ade80' : '#f87171'}">
¥${(income - expense).toFixed(2)}
</div>
<div class="label">${incCount + expCount} 笔合计</div>
</div>
`;
}
function switchTab(tab) {
activeTab = tab;
document.querySelectorAll(".tab").forEach(t => t.classList.remove("active"));
document.querySelector(`.tab[data-tab="${tab}"]`).classList.add("active");
renderSummary(tab);
}
function renderSummary(tab) {
const grid = document.getElementById("summaryGrid");
let items = [];
if (tab === "type") items = summaryData.by_type || [];
else if (tab === "channel") items = summaryData.by_channel || [];
else if (tab === "wallet") items = summaryData.by_wallet || [];
else if (tab === "name") items = summaryData.by_name || [];
grid.innerHTML = items.map(r => {
const isIn = r.payto === "收入";
const label = r.paytype || r.payqd || r.paybox || r.payname || "未知";
return `<div class="summary-item ${isIn ? 'income' : 'expense'}">
<div class="s-left">
<div class="s-label">${label}</div>
<div class="s-meta">${r.payto}</div>
</div>
<div class="s-right">
<div class="s-count">${r.count} <span>笔</span></div>
<div class="s-value" style="color:${isIn ? '#4ade80' : '#f87171'}">¥${Number(r.total).toFixed(2)}</div>
</div>
</div>`;
}).join("");
}
// 🔴 关闭服务
function shutdown() {
if (!confirm("⚠️ 确定要关闭账单查询服务吗?\n关闭后需要手动重新启动。")) return;
fetch("/shutdown?token={{ shutdown_token }}")
.then(r => r.text())
.then(t => {
document.body.innerHTML = `
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;
background:linear-gradient(135deg,#0f0c29,#302b63,#24243e);color:#e0e0e0;
font-family:system-ui;text-align:center;">
<div>
<div style="font-size:4em;margin-bottom:16px;">🛑</div>
<h2>${t}</h2>
<p style="color:#888;margin-top:12px;">服务已停止,可以关闭此页面</p>
</div>
</div>`;
})
.catch(() => alert("关闭程序已启动"));
}
init();
</script>
</body>
</html>
"""
# if __name__ == "__main__":
# command = sys.argv[1] if len(sys.argv) > 1 else "start"
# # ---------- stop ----------
# if command == "stop":
# # 尝试通过 HTTP 调 shutdown 接口
# try:
# urlopen(f"http://localhost:{PORT}/shutdown?token={SHUTDOWN_TOKEN}", timeout=3)
# print("🛑 已发送关闭指令")
# except URLError:
# print("⚠️ 服务未运行或无法连接")
# sys.exit(0)
# # ---------- status ----------
# elif command == "status":
# info = server_status()
# if info["running"]:
# print(f"✅ Flask 服务运行中 → http://localhost:{info['port']}")
# else:
# print("❌ Flask 服务未运行")
# sys.exit(0)
# # ---------- restart ----------
# elif command == "restart":
# stop_server()
# time.sleep(0.5)
# print("🔄 正在重启...")
# # ---------- start (默认) ----------
# elif command != "start":
# print(f"用法: python3 {sys.argv[0]} [start|stop|restart|status]")
# sys.exit(1)
# # 启动 Flask 子线程
# start_server(host="0.0.0.0", port=PORT, debug=True)
# # 主线程继续做其他事(不会被 Flask 阻塞)
# print("📌 主线程继续运行中... (Ctrl+C 退出)")
# try:
# while True:
# time.sleep(1)
# except KeyboardInterrupt:
# print("\n👋 主线程退出")
# stop_server()