Files
astrbot_bdzxb_bill/tools/zhangdan_flask.py
T
bdzxb 3e277afddc modified: .idea/misc.xml
modified:   README.md
	modified:   __pycache__/main.cpython-312.pyc
	modified:   _conf_schema.json
	new file:   image.png
	modified:   main.py
	deleted:    static/files/ceshi.txt
	deleted:    static/img/server-icon.png
	new file:   tools/.flask.pid
	new file:   tools/__pycache__/mail_tool.cpython-312.pyc
	new file:   tools/__pycache__/zhangdan_flask.cpython-312.pyc
	new file:   tools/mail_tool.py
	new file:   tools/zhangdan_flask.py
2026-05-16 15:11:33 +08:00

1063 lines
30 KiB
Python

#!/usr/bin/env python3
"""
账单查询服务 - Flask版
从 zhangdan.db 读取数据,提供网页展示和API接口
"""
import sqlite3
import os
import secrets
import threading
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, port, debug):
"""在子线程中启动 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()]
conn.close()
return jsonify({
"overview": overview,
"by_type": by_type,
"by_channel": by_channel,
"by_wallet": by_wallet,
})
@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-label { font-size: 0.9em; }
.summary-item .s-meta { font-size: 0.75em; 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-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>
<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 items = summaryData.by_wallet || [];
grid.innerHTML = items.map(r => {
const isIn = r.payto === "收入";
return `<div class="summary-item ${isIn ? 'income' : 'expense'}">
<div>
<div class="s-label">${r.paytype || r.payqd || r.paybox || "未知"}</div>
<div class="s-meta">${r.count} 笔 · ${r.payto}</div>
</div>
<div class="s-value" style="color:${isIn ? '#4ade80' : '#f87171'}">
¥${Number(r.total).toFixed(2)}
</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>
"""