3e277afddc
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
1063 lines
30 KiB
Python
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>
|
|
"""
|