feat: 提交小工具集合网站完整源码

- Flask 框架搭建的多功能工具平台
- 包含 base64编解码、Excel合并、文件转换、图片压缩、JSON工具
- 邮件通知、智能数据、临时文本、URL编解码、文本比对等功能
- uWSGI 部署配置
main
Yala 1 month ago
parent 899ea7a111
commit c686e18776

178
.gitignore vendored

@ -1,162 +1,42 @@
# ---> Python
# Byte-compiled / optimized / DLL files
# OS files
.DS_Store
Thumbs.db
ehthumbs.db
Desktop.ini
# IDE
.idea/
*.swp
*.swo
*~
# Python
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
dist/
build/
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
# Virtualenv
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
env/
.env
# Pyre type checker
.pyre/
# Logs
logs/
*.log
# pytype static type analyzer
.pytype/
# Uploads
uploads/
# Cython debug symbols
cython_debug/
# Output
output/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Config
config.json
# Git
*.orig

295
app.py

@ -0,0 +1,295 @@
import subprocess
from flask import Flask, render_template, request, send_file, session, redirect, url_for
from datetime import datetime
from common.logger import tool_logger, error_logger, system_logger
import os, uuid
# =========================
# 工具模块导入
# =========================
from tools.file_convert import file_convert_page, file_convert_download
from tools.temp_upload import (
temp_upload_page,
download_temp_file
)
from tools.excel_merge import (
read_file,
merge_df,
export_excel,
build_preview
)
from tools.smart_data_v2 import (
smart_data_page_v2,
smart_download_v2
)
from tools.weekly_permission import weekly_permission_page
# ⭐ 新增:邮件通知工具
from tools.mail_notify import mail_notify_page, mail_notify_progress
# ⭐ 新增Base64 编解码工具
from tools.base64_codec import page as base64_page
# ⭐ 新增JSON 格式化工具
from tools.json_tool import page as json_page
# ⭐ 新增URL 编解码工具
from tools.url_codec import page as url_page
# ⭐ 新增:图片压缩工具
from tools.image_compress import page as image_compress_page
# ⭐ 新增:文本差异对比工具
from tools.text_diff import page as text_diff_page
# =========================
# Flask app
# =========================
app = Flask(__name__)
app.secret_key = "excel_merge_secret_key"
UPLOAD_FOLDER = "uploads"
OUTPUT_FOLDER = "output"
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
os.makedirs(OUTPUT_FOLDER, exist_ok=True)
# =========================
# reset
# =========================
@app.route("/reset")
def reset():
session.clear()
return redirect(url_for("index"))
# =========================
# 首页
# =========================
@app.route("/")
def index():
return render_template("index.html")
# =========================
# Excel merge原逻辑不变
# =========================
def handle_excel_merge():
preview_data = None
download_file = None
message = None
if request.method == "POST":
try:
file1 = request.files.get("file1")
file2 = request.files.get("file2")
if file1 and file1.filename:
p1 = os.path.join(UPLOAD_FOLDER, f"{uuid.uuid4()}_{file1.filename}")
file1.save(p1)
session["file1_path"] = p1
session["file1_name"] = file1.filename
if file2 and file2.filename:
p2 = os.path.join(UPLOAD_FOLDER, f"{uuid.uuid4()}_{file2.filename}")
file2.save(p2)
session["file2_path"] = p2
session["file2_name"] = file2.filename
if not session.get("file1_path") or not session.get("file2_path"):
raise Exception("请上传两个文件")
key1 = request.form.get("key1", "").strip()
key2 = request.form.get("key2", "").strip()
join_type = request.form.get("join_type", "left")
session["key1"] = key1
session["key2"] = key2
session["join_type"] = join_type
df1 = read_file(session["file1_path"])
df2 = read_file(session["file2_path"])
merged_df = merge_df(df1, df2, key1, key2, join_type)
out_name = f"merged_{uuid.uuid4().hex}.xlsx"
out_path = os.path.join(OUTPUT_FOLDER, out_name)
export_excel(merged_df, out_path)
preview_data = build_preview(merged_df)
download_file = out_name
tool_logger.info(
f"[ExcelMerge] file1={session.get('file1_name')} "
f"file2={session.get('file2_name')} rows={len(merged_df)}"
)
message = (
f"整合成功,共 {len(merged_df)} 条记录 "
f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
)
except Exception as e:
error_logger.exception("[ExcelMergeError]")
message = f"错误:{e}"
return render_template(
"excel.html",
preview_data=preview_data,
download_file=download_file,
message=message,
file1_name=session.get("file1_name", ""),
file2_name=session.get("file2_name", ""),
key1=session.get("key1", ""),
key2=session.get("key2", ""),
join_type=session.get("join_type", "left"),
)
# =========================
# download
# =========================
@app.route("/download/<filename>")
def download(filename):
return send_file(
os.path.join(OUTPUT_FOLDER, filename),
as_attachment=True
)
# =========================
# TOOL HUB统一入口
# =========================
TOOL_ROUTES = {}
def tool_dispatch(name):
handler = TOOL_ROUTES.get(name)
if handler:
return handler()
return render_template(f"{name}.html")
@app.route("/tool/mail_notify/progress/<task_id>")
def mail_progress(task_id):
return mail_notify_progress(task_id)
@app.route("/tool/<name>", methods=["GET", "POST"])
def tool(name):
return tool_dispatch(name)
# =========================
# 工具注册(核心扩展点)
# =========================
TOOL_ROUTES.update({
"excel": handle_excel_merge,
"smart_data_v2": smart_data_page_v2,
"file_convert": file_convert_page,
"weekly_permission": weekly_permission_page,
# ⭐ 新增邮件通知工具
"mail_notify": mail_notify_page,
# ⭐ 新增 Base64 编解码工具
"base64": base64_page,
# ⭐ 新增 JSON 格式化工具
"json": json_page,
# ⭐ 新增 URL 编解码工具
"url": url_page,
# ⭐ 新增图片压缩工具
"image_compress": image_compress_page,
# ⭐ 新增文本差异对比工具
"text_diff": text_diff_page,
})
# =========================
# temp upload
# =========================
@app.route("/tool/temp_upload", methods=["GET", "POST"])
def temp_upload():
return temp_upload_page()
@app.route("/temp/download/<filename>")
def temp_download(filename):
return download_temp_file(filename)
# =========================
# smart data download
# =========================
@app.route("/tool/smart_data_v2/download/<filename>")
def smart_data_v2_download(filename):
return smart_download_v2(filename)
# =========================
# file convert download
# =========================
@app.route("/download/file_convert/<filename>")
def file_convert_download_route(filename):
return file_convert_download(filename)
# =========================
# image compress download
# =========================
@app.route("/tool/image_compress/download/<filename>")
def image_compress_download(filename):
return send_file(os.path.join(OUTPUT_FOLDER, filename), as_attachment=True)
cleanup_process = None
def start_cleanup_task():
"""
启动定时清理任务
"""
global cleanup_process
cleanup_path = os.path.join(
os.path.dirname(__file__),
"cleanup_upload.py"
)
cleanup_process = subprocess.Popen(
["python3", cleanup_path]
)
system_logger.info(
f"[cleanup] started pid="
f"{cleanup_process.pid}"
)
print(
f"[cleanup] started pid="
f"{cleanup_process.pid}"
)
# =========================
# main
# =========================
if __name__ == "__main__":
# 防止 Flask debug 启动两次
if os.environ.get("WERKZEUG_RUN_MAIN") == "true":
start_cleanup_task()
app.run(host="0.0.0.0", port=8209, debug=True)

@ -0,0 +1,135 @@
import os
import time
import shutil
from datetime import datetime
from common.logger import system_logger
# ==========================
# 项目路径
# ==========================
BASE_DIR = os.path.dirname(
os.path.abspath(__file__)
)
# 需要清理的目录
CLEAN_DIRS = [
os.path.join(BASE_DIR, "uploads"),
os.path.join(BASE_DIR, "uploads", "temp")
]
# ==========================
# 清理函数
# ==========================
def clear_dir(path):
if not os.path.exists(path):
system_logger.error(
f"[Cleanup] path not exist: {path}"
)
return
deleted_count = 0
for filename in os.listdir(path):
file_path = os.path.join(path, filename)
try:
if os.path.isfile(file_path):
os.remove(file_path)
deleted_count += 1
elif os.path.isdir(file_path):
shutil.rmtree(file_path)
deleted_count += 1
except Exception as e:
system_logger.exception(
f"[Cleanup] delete failed: {file_path}"
)
system_logger.info(
f"[Cleanup] done path={path} deleted={deleted_count}"
)
# ==========================
# 总清理入口
# ==========================
def clear_all():
system_logger.info(
"[Cleanup] task started"
)
for path in CLEAN_DIRS:
clear_dir(path)
system_logger.info(
"[Cleanup] task finished"
)
# ==========================
# 定时调度
# ==========================
TTL_SECONDS = 3600 # 1小时
def scheduler():
system_logger.info("[Cleanup] TTL scheduler started")
while True:
now = time.time()
for path in CLEAN_DIRS:
if not os.path.exists(path):
continue
deleted = 0
for filename in os.listdir(path):
file_path = os.path.join(path, filename)
try:
if not os.path.isfile(file_path):
continue
# 文件创建时间
file_time = os.path.getmtime(file_path)
if now - file_time > TTL_SECONDS:
os.remove(file_path)
deleted += 1
except Exception as e:
system_logger.exception(f"[Cleanup] failed: {file_path}")
if deleted > 0:
system_logger.info(f"[Cleanup] path={path} deleted={deleted}")
time.sleep(300) # 每5分钟检查一次
# ==========================
# main
# ==========================
if __name__ == "__main__":
system_logger.info(
"[Cleanup] service started"
)
scheduler()

@ -0,0 +1,62 @@
import logging
import os
from logging.handlers import TimedRotatingFileHandler
BASE_DIR = os.path.dirname(
os.path.dirname(os.path.abspath(__file__))
)
LOG_DIR = os.path.join(BASE_DIR, "logs")
os.makedirs(LOG_DIR, exist_ok=True)
def create_logger(name, filename):
logger = logging.getLogger(name)
if logger.handlers:
return logger
logger.setLevel(logging.INFO)
log_path = os.path.join(LOG_DIR, filename)
handler = TimedRotatingFileHandler(
log_path,
when="midnight",
interval=1,
backupCount=30,
encoding="utf-8"
)
handler.suffix = "%Y-%m-%d.log"
formatter = logging.Formatter(
"[%(asctime)s] "
"[%(levelname)s] "
"%(message)s"
)
handler.setFormatter(formatter)
logger.addHandler(handler)
return logger
# 直接可用
system_logger = create_logger(
"system_logger",
"system.log"
)
tool_logger = create_logger(
"tool_logger",
"tool.log"
)
error_logger = create_logger(
"error_logger",
"error.log"
)

@ -0,0 +1,293 @@
#!/usr/bin/env python3
"""
邮件批量通知工具 - CLI
用于在新系统切换时批量给全员发送新的账号口令通知
Usage:
python3 mail_sender.py users.txt
python3 mail_sender.py users.txt --smtp smtp.ctvit.com.cn --port 465
python3 mail_sender.py users.txt --sender zhangguozhong@ctvit.com.cn
TXT 格式每行 邮箱 密码空格 / tab / | / , 都支持
"""
import os
import re
import sys
import time
import argparse
import smtplib
import logging
from datetime import datetime
from email.mime.text import MIMEText
from email.header import Header
# =====================================
# 日志配置
# =====================================
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("MailSender")
# =====================================
# 邮件模板
# =====================================
DEFAULT_TEMPLATE = """亲爱的用户:
您好这是您的账号配置信息
您的邮件账号{email}
您的初始口令{password}
请务必妥善保管您的账号信息不要泄露给他人
如有疑问请联系技术支持部门
---
此邮件为系统自动发送请勿直接回复
"""
# =====================================
# TXT 解析
# =====================================
def parse_users(file_path):
users = []
with open(file_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
parts = re.split(r"[\s\t\|:;]+", line)
if len(parts) < 2:
continue
email = parts[0].strip()
password = parts[1].strip()
if not email or "@" not in email:
logger.warning(f"跳过无效行(非邮箱格式): {line}")
continue
users.append({"email": email, "password": password})
return users
# =====================================
# SMTP 连接(自动探测端口)
# =====================================
def build_smtp(smtp_server, smtp_port, sender_email, sender_pass):
smtp_port = int(smtp_port)
# 465 → SSL
if smtp_port == 465:
server = smtplib.SMTP_SSL(smtp_server, smtp_port, timeout=30)
logger.info(f"📡 连接 {smtp_server}:{smtp_port} (SSL)")
server.login(sender_email, sender_pass)
return server
# 587 → STARTTLS
if smtp_port == 587:
server = smtplib.SMTP(smtp_server, smtp_port, timeout=30)
server.ehlo()
server.starttls()
server.ehlo()
logger.info(f"📡 连接 {smtp_server}:{smtp_port} (STARTTLS)")
server.login(sender_email, sender_pass)
return server
# 25 → 明文
server = smtplib.SMTP(smtp_server, smtp_port, timeout=30)
logger.info(f"📡 连接 {smtp_server}:{smtp_port} (明文)")
server.login(sender_email, sender_pass)
return server
# =====================================
# 单封发送(含重试)
# =====================================
def send_mail(server, sender_email, receiver_email, subject, content, reply_to=None):
msg = MIMEText(content, "plain", "utf-8")
msg["From"] = sender_email
msg["To"] = receiver_email
msg["Subject"] = Header(subject or "邮件通知", "utf-8")
if reply_to:
msg["Reply-To"] = reply_to
server.sendmail(sender_email, [receiver_email], msg.as_string())
def send_with_retry(
server, sender_email, receiver_email,
subject, content, reply_to=None, max_retries=3
):
last_error = None
for attempt in range(1, max_retries + 1):
try:
send_mail(server, sender_email, receiver_email, subject, content, reply_to)
return True, None
except smtplib.SMTPServerDisconnected:
last_error = "连接断开"
if attempt < max_retries:
wait = 2 ** attempt
logger.warning(f" ⚠ 连接断开,{wait}s 后重试 (第{attempt}次)")
time.sleep(wait)
except smtplib.SMTPResponseException as e:
code = e.smtp_code
if 400 <= code < 500 and attempt < max_retries:
last_error = f"SMTP {code}"
wait = 2 ** attempt
logger.warning(f" ⚠ SMTP {code}{wait}s 后重试 (第{attempt}次)")
time.sleep(wait)
else:
return False, f"SMTP {code}: {e}"
except Exception as e:
return False, str(e)
return False, last_error
# =====================================
# 主流程
# =====================================
def main():
parser = argparse.ArgumentParser(
description="📧 邮件批量通知工具 (CLI)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例
python3 mail_sender.py users.txt
python3 mail_sender.py users.txt --port 465 --sender xxx@qq.com
python3 mail_sender.py users.txt --subject "系统账号通知" --delay 2
python3 mail_sender.py users.txt --template my_template.txt
""",
)
parser.add_argument("file", help="用户列表 TXT 文件路径(每行:邮箱 密码)")
parser.add_argument("--smtp", default="smtp.ctvit.com.cn", help="SMTP 服务器地址(默认 smtp.ctvit.com.cn")
parser.add_argument("--port", type=int, default=25, help="SMTP 端口(默认 25常见 465/587")
parser.add_argument("--sender", default="zhangguozhong@ctvit.com.cn", help="发件邮箱")
parser.add_argument("--passwd", help="SMTP 密码/授权码(如不传则交互输入)")
parser.add_argument("--subject", default="账号及口令通知", help="邮件主题")
parser.add_argument("--reply-to", help="回复地址(可选)")
parser.add_argument("--template", help="邮件模板文件路径(替代默认模板,支持 {email} {password}")
parser.add_argument("--delay", type=float, default=1.0, help="每封邮件间隔秒数(默认 1s")
parser.add_argument("--retry", type=int, default=3, help="失败重试次数(默认 3")
parser.add_argument("--test", action="store_true", help="测试模式:只给发件人自己发一封测试邮件")
args = parser.parse_args()
# ─── 检查文件 ───
if not os.path.exists(args.file):
logger.error(f"❌ 文件不存在: {args.file}")
sys.exit(1)
# ─── 读取模板 ───
if args.template:
with open(args.template, "r", encoding="utf-8") as f:
template = f.read()
logger.info(f"📝 使用自定义模板: {args.template}")
else:
template = DEFAULT_TEMPLATE
logger.info("📝 使用默认模板")
# ─── 获取密码 ───
sender_pass = args.passwd
if not sender_pass:
import getpass
sender_pass = getpass.getpass(f"🔑 请输入 {args.sender} 的 SMTP 密码/授权码: ")
# ─── 解析用户列表 ───
users = parse_users(args.file)
if not users:
logger.error("❌ 未识别到有效用户数据")
sys.exit(1)
logger.info(f"📋 共读取到 {len(users)} 个用户")
# ─── 测试发送 ───
if args.test:
content = template.format(email=args.sender, password="123456")
server = None
try:
server = build_smtp(args.smtp, args.port, args.sender, sender_pass)
send_mail(server, args.sender, args.sender, f"[测试] {args.subject}", content, args.reply_to)
logger.info(f"✅ 测试邮件发送成功(已发送至 {args.sender}")
except Exception as e:
logger.error(f"❌ 测试发送失败: {e}")
sys.exit(1)
finally:
if server:
server.quit()
return
# ─── 正式发送 ───
total = len(users)
success_count = 0
failed_count = 0
failed_users = []
server = None
try:
server = build_smtp(args.smtp, args.port, args.sender, sender_pass)
logger.info(f"✅ 登录成功!开始批量发送(共 {total} 封)...")
print("" * 50)
for idx, user in enumerate(users, 1):
email = user["email"]
password = user["password"]
content = template.format(email=email, password=password)
ok, err = send_with_retry(
server, args.sender, email,
args.subject, content,
reply_to=args.reply_to,
max_retries=args.retry,
)
if ok:
success_count += 1
logger.info(f" [{idx}/{total}] ✅ {email}")
else:
failed_count += 1
failed_users.append({"email": email, "error": err})
logger.error(f" [{idx}/{total}] ❌ {email}{err}")
# 发送间隔
if idx < total:
time.sleep(args.delay)
except smtplib.SMTPAuthenticationError:
logger.error("❌ SMTP 认证失败,请检查邮箱地址和密码/授权码是否正确")
sys.exit(1)
except Exception as e:
logger.error(f"❌ 连接错误: {e}")
sys.exit(1)
finally:
if server:
server.quit()
# ─── 汇总 ───
print("" * 50)
logger.info(f"📊 发送汇总:")
logger.info(f" 总 数:{total}")
logger.info(f" 成 功:{success_count}")
logger.info(f" 失 败:{failed_count}")
if failed_users:
print()
logger.warning("📋 失败人员列表:")
for fu in failed_users:
logger.warning(f"{fu['email']}{fu['error']}")
# 写入失败日志
log_path = f"failed_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
with open(log_path, "w", encoding="utf-8") as f:
for fu in failed_users:
f.write(f"{fu['email']}\t{fu['error']}\n")
logger.info(f"📁 失败详情已保存至: {log_path}")
if failed_count == 0:
logger.info("🎉 全部发送成功!")
elif success_count > 0:
logger.info(f"⚠️ 部分失败,请检查失败列表重发")
if __name__ == "__main__":
main()

@ -0,0 +1,238 @@
#!/usr/bin/env python3
"""
邮件批量通知工具 - 简易版
直接运行即可发送无需额外参数
"""
import os
import re
import sys
import time
import smtplib
import logging
from datetime import datetime
from email.mime.text import MIMEText
from email.header import Header
# ===================== 配置区(按需修改) =====================
SMTP_SERVER = "smtp.ctvit.com.cn"
SMTP_PORT = 25
SENDER_EMAIL = "zhangguozhong@ctvit.com.cn"
SENDER_PASS = "&TBJ7p^u$HF9hp"
SUBJECT = "账号及口令通知"
REPLY_TO = "" # 回复地址(可选,留空则不填)
DELAY = 1 # 每封间隔(秒)
MAX_RETRIES = 3 # 失败重试次数
USER_FILE = "users.txt" # 用户列表文件
TEMPLATE_FILE = "" # 自定义模板文件(留空用默认模板)
# =============================================================
DEFAULT_TEMPLATE = """亲爱的用户:
您好这是您的账号配置信息
您的邮件账号{email}
您的初始口令{password}
请务必妥善保管您的账号信息不要泄露给他人
如有疑问请联系技术支持部门
---
此邮件为系统自动发送请勿直接回复"""
# 日志
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("MailSender")
def parse_users(file_path):
users = []
with open(file_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
parts = re.split(r"[\s\t\|:;]+", line)
if len(parts) < 2:
continue
email = parts[0].strip()
password = parts[1].strip()
if not email or "@" not in email:
logger.warning(f"跳过无效行: {line}")
continue
users.append({"email": email, "password": password})
return users
def build_smtp():
if SMTP_PORT == 465:
server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=30)
logger.info(f"📡 连接 {SMTP_SERVER}:{SMTP_PORT} (SSL)")
elif SMTP_PORT == 587:
server = smtplib.SMTP(SMTP_SERVER, SMTP_PORT, timeout=30)
server.ehlo()
server.starttls()
server.ehlo()
logger.info(f"📡 连接 {SMTP_SERVER}:{SMTP_PORT} (STARTTLS)")
else:
server = smtplib.SMTP(SMTP_SERVER, SMTP_PORT, timeout=30)
logger.info(f"📡 连接 {SMTP_SERVER}:{SMTP_PORT} (明文)")
server.login(SENDER_EMAIL, SENDER_PASS)
return server
def send_one(server, receiver_email, content):
msg = MIMEText(content, "plain", "utf-8")
msg["From"] = SENDER_EMAIL
msg["To"] = receiver_email
msg["Subject"] = Header(SUBJECT, "utf-8")
if REPLY_TO:
msg["Reply-To"] = REPLY_TO
server.sendmail(SENDER_EMAIL, [receiver_email], msg.as_string())
def send_with_retry(server, receiver_email, content):
for attempt in range(1, MAX_RETRIES + 1):
try:
send_one(server, receiver_email, content)
return True, None
except smtplib.SMTPServerDisconnected:
if attempt < MAX_RETRIES:
wait = 2 ** attempt
logger.warning(f" ⚠ 连接断开,{wait}s 后重试 (第{attempt}次)")
time.sleep(wait)
except smtplib.SMTPResponseException as e:
code = e.smtp_code
if 400 <= code < 500 and attempt < MAX_RETRIES:
wait = 2 ** attempt
logger.warning(f" ⚠ SMTP {code}{wait}s 后重试 (第{attempt}次)")
time.sleep(wait)
else:
return False, f"SMTP {code}: {e}"
except Exception as e:
return False, str(e)
return False, "重试耗尽"
def main():
logger.info("=" * 50)
logger.info("📧 邮件批量通知工具 启动")
logger.info(f" 发件人:{SENDER_EMAIL}")
logger.info(f" SMTP{SMTP_SERVER}:{SMTP_PORT}")
logger.info(f" 间隔:{DELAY}s  重试{MAX_RETRIES}")
logger.info("=" * 50)
# 1. 检查文件
if not os.path.exists(USER_FILE):
logger.error(f"❌ 文件不存在: {USER_FILE}")
sys.exit(1)
# 2. 读取模板
if TEMPLATE_FILE:
with open(TEMPLATE_FILE, "r", encoding="utf-8") as f:
template = f.read()
logger.info(f"📝 自定义模板: {TEMPLATE_FILE}")
else:
template = DEFAULT_TEMPLATE
logger.info("📝 默认模板")
# 3. 解析用户
users = parse_users(USER_FILE)
if not users:
logger.error("❌ 未识别到有效用户")
sys.exit(1)
logger.info(f"📋 共 {len(users)} 个收件人")
# 4. 测试发送(先发给自己验证)
logger.info("🧪 正在测试发送(发给自己验证)...")
server = None
try:
server = build_smtp()
test_content = template.format(email=SENDER_EMAIL, password="123456")
send_one(server, SENDER_EMAIL, test_content)
logger.info(f"✅ 测试成功!已发送至 {SENDER_EMAIL}")
except smtplib.SMTPAuthenticationError:
logger.error("❌ SMTP 认证失败,请检查密码/授权码")
sys.exit(1)
except Exception as e:
logger.error(f"❌ 测试发送失败: {e}")
logger.error(" 请检查 SMTP 服务器和端口是否正确")
sys.exit(1)
finally:
if server:
try:
server.quit()
except Exception:
pass
# 5. 正式发送
total = len(users)
success = 0
failed = 0
failed_list = []
print()
logger.info(f"🚀 开始批量发送(共 {total} 封)")
print("" * 50)
for idx, user in enumerate(users, 1):
email = user["email"]
content = template.format(email=email, password=user["password"])
server = None
try:
server = build_smtp()
ok, err = send_with_retry(server, email, content)
except smtplib.SMTPAuthenticationError:
logger.error("❌ SMTP 认证失败")
sys.exit(1)
except Exception as e:
ok, err = False, str(e)
finally:
if server:
try:
server.quit()
except Exception:
pass
if ok:
success += 1
logger.info(f" [{idx}/{total}] ✅ {email}")
else:
failed += 1
failed_list.append({"email": email, "error": err})
logger.error(f" [{idx}/{total}] ❌ {email}{err}")
if idx < total:
time.sleep(DELAY)
# 6. 汇总
print("" * 50)
logger.info(f"📊 发送完成")
logger.info(f" 总 数:{total}")
logger.info(f" 成 功:{success}")
logger.info(f" 失 败:{failed}")
if failed_list:
print()
logger.warning("📋 失败人员:")
for f in failed_list:
logger.warning(f"{f['email']}{f['error']}")
log_file = f"failed_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
with open(log_file, "w", encoding="utf-8") as f:
for fu in failed_list:
f.write(f"{fu['email']}\t{fu['error']}\n")
logger.info(f"📁 失败详情已保存: {log_file}")
if failed == 0:
logger.info("🎉 全部发送成功!")
if __name__ == "__main__":
main()

@ -0,0 +1,16 @@
flask
flask-cors
pandas
openpyxl
xlrd
xlsxwriter
python-dotenv
tqdm
chardet
requests
httpx
orjson
pydantic
werkzeug
loguru
rich

@ -0,0 +1,202 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Base64 编解码</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
margin: 0;
padding: 24px 16px;
background: #0b0f14;
color: #e5e7eb;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial;
min-height: 100vh;
}
.box {
max-width: 860px;
margin: auto;
background: rgba(255,255,255,0.04);
padding: 24px;
border-radius: 16px;
border: 1px solid rgba(255,255,255,0.08);
}
.card {
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 14px;
padding: 18px;
margin-bottom: 14px;
}
.card-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
color: #f3f4f6;
}
.tool-label {
font-size: 13px;
color: #9ca3af;
margin-bottom: 8px;
display: block;
}
textarea {
width: 100%;
background: #111827;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 10px;
color: #e5e7eb;
font-size: 14px;
font-family: "SF Mono", "Fira Code", Consolas, monospace;
padding: 12px;
resize: vertical;
outline: none;
transition: border-color 0.2s;
}
textarea:focus { border-color: rgba(59,130,246,0.6); }
textarea::placeholder { color: #6b7280; }
textarea.result-textarea {
background: rgba(0,0,0,0.3);
border-color: rgba(34,197,94,0.3);
color: #4ade80;
}
.btn-row { display: flex; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 18px;
font-size: 14px;
border-radius: 10px;
border: none;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.btn-primary { background: #2563eb; color: white; }
.btn-primary:hover { background: #1d4ed8; }
.btn-secondary { background: rgba(255,255,255,0.08); color: #e5e7eb; border: 1px solid rgba(255,255,255,0.1); }
.btn-secondary:hover { background: rgba(255,255,255,0.12); }
.result-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.copy-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
font-size: 12px;
border-radius: 8px;
background: rgba(34,197,94,0.15);
color: #22c55e;
border: 1px solid rgba(34,197,94,0.3);
cursor: pointer;
transition: all 0.2s;
}
.copy-btn:hover { background: rgba(34,197,94,0.25); }
.copy-btn.copied { background: rgba(34,197,94,0.4); color: #4ade80; }
.toast {
position: fixed;
right: 16px;
bottom: 16px;
background: rgba(17,24,39,0.95);
color: #e5e7eb;
padding: 10px 16px;
border-radius: 10px;
border: 1px solid rgba(34,197,94,0.3);
font-size: 13px;
opacity: 0;
transform: translateY(10px);
transition: all 0.25s ease;
z-index: 9999;
}
.toast.show { opacity: 1; transform: translateY(0); }
@media (max-width: 600px) {
body { padding: 16px 12px; }
.box { padding: 18px 14px; }
.btn-row { flex-direction: column; }
.btn { width: 100%; justify-content: center; }
}
</style>
</head>
<body>
<div class="box">
<div class="card">
<div class="card-title">🔐 Base64 编解码</div>
<label class="tool-label">输入内容</label>
<textarea id="inputText" rows="4" placeholder="输入要编码或解码的文本...">{{ input_text or '' }}</textarea>
<div class="btn-row">
<button class="btn btn-primary" onclick="doEncode()">编码 Encode</button>
<button class="btn btn-secondary" onclick="doDecode()">解码 Decode</button>
</div>
</div>
{% if result %}
<div class="card">
<div class="result-header">
<span class="tool-label" style="margin:0">处理结果</span>
<button class="copy-btn" id="copyBtn" onclick="copyResult()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span id="copyText">复制</span>
</button>
</div>
<textarea id="resultText" class="result-textarea" rows="4" readonly>{{ result }}</textarea>
</div>
{% endif %}
</div>
<div class="toast" id="toast"></div>
<script>
function doEncode() {
const text = document.getElementById('inputText').value;
if (!text.trim()) { showToast('请输入内容'); return; }
window.location.href = '/tool/base64?action=encode_text&text=' + encodeURIComponent(text);
}
function doDecode() {
const text = document.getElementById('inputText').value;
if (!text.trim()) { showToast('请输入内容'); return; }
window.location.href = '/tool/base64?action=decode_text&text=' + encodeURIComponent(text);
}
function copyResult() {
const result = document.getElementById('resultText').value;
if (!result) return;
navigator.clipboard.writeText(result).then(() => {
const btn = document.getElementById('copyBtn');
const text = document.getElementById('copyText');
btn.classList.add('copied');
text.textContent = '已复制!';
showToast('已复制到剪贴板');
setTimeout(() => { btn.classList.remove('copied'); text.textContent = '复制'; }, 2000);
}).catch(() => showToast('复制失败,请手动选择复制'));
}
function showToast(msg) {
const t = document.getElementById('toast');
t.innerText = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2000);
}
</script>
</body>
</html>

@ -0,0 +1,138 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Excel Merge</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
* { box-sizing: border-box; }
body {
margin: 0;
padding: 32px 24px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial;
background: radial-gradient(circle at top, #111827, #0b0f14);
color: #e5e7eb;
}
.card-box {
max-width: 1200px;
margin: auto;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
padding: 32px 28px;
border-radius: 18px;
backdrop-filter: blur(10px);
}
h3 { color: #f3f4f6; margin-bottom: 22px; font-size: 20px; }
label { color: #cbd5e1; font-size: 13px; display: block; margin-bottom: 6px; }
input, select, textarea {
background: rgba(255,255,255,0.06) !important;
border: 1px solid rgba(255,255,255,0.12) !important;
color: #e5e7eb !important;
border-radius: 10px !important;
}
input::placeholder, textarea::placeholder { color: rgba(229,231,235,0.45) !important; }
input:focus, select:focus, textarea:focus {
outline: none;
border-color: rgba(59,130,246,0.8) !important;
box-shadow: 0 0 0 3px rgba(59,130,246,0.15);
}
select option { background: #111827; color: #e5e7eb; }
.btn { margin-right: 10px; margin-top: 8px; }
.btn-primary { background: #2563eb; border: none; }
.btn-primary:hover { background: #1d4ed8; }
.btn-secondary { background: rgba(255,255,255,0.1); border: none; color: #e5e7eb; }
.btn-secondary:hover { background: rgba(255,255,255,0.15); }
.btn-success { background: #16a34a; border: none; }
.btn-success:hover { background: #15803d; }
.file-box {
background: rgba(255,255,255,0.03);
padding: 14px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.06);
margin-bottom: 14px;
}
.preview-box {
max-height: 700px;
overflow: auto;
margin-top: 20px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.08);
}
.preview-table { background: #ffffff; color: #111827; border-radius: 10px; overflow: hidden; min-width: 600px; }
.preview-table thead th { position: sticky; top: 0; background: #f3f4f6; color: #111827; font-size: 13px; padding: 10px 12px; }
.preview-table tbody td { font-size: 13px; padding: 9px 12px; }
.h1c { background: #ffe8e8 !important; }
.h2c { background: #f7e8ff !important; }
.alert { border-radius: 10px; margin-top: 16px; }
@media (max-width: 768px) {
body { padding: 20px 14px; }
.card-box { padding: 22px 18px; }
h3 { font-size: 18px; }
.preview-box { max-height: 450px; }
.btn { width: 100%; margin-right: 0; }
}
</style>
</head>
<body>
<div class="card-box">
<h3>📊 Excel / CSV 文件整合工具</h3>
<form method="post" action="{{ url_for('tool', name='excel') }}" enctype="multipart/form-data">
<div class="row">
<div class="col-md-6 file-box">
<label>文件1 {% if file1_name %}<span style="color:#f87171">✔ {{file1_name}}</span>{% endif %}</label>
<input type="file" name="file1" class="form-control" accept=".xlsx,.xls,.csv">
</div>
<div class="col-md-6 file-box">
<label>文件2 {% if file2_name %}<span style="color:#c084fc">✔ {{file2_name}}</span>{% endif %}</label>
<input type="file" name="file2" class="form-control" accept=".xlsx,.xls,.csv">
</div>
</div>
<br>
<div class="row">
<div class="col-md-4"><input name="key1" value="{{key1}}" placeholder="文件1关联字段" class="form-control" required></div>
<div class="col-md-4"><input name="key2" value="{{key2}}" placeholder="文件2关联字段" class="form-control" required></div>
<div class="col-md-4"><select name="join_type" class="form-select"><option value="left" {% if join_type=='left' %}selected{% endif %}>左连接</option><option value="inner" {% if join_type=='inner' %}selected{% endif %}>内连接</option><option value="outer" {% if join_type=='outer' %}selected{% endif %}>外连接</option></select></div>
</div>
<br>
<button class="btn btn-primary">开始整合</button>
<a href="{{ url_for('reset') }}" class="btn btn-secondary">重新开始</a>
</form>
{% if message %}
<div class="alert {% if '错误' in message %}alert-danger{% else %}alert-success{% endif %}">{{message}}</div>
{% endif %}
{% if preview_data %}
<div class="preview-box">{{preview_data|safe}}</div>
<a href="{{ url_for('download', filename=download_file) }}" class="btn btn-success mt-3">下载Excel</a>
{% endif %}
</div>
</div>
<script>
window.onload=function(){
const table=document.getElementById('previewTable');
if(!table)return;
const k1='{{key1}}',k2='{{key2}}';
let i1=-1,i2=-1;
table.querySelectorAll('thead th').forEach((th,i)=>{const t=th.innerText.trim();if(t===k1){i1=i;th.classList.add('h1c')}if(t===k2){i2=i;th.classList.add('h2c')}});
table.querySelectorAll('tbody tr').forEach(r=>{const tds=r.querySelectorAll('td');if(i1>=0) tds[i1]?.classList.add('h1c');if(i2>=0) tds[i2]?.classList.add('h2c')}});
}
</script>
</body>
</html>

@ -0,0 +1,136 @@
<!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>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
margin: 0;
padding: 24px 16px;
background: #0b0f14;
color: #e5e7eb;
font-family: Arial, -apple-system, sans-serif;
min-height: 100vh;
}
.box {
max-width: 1400px;
margin: auto;
background: rgba(255,255,255,0.04);
padding: 24px;
border-radius: 16px;
border: 1px solid rgba(255,255,255,0.08);
}
h2 {
margin: 0 0 14px 0;
font-size: 20px;
}
input, select {
width: 100%;
padding: 10px 12px;
margin: 8px 0;
background: #111827;
border: 1px solid rgba(255,255,255,0.12);
color: #e5e7eb;
border-radius: 10px;
font-size: 14px;
}
button {
padding: 10px 16px;
background: #2563eb;
border: none;
color: white;
border-radius: 10px;
cursor: pointer;
font-weight: 600;
font-size: 14px;
margin-top: 10px;
}
button:hover { background: #1d4ed8; }
.preview {
margin-top: 18px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 12px;
padding: 12px;
overflow-x: auto;
}
.preview table {
width: 100%;
border-collapse: collapse;
min-width: 700px;
font-size: 13px;
}
.preview thead th {
position: sticky;
top: 0;
background: #111827;
color: #f9fafb;
padding: 10px;
text-align: left;
border-bottom: 1px solid rgba(255,255,255,0.08);
white-space: nowrap;
}
.preview tbody td {
padding: 8px 10px;
border-bottom: 1px solid rgba(255,255,255,0.05);
color: #d1d5db;
white-space: nowrap;
}
.preview tbody tr:hover { background: rgba(59,130,246,0.08); }
.download {
display: inline-block;
margin-top: 14px;
padding: 10px 14px;
background: #22c55e;
color: #0b0f14;
border-radius: 10px;
text-decoration: none;
font-weight: 600;
font-size: 14px;
}
.download:hover { background: #16a34a; }
@media (max-width: 600px) {
body { padding: 16px 12px; }
.box { padding: 18px 14px; }
h2 { font-size: 18px; }
input, select { font-size: 16px; }
button { width: 100%; }
}
</style>
</head>
<body>
<div class="box">
<h2>📁 文件格式转换工具</h2>
<form method="post" enctype="multipart/form-data">
<input type="file" name="file" required>
<select name="target">
<option value="excel">转 Excel</option>
<option value="csv">转 CSV</option>
<option value="txt">转 TXT</option>
</select>
<button>开始转换</button>
</form>
{% if message %}<p>{{message}}</p>{% endif %}
{% if preview %}<div class="preview">{{ preview|safe }}</div>{% endif %}
{% if download_file %}
<a class="download" href="{{ url_for('file_convert_download_route', filename=download_file) }}">⬇ 下载文件</a>
{% endif %}
</div>
</body>
</html>

@ -0,0 +1,170 @@
<!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>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
margin: 0;
padding: 28px 20px;
background: #0b0f14;
color: #e5e7eb;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial;
min-height: 100vh;
}
.box {
max-width: 800px;
margin: auto;
background: rgba(255,255,255,0.04);
padding: 26px;
border-radius: 16px;
border: 1px solid rgba(255,255,255,0.08);
}
.card {
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 14px;
padding: 18px;
margin-bottom: 14px;
}
.card-title { font-size: 16px; font-weight: 600; margin-bottom: 14px; color: #f3f4f6; }
.tool-label { font-size: 13px; color: #9ca3af; margin-bottom: 8px; display: block; }
input[type="file"] {
width: 100%;
padding: 12px;
background: #111827;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 10px;
color: #e5e7eb;
font-size: 14px;
}
.quality-row {
display: flex;
align-items: center;
gap: 14px;
margin-top: 14px;
}
input[type="range"] {
flex: 1;
accent-color: #2563eb;
}
.quality-badge {
padding: 4px 10px;
border-radius: 8px;
background: rgba(59,130,246,0.15);
border: 1px solid rgba(59,130,246,0.3);
color: #60a5fa;
font-size: 13px;
font-weight: 600;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 18px;
font-size: 14px;
border-radius: 10px;
border: none;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.btn-primary { background: #2563eb; color: white; }
.btn-primary:hover { background: #1d4ed8; }
.download-btn {
display: inline-block;
margin-top: 14px;
padding: 10px 16px;
background: #22c55e;
color: #0b0f14;
border-radius: 10px;
text-decoration: none;
font-weight: 600;
font-size: 14px;
}
.download-btn:hover { background: #16a34a; }
.message { margin-top: 12px; font-size: 14px; color: #22c55e; }
.error { color: #f87171; }
.toast {
position: fixed;
right: 20px;
bottom: 20px;
background: rgba(17,24,39,0.95);
color: #e5e7eb;
padding: 10px 16px;
border-radius: 10px;
border: 1px solid rgba(34,197,94,0.3);
font-size: 13px;
opacity: 0;
transform: translateY(10px);
transition: all 0.25s ease;
z-index: 9999;
}
.toast.show { opacity: 1; transform: translateY(0); }
@media (max-width: 600px) {
body { padding: 20px 12px; }
.box { padding: 18px 14px; }
.btn { width: 100%; justify-content: center; }
.quality-row { flex-direction: column; align-items: flex-start; }
}
</style>
</head>
<body>
<div class="box">
<div class="card">
<div class="card-title">🖼️ 图片压缩工具</div>
<form method="post" enctype="multipart/form-data">
<label class="tool-label">选择图片JPG/PNG/WEBP</label>
<input type="file" name="file" accept=".jpg,.jpeg,.png,.webp" required>
<div class="quality-row">
<span class="tool-label" style="margin:0">压缩质量</span>
<input type="range" name="quality" min="30" max="95" value="{{ quality or 85 }}" oninput="document.getElementById('qBadge').innerText = this.value + '%'">
<span class="quality-badge" id="qBadge">{{ quality or 85 }}%</span>
</div>
<br>
<button class="btn btn-primary" type="submit">🗜️ 开始压缩</button>
</form>
{% if message %}
<div class="message {% if '❌' in message %}error{% endif %}">{{ message }}</div>
{% endif %}
{% if download_file %}
<a class="download-btn" href="/tool/image_compress/download/{{ download_file }}">⬇ 下载压缩图片</a>
{% endif %}
</div>
</div>
<div class="toast" id="toast"></div>
<script>
function showToast(msg) {
const t = document.getElementById('toast');
t.innerText = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2000);
}
</script>
</body>
</html>

@ -0,0 +1,261 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tool Hub</title>
<link rel="icon" type="image/svg+xml"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%230b0f14'/%3E%3Crect x='20' y='20' width='20' height='20' fill='%232563eb'/%3E%3Crect x='60' y='20' width='20' height='20' fill='%232563eb'/%3E%3Crect x='20' y='60' width='20' height='20' fill='%232563eb'/%3E%3Crect x='60' y='60' width='20' height='20' fill='%232563eb'/%3E%3C/svg%3E">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial;
background: radial-gradient(circle at top, #111827, #0b0f14);
color: #e5e7eb;
min-height: 100vh;
}
.container {
max-width: 1080px;
margin: 0 auto;
padding: 56px 24px 60px;
}
.header {
text-align: center;
padding: 0 0 48px;
}
.header h1 {
font-size: 44px;
font-weight: 700;
letter-spacing: 2px;
margin-bottom: 10px;
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #818cf8 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.header p {
color: #6b7280;
font-size: 15px;
letter-spacing: 0.5px;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.card {
display: block;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 16px;
padding: 26px 24px;
transition: transform 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
backdrop-filter: blur(10px);
text-decoration: none;
color: inherit;
position: relative;
overflow: hidden;
}
.card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(59,130,246,0.6), transparent);
opacity: 0;
transition: opacity 0.25s ease;
}
.card:hover {
transform: translateY(-5px);
border-color: rgba(59,130,246,0.4);
box-shadow: 0 16px 40px rgba(0,0,0,0.45), 0 0 0 1px rgba(59,130,246,0.1);
}
.card:hover::before {
opacity: 1;
}
.card-icon {
font-size: 20px;
margin-right: 8px;
}
.card h3 {
font-size: 17px;
font-weight: 600;
color: #f3f4f6;
margin-bottom: 8px;
}
.card p {
font-size: 13px;
color: #6b7280;
line-height: 1.6;
margin-bottom: 20px;
}
.btn {
display: inline-block;
padding: 7px 14px;
font-size: 13px;
border-radius: 8px;
background: rgba(37,99,235,0.2);
color: #60a5fa;
border: 1px solid rgba(37,99,235,0.3);
font-weight: 500;
transition: all 0.2s;
}
.btn:hover {
background: rgba(37,99,235,0.35);
color: #93c5fd;
}
.toast {
position: fixed;
right: 24px;
bottom: 24px;
background: rgba(17,24,39,0.95);
color: #e5e7eb;
padding: 12px 18px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.1);
font-size: 14px;
opacity: 0;
transform: translateY(10px);
transition: all 0.25s ease;
z-index: 9999;
pointer-events: none;
}
.toast.show { opacity: 1; transform: translateY(0); }
/* ---- 响应式 ---- */
/* 平板横屏 */
@media (max-width: 900px) {
.grid { grid-template-columns: repeat(2, 1fr); }
.container { padding: 40px 20px 50px; }
.header h1 { font-size: 36px; }
.card { padding: 22px 20px; }
}
/* 手机 */
@media (max-width: 540px) {
.grid { grid-template-columns: 1fr; gap: 14px; }
.container { padding: 30px 16px 40px; }
.header { padding-bottom: 36px; }
.header h1 { font-size: 28px; }
.header p { font-size: 13px; }
.card { padding: 20px 18px; }
.card-icon { font-size: 24px; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Tool Hub</h1>
<p>Minimal Engineering Tools · Lightweight · Extensible</p>
</div>
<div class="grid">
<a class="card" href="{{ url_for('tool', name='excel') }}" target="_blank" rel="noopener noreferrer">
<h3><span class="card-icon">📊</span> Excel 合并工具</h3>
<p>CSV / XLSX 数据合并分析</p>
<span class="btn">进入</span>
</a>
<a class="card" href="{{ url_for('tool', name='temp_upload') }}" target="_blank" rel="noopener noreferrer">
<h3><span class="card-icon">📂</span> 临时文件工具</h3>
<p>上传 / 暂存 / 下载</p>
<span class="btn">进入</span>
</a>
<a class="card" href="{{ url_for('tool', name='smart_data_v2') }}" target="_blank" rel="noopener noreferrer">
<h3><span class="card-icon">🧠</span> 数据对比 / 去重 / 筛选</h3>
<p>多格式数据解析,自动识别字段结构与关联键</p>
<span class="btn">进入</span>
</a>
<a class="card" href="{{ url_for('tool', name='file_convert') }}" target="_blank" rel="noopener noreferrer">
<h3><span class="card-icon">📁</span> 文件格式转换工具</h3>
<p>Excel / CSV / TXT 格式互转,一键导出</p>
<span class="btn">进入</span>
</a>
<a class="card" href="{{ url_for('tool', name='weekly_permission') }}" target="_blank" rel="noopener noreferrer">
<h3><span class="card-icon">📌</span> 每周动态授权工具</h3>
<p>中心 + 工号 → 自动生成权限 SQL</p>
<span class="btn">进入</span>
</a>
<!--
<a class="card" href="{{ url_for('tool', name='mail_notify') }}" target="_blank" rel="noopener noreferrer">
<h3><span class="card-icon">📧</span> 邮件群发工具</h3>
<p>TXT 解析账号列表 + SMTP 批量通知发送</p>
<span class="btn">进入</span>
</a>
-->
<a class="card" href="{{ url_for('tool', name='base64') }}" target="_blank" rel="noopener noreferrer">
<h3><span class="card-icon">🔐</span> Base64 编解码</h3>
<p>文本 Base64 编码解码,一键复制结果</p>
<span class="btn">进入</span>
</a>
<a class="card" href="{{ url_for('tool', name='json') }}" target="_blank" rel="noopener noreferrer">
<h3><span class="card-icon">📋</span> JSON 格式化工具</h3>
<p>格式化 / 压缩 / 校验,一键复制</p>
<span class="btn">进入</span>
</a>
<a class="card" href="{{ url_for('tool', name='url') }}" target="_blank" rel="noopener noreferrer">
<h3><span class="card-icon">🔗</span> URL 编解码</h3>
<p>URL 编码解码,开发者刚需工具</p>
<span class="btn">进入</span>
</a>
<a class="card" href="{{ url_for('tool', name='image_compress') }}" target="_blank" rel="noopener noreferrer">
<h3><span class="card-icon">🖼️</span> 图片压缩工具</h3>
<p>JPG/PNG/WEBP 图片压缩,保持清晰度</p>
<span class="btn">进入</span>
</a>
<a class="card" href="{{ url_for('tool', name='text_diff') }}" target="_blank" rel="noopener noreferrer">
<h3><span class="card-icon">📝</span> 文本差异对比</h3>
<p>两段文本对比,高亮显示差异</p>
<span class="btn">进入</span>
</a>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
function showToast(msg) {
const t = document.getElementById('toast');
t.innerText = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 1800);
}
</script>
</body>
</html>

@ -0,0 +1,223 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JSON 格式化工具</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
margin: 0;
padding: 36px 24px;
background: radial-gradient(circle at top, #111827, #0b0f14);
color: #e5e7eb;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial;
min-height: 100vh;
}
.box {
max-width: 960px;
margin: auto;
}
.page-title {
font-size: 22px;
font-weight: 600;
color: #f3f4f6;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.card {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 16px;
padding: 24px;
margin-bottom: 16px;
}
.section-label {
font-size: 12px;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 10px;
}
textarea {
width: 100%;
background: #0d1117;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 12px;
color: #e6edf3;
font-family: "SF Mono", "Fira Code", Consolas, monospace;
font-size: 13.5px;
line-height: 1.7;
padding: 16px;
outline: none;
transition: border-color 0.2s;
}
textarea:focus { border-color: rgba(59,130,246,0.5); }
textarea::placeholder { color: #484f58; }
textarea.input-area {
border-color: rgba(59,130,246,0.2);
min-height: 280px;
}
textarea.result-area {
border-color: rgba(34,197,94,0.25);
color: #7ee787;
min-height: 260px;
}
.btn-row {
display: flex;
gap: 10px;
margin-top: 14px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
font-size: 13px;
border-radius: 10px;
border: none;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.btn-primary { background: #2563eb; color: white; }
.btn-primary:hover { background: #1d4ed8; transform: translateY(-1px); }
.btn-secondary { background: rgba(255,255,255,0.06); color: #9ca3af; border: 1px solid rgba(255,255,255,0.1); }
.btn-secondary:hover { background: rgba(255,255,255,0.1); }
.btn-green { background: rgba(34,197,94,0.12); color: #4ade80; border: 1px solid rgba(34,197,94,0.25); }
.btn-green:hover { background: rgba(34,197,94,0.2); }
.result-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.result-title {
font-size: 12px;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 1px;
}
.copy-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 12px;
font-size: 12px;
border-radius: 8px;
background: rgba(34,197,94,0.12);
color: #4ade80;
border: 1px solid rgba(34,197,94,0.25);
cursor: pointer;
transition: all 0.2s;
}
.copy-btn:hover { background: rgba(34,197,94,0.22); }
.copy-btn.copied { background: rgba(34,197,94,0.35); }
.toast {
position: fixed;
right: 24px;
bottom: 24px;
background: rgba(17,24,39,0.96);
color: #e5e7eb;
padding: 10px 16px;
border-radius: 10px;
border: 1px solid rgba(34,197,94,0.3);
font-size: 13px;
opacity: 0;
transform: translateY(8px);
transition: all 0.25s ease;
z-index: 9999;
pointer-events: none;
}
.toast.show { opacity: 1; transform: translateY(0); }
@media (max-width: 768px) {
body { padding: 24px 16px; }
.card { padding: 18px 16px; }
textarea.input-area, textarea.result-area { min-height: 200px; }
.btn-row { flex-wrap: wrap; }
.btn { flex: 1; justify-content: center; }
}
</style>
</head>
<body>
<div class="box">
<div class="page-title">📋 JSON 格式化工具</div>
<div class="card">
<div class="section-label">输入</div>
<textarea class="input-area" id="jsonInput" placeholder="粘贴 JSON 内容至此处...">{{ text or '' }}</textarea>
<div class="btn-row">
<button class="btn btn-primary" onclick="doAction('format')">✨ 格式化</button>
<button class="btn btn-secondary" onclick="doAction('minify')">⏱ 压缩</button>
<button class="btn btn-green" onclick="doAction('validate')">✅ 校验</button>
</div>
</div>
{% if result %}
<div class="card">
<div class="result-bar">
<span class="result-title">输出</span>
<button class="copy-btn" id="copyBtn" onclick="copyResult()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span id="copyText">复制</span>
</button>
</div>
<textarea class="result-area" id="resultText" readonly>{{ result }}</textarea>
</div>
{% endif %}
</div>
<div class="toast" id="toast"></div>
<script>
function doAction(action) {
const text = document.getElementById('jsonInput').value;
if (!text.trim()) { showToast('请输入 JSON'); return; }
window.location.href = '/tool/json?action=' + action + '&text=' + encodeURIComponent(text);
}
function copyResult() {
const result = document.getElementById('resultText').value;
if (!result) return;
navigator.clipboard.writeText(result).then(() => {
const btn = document.getElementById('copyBtn');
document.getElementById('copyText').textContent = '已复制!';
btn.classList.add('copied');
showToast('已复制到剪贴板');
setTimeout(() => { btn.classList.remove('copied'); document.getElementById('copyText').textContent = '复制'; }, 2000);
}).catch(() => showToast('复制失败'));
}
function showToast(msg) {
const t = document.getElementById('toast');
t.innerText = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2000);
}
</script>
</body>
</html>

@ -0,0 +1,301 @@
<!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>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
margin: 0; padding: 24px 16px;
background: #0b0f14;
color: #e5e7eb;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial;
min-height: 100vh;
}
.container { max-width: 1200px; margin: auto; }
.card {
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 14px;
padding: 18px;
margin-bottom: 14px;
}
h2 { margin: 0 0 10px 0; font-size: 20px; }
h3 { margin: 10px 0 6px 0; font-size: 16px; }
.badge {
display: inline-block;
padding: 3px 10px; font-size: 12px;
border-radius: 999px;
background: rgba(59,130,246,0.15);
border: 1px solid rgba(59,130,246,0.35);
color: #60a5fa;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 10px;
}
.grid3 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
margin-top: 10px;
}
input, textarea, select {
width: 100%; padding: 10px 12px;
border-radius: 10px;
background: #111827;
border: 1px solid rgba(255,255,255,0.12);
color: #e5e7eb;
outline: none; font-size: 14px; font-family: inherit;
}
textarea { min-height: 400px; resize: vertical; }
input:focus, textarea:focus { border-color: #2563eb; }
button {
margin-top: 12px; padding: 10px 14px;
border: none; border-radius: 10px;
cursor: pointer; font-weight: 600; font-size: 14px; margin-right: 8px;
}
.btn-primary { background: #2563eb; color: #fff; }
.btn-primary:hover { background: #1d4ed8; }
.btn-green { background: #22c55e; color: #0b0f14; }
.btn-green:hover { background: #16a34a; }
.btn-gray { background: #374151; color: #e5e7eb; }
.btn-gray:hover { background: #4b5563; }
.hint { font-size: 12px; color: #9ca3af; margin-top: 6px; }
/* Progress */
#progress-area { display: none; }
.progress-bar-bg {
width: 100%; height: 20px;
background: #1f2937; border-radius: 999px;
overflow: hidden; margin: 8px 0;
}
.progress-bar-fill {
height: 100%; width: 0%;
background: linear-gradient(90deg, #2563eb, #22c55e);
border-radius: 999px;
transition: width 0.5s;
}
.stat-row { display: flex; gap: 20px; font-size: 14px; margin: 6px 0; }
.stat-item { display: flex; align-items: center; gap: 4px; }
#result-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 8px; }
#result-table th, #result-table td {
padding: 6px 10px; border-bottom: 1px solid rgba(255,255,255,0.06);
text-align: left;
}
.status-success { color: #22c55e; }
.status-failed { color: #ef4444; }
/* Toast & Loading */
#toast {
position: fixed; bottom: 30px; left: 50%;
transform: translateX(-50%);
padding: 10px 14px; border-radius: 10px;
font-size: 13px; display: none; z-index: 9999;
}
#loading {
display: none; position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.4);
align-items: center; justify-content: center; z-index: 9998;
}
.spinner {
width: 40px; height: 40px;
border: 4px solid #374151;
border-top: 4px solid #60a5fa;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { 0%{transform:rotate(0)} 100%{transform:rotate(360deg)} }
@media (max-width: 600px) {
.grid, .grid3 { grid-template-columns: 1fr; }
body { padding: 16px 12px; }
textarea { min-height: 280px; }
button { width: 100%; margin-right: 0; margin-bottom: 8px; }
}
</style>
</head>
<body data-task-id="{{task_id or ""}}">
<div class="container">
<form method="post" action="/tool/mail_notify" enctype="multipart/form-data" id="sendForm">
<input type="hidden" name="action" id="action" value="send">
<div class="card">
<h2>📧 邮件批量通知工具 <span class="badge">SMTP · TXT Batch</span></h2>
<div class="hint">上传 TXT → 自动解析账号密码 → 批量发送邮件通知(含自动重试 × 3</div>
</div>
<div class="card">
<h3>🔧 SMTP 配置</h3>
<div class="grid3">
<input name="smtp_server" placeholder="SMTP服务器" value="{{smtp_server or 'smtp.ctvit.com.cn'}}">
<input name="smtp_port" placeholder="端口" value="{{smtp_port or '25'}}">
<input name="reply_to" placeholder="回复地址(可选)" value="{{reply_to or ''}}">
</div>
</div>
<div class="card">
<h3>📬 发件人</h3>
<div class="grid3">
<input name="sender_email" placeholder="发件邮箱" value="{{sender_email or ''}}">
<input name="sender_pass" type="password" placeholder="SMTP密码 / 授权码" value="" autocomplete="off">
<input name="subject" placeholder="邮件主题" value="{{subject or '账号及初始口令通知'}}">
</div>
<div class="hint">🔒 密码仅用于本次请求,不回传、不存储</div>
</div>
<div class="card">
<h3>📝 邮件模板</h3>
<textarea name="template_content">{% raw %}尊敬的老师/同事:
您好!
根据系统账号开通安排,您的账号已创建完成。现将相关登录信息通知如下,请妥善保存并注意账号安全。
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
账号(邮箱):{{email}}
初始口令:{{password}}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
请在首次登录后及时修改初始口令,并妥善保管个人账号信息。
此致
敬礼!
信息技术支持中心
{% endraw %}</textarea>
<div class="hint">支持变量:<code>{<!-- -->{email}}</code><code>{<!-- -->{password}}</code></div>
</div>
<div class="card">
<h3>📎 用户列表</h3>
<input type="file" name="user_file" id="user_file" required>
<div class="hint">TXT格式每行"邮箱 密码"(空格 / | / , / tab 均支持)</div>
<br>
<button class="btn-primary" type="submit" onclick="setAction('send')">🚀 开始群发</button>
<button class="btn-green" type="submit" onclick="setAction('test')">🧪 测试发送(发给自己)</button>
</div>
</form>
{% if message %}
<div class="card"><div>{{message}}</div></div>
{% endif %}
<!-- ========== 进度区域(异步群发时显示) ========== -->
<div id="progress-area" class="card">
<h3>⏳ 发送进度</h3>
<div class="stat-row">
<span class="stat-item">📤 总计:<strong id="p-total">0</strong></span>
<span class="stat-item">✅ 成功:<strong id="p-success" style="color:#22c55e">0</strong></span>
<span class="stat-item">❌ 失败:<strong id="p-failed" style="color:#ef4444">0</strong></span>
<span class="stat-item">📶 进度:<strong id="p-pct">0%</strong></span>
</div>
<div class="progress-bar-bg">
<div id="progress-fill" class="progress-bar-fill"></div>
</div>
<table id="result-table">
<thead><tr><th>邮箱</th><th>状态</th></tr></thead>
<tbody id="result-body"></tbody>
</table>
</div>
</div>
<div id="loading"><div class="spinner"></div></div>
<div id="toast"></div>
<script>
let pollTimer = null;
function setAction(type){
document.getElementById("action").value = type;
}
function showToast(msg, color){
const t = document.getElementById("toast");
t.innerText = msg;
t.style.background = color || "rgba(34,197,94,0.95)";
t.style.display = "block";
setTimeout(() => t.style.display = "none", 3000);
}
// 异步任务进度轮询
function startPoll(taskId){
const area = document.getElementById("progress-area");
area.style.display = "block";
document.getElementById("result-body").innerHTML = "";
document.getElementById("progress-fill").style.width = "0%";
if(pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(() => {
fetch("/tool/mail_notify/progress/" + taskId)
.then(r => r.json())
.then(d => {
if(d.error){
clearInterval(pollTimer);
return;
}
const total = d.total || 0;
const done = d.current || 0;
const success = d.success || 0;
const failed = d.failed || 0;
const pct = total > 0 ? Math.round(done / total * 100) : 0;
document.getElementById("p-total").innerText = total;
document.getElementById("p-success").innerText = success;
document.getElementById("p-failed").innerText = failed;
document.getElementById("p-pct").innerText = pct + "%";
document.getElementById("progress-fill").style.width = pct + "%";
// 结果列表
const results = d.results || [];
let html = "";
for(let i = results.length - 20; i < results.length; i++){
const r = results[i];
if(!r) continue;
const cls = r.status === "success" ? "status-success" : "status-failed";
html += "<tr><td>" + r.email + "</td><td class='" + cls + "'>" + r.status + "</td></tr>";
}
document.getElementById("result-body").innerHTML = html;
// 完成
if(d.status === "done" || d.status === "error"){
clearInterval(pollTimer);
pollTimer = null;
if(d.error){
showToast("❌ 发送异常:" + d.error, "rgba(239,68,68,0.95)");
} else if(failed > 0) {
showToast("✅ 发送完成:成功 " + success + " / 失败 " + failed, "rgba(34,197,94,0.95)");
} else {
showToast("🎉 全部发送成功!共 " + success + " 封", "rgba(34,197,94,0.95)");
}
}
})
.catch(e => {
// 忽略网络错误,继续轮询
});
}, 1500);
}
// 页面加载时检测 task_id 自动启动轮询
document.addEventListener("DOMContentLoaded", function(){
const taskId = document.body.getAttribute("data-task-id");
if(taskId && taskId.length === 32){
startPoll(taskId);
}
});
// 提交表单时显示 loading
document.getElementById("sendForm").addEventListener("submit", function(){
document.getElementById("loading").style.display = "flex";
});
</script>
</body>
</html>

@ -0,0 +1,219 @@
<!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>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
margin: 0;
padding: 24px 16px;
background: #0b0f14;
color: #e5e7eb;
font-family: Arial, -apple-system, sans-serif;
min-height: 100vh;
}
.container { max-width: 1400px; margin: auto; }
.card {
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 14px;
padding: 22px;
margin-bottom: 16px;
}
h2 { margin: 0 0 10px 0; font-size: 20px; }
h3 { margin: 0 0 12px 0; font-size: 16px; }
.badge {
display: inline-block;
padding: 4px 10px;
font-size: 12px;
border-radius: 999px;
background: rgba(34,197,94,0.12);
border: 1px solid rgba(34,197,94,0.35);
color: #22c55e;
}
.upload-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-top: 12px;
}
input[type="file"] {
width: 100%;
padding: 12px;
background: #111827;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 10px;
color: #e5e7eb;
font-size: 14px;
}
button {
margin-top: 14px;
padding: 10px 16px;
background: #2563eb;
border: none;
border-radius: 10px;
color: white;
font-weight: 600;
cursor: pointer;
font-size: 14px;
}
button:hover { background: #1d4ed8; }
.info { margin-top: 10px; color: #60a5fa; font-size: 13px; }
.info-title { font-size: 14px; color: #93c5fd; margin-bottom: 8px; font-weight: 600; }
.info-main { font-size: 13px; color: #e5e7eb; margin-bottom: 12px; }
.info-grid { display: flex; gap: 12px; }
.info-item {
flex: 1;
padding: 10px 12px;
border-radius: 10px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
}
.info-item .label { font-size: 12px; color: #9ca3af; }
.info-item .value { font-size: 18px; font-weight: 700; }
.info-item.success .value { color: #22c55e; }
.info-item.warn .value { color: #f59e0b; }
.preview-wrapper {
margin-top: 10px;
padding: 12px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 12px;
overflow: auto;
}
.preview-wrapper table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
white-space: nowrap;
}
.preview-wrapper thead th {
position: sticky;
top: 0;
background: #111827;
color: #f9fafb;
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.preview-wrapper tbody td {
padding: 9px 12px;
color: #d1d5db;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.preview-wrapper tbody tr:hover { background: rgba(59,130,246,0.08); }
.key1 { background: rgba(59,130,246,0.25) !important; color: #93c5fd !important; font-weight: 600; }
.key2 { background: rgba(168,85,247,0.25) !important; color: #d8b4fe !important; font-weight: 600; }
.download-btn {
display: inline-block;
margin-top: 12px;
padding: 8px 12px;
background: #22c55e;
color: #0b0f14;
border-radius: 10px;
text-decoration: none;
font-weight: 600;
font-size: 14px;
}
.download-btn:hover { background: #16a34a; }
@media (max-width: 768px) {
body { padding: 20px 12px; }
.upload-grid { grid-template-columns: 1fr; }
.card { padding: 18px 14px; }
h2 { font-size: 18px; }
.info-grid { flex-direction: column; gap: 8px; }
}
</style>
</head>
<body>
<div class="container">
<div class="card">
<h2>🧠 智能数据匹配引擎 <span class="badge">Auto Join · Schema Detect</span></h2>
</div>
<div class="card">
<h3>📂 上传数据文件</h3>
<form method="post" enctype="multipart/form-data">
<div class="upload-grid">
<input type="file" name="file1" required>
<input type="file" name="file2" required>
</div>
<button>开始智能匹配</button>
</form>
</div>
{% if message %}
<div class="card"><div class="info">{{ message }}</div></div>
{% endif %}
{% if match_info %}
<div class="card">
<div class="info-title">🧠 匹配分析结果</div>
<div class="info-main">{{ match_info }}</div>
<div class="info-grid">
<div class="info-item success">
<div class="label">成功匹配</div>
<div class="value">{{ matched_count or 0 }}</div>
</div>
<div class="info-item warn">
<div class="label">未匹配</div>
<div class="value">{{ unmatched_count or 0 }}</div>
</div>
</div>
</div>
{% endif %}
{% if preview %}
<div class="card">
<h3>📊 预览结果</h3>
<div class="preview-wrapper">{{ preview|safe }}</div>
<a class="download-btn" href="{{ url_for('smart_data_v2_download', filename=download_file) }}">下载结果</a>
</div>
{% endif %}
</div>
<script>
window.onload = function () {
const table = document.querySelector("table");
if (!table) return;
const k1 = "{{ match_info.split('↔')[0] if match_info else '' }}";
const k2 = "{{ match_info.split('↔')[1] if match_info else '' }}";
let i1 = -1, i2 = -1;
table.querySelectorAll("thead th").forEach((th, i) => {
const t = th.innerText.trim();
if (t.includes(k1.trim())) { i1 = i; th.classList.add("key1"); }
if (t.includes(k2.trim())) { i2 = i; th.classList.add("key2"); }
});
table.querySelectorAll("tbody tr").forEach(row => {
const tds = row.querySelectorAll("td");
if (i1 >= 0) tds[i1]?.classList.add("key1");
if (i2 >= 0) tds[i2]?.classList.add("key2");
});
};
</script>
</body>
</html>

@ -0,0 +1,77 @@
<!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>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
margin: 0;
padding: 24px 16px;
background: #0b0f14;
color: #e5e7eb;
font-family: Arial, -apple-system, sans-serif;
min-height: 100vh;
}
.box {
max-width: 900px;
margin: auto;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
padding: 20px;
border-radius: 14px;
}
textarea {
width: 100%;
height: 280px;
padding: 12px;
background: #111827;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 10px;
color: #e5e7eb;
font-family: monospace;
font-size: 14px;
resize: vertical;
outline: none;
}
textarea:focus { border-color: #2563eb; }
button {
margin-top: 10px;
padding: 10px 14px;
background: #2563eb;
border: none;
color: white;
border-radius: 10px;
cursor: pointer;
font-size: 14px;
}
button:hover { background: #1d4ed8; }
.msg { margin-top: 10px; color: #60a5fa; font-size: 13px; }
.link { margin-top: 10px; font-size: 13px; color: #22c55e; word-break: break-all; }
@media (max-width: 600px) {
body { padding: 16px 12px; }
textarea { height: 200px; font-size: 16px; }
button { width: 100%; }
}
</style>
</head>
<body>
<div class="box">
<form method="post">
<textarea name="content" placeholder="输入或粘贴文本内容...">{{ content or '' }}</textarea>
<button type="submit">提交</button>
</form>
{% if msg %}<div class="msg">{{msg}}</div>{% endif %}
{% if link %}<div class="link">📄 访问地址:<a href="{{link}}" target="_blank" style="color:#22c55e;">{{link}}</a></div>{% endif %}
</div>
</body>
</html>

@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>临时文本</title>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
margin: 0;
padding: 24px 16px;
background: #0b0f14;
color: #e5e7eb;
font-family: Arial, -apple-system, sans-serif;
min-height: 100vh;
}
.container { max-width: 960px; margin: auto; }
.card {
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 14px;
padding: 18px;
margin-bottom: 14px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.btn {
padding: 6px 10px;
font-size: 12px;
background: #22c55e;
border: none;
border-radius: 8px;
cursor: pointer;
color: #0b0f14;
font-weight: 600;
}
.btn:hover { background: #16a34a; }
.content { margin-top: 12px; }
.content pre { background: #111827; padding: 12px; border-radius: 10px; overflow: auto; font-size: 14px; }
@media (max-width: 600px) {
body { padding: 16px 12px; }
.card { padding: 14px; }
}
</style>
</head>
<body>
<div class="container">
<div class="card header">
<div>📄 临时文本内容</div>
<button class="btn" onclick="copyText()">一键复制</button>
</div>
<div class="card content" id="md"></div>
</div>
<script>
const content = `{{ content|replace('\n','\\n')|safe }}`;
document.getElementById("md").innerHTML = marked.parse(content);
function copyText(){
navigator.clipboard.writeText(content)
.then(() => alert("复制成功"))
.catch(() => alert("复制失败"));
}
</script>
</body>
</html>

@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>临时文件上传</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
* { box-sizing: border-box; }
body {
margin: 0;
padding: 24px 16px;
background: radial-gradient(circle at top, #111827, #0b0f14);
color: #e5e7eb;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial;
min-height: 100vh;
}
.card-box {
max-width: 960px;
margin: auto;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 18px;
padding: 28px;
backdrop-filter: blur(10px);
}
h3 { margin-bottom: 18px; font-size: 18px; }
.form-control {
background: rgba(255,255,255,0.06) !important;
border: 1px solid rgba(255,255,255,0.12) !important;
color: #e5e7eb !important;
}
.table { margin-top: 18px; border-radius: 10px; overflow: hidden; }
.table-dark { --bs-table-bg: #111827; }
.table td, .table th { font-size: 13px; }
.btn-primary { background: #2563eb; border: none; }
.btn-primary:hover { background: #1d4ed8; }
.btn-success { background: #16a34a; border: none; }
.auto-clean-badge {
display: inline-block;
margin-left: 8px;
padding: 3px 8px;
font-size: 11px;
font-weight: 500;
color: #f59e0b;
background: rgba(245,158,11,0.12);
border: 1px solid rgba(245,158,11,0.35);
border-radius: 999px;
vertical-align: middle;
}
@media (max-width: 600px) {
body { padding: 16px 12px; }
.card-box { padding: 18px 14px; }
h3 { font-size: 16px; }
.col-md-3 { margin-top: 10px; }
.btn { width: 100%; }
}
</style>
</head>
<body>
<div class="card-box">
<h3>📂 临时文件上传工具 <span class="auto-clean-badge">零点自动清空</span></h3>
<form method="post" enctype="multipart/form-data">
<div class="row">
<div class="col-12 col-md-9">
<input type="file" name="file" class="form-control" required>
</div>
<div class="col-12 col-md-3">
<button class="btn btn-primary w-100">上传文件</button>
</div>
</div>
</form>
{% if message %}
<div class="alert alert-info mt-3">{{message}}</div>
{% endif %}
<h5 class="mt-4">已上传文件</h5>
<table class="table table-dark table-hover">
<thead><tr><th>文件名</th><th>大小(KB)</th><th>操作</th></tr></thead>
<tbody>
{% for file in files %}
<tr>
<td>{{ file.name }}</td>
<td>{{ file.size }}</td>
<td><a href="{{ url_for('temp_download', filename=file.name) }}" class="btn btn-success btn-sm">下载</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</body>
</html>

@ -0,0 +1,216 @@
<!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>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
margin: 0;
padding: 36px 24px;
background: radial-gradient(circle at top, #111827, #0b0f14);
color: #e5e7eb;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial;
min-height: 100vh;
}
.box {
max-width: 1200px;
margin: auto;
}
.page-title {
font-size: 22px;
font-weight: 600;
color: #f3f4f6;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.card {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 16px;
padding: 24px;
margin-bottom: 16px;
}
.section-label {
font-size: 12px;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 10px;
}
textarea {
width: 100%;
background: #0d1117;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 12px;
color: #e6edf3;
font-family: "SF Mono", "Fira Code", Consolas, monospace;
font-size: 13.5px;
line-height: 1.7;
padding: 16px;
outline: none;
transition: border-color 0.2s;
resize: vertical;
}
textarea:focus { border-color: rgba(59,130,246,0.5); }
textarea::placeholder { color: #484f58; }
.grid-inputs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 14px;
}
.input-area {
min-height: 240px;
border-color: rgba(59,130,246,0.15);
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
font-size: 13px;
border-radius: 10px;
border: none;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.btn-primary { background: #2563eb; color: white; }
.btn-primary:hover { background: #1d4ed8; transform: translateY(-1px); }
/* diff result */
.diff-card {
background: rgba(255,255,255,0.02);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 14px;
overflow: hidden;
}
.diff-header-bar {
display: flex;
align-items: center;
padding: 14px 18px;
background: rgba(255,255,255,0.03);
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.diff-header-bar span {
font-size: 12px;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 1px;
}
.diff-wrapper {
overflow: auto;
max-height: 500px;
}
.diff-wrapper table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
font-family: "SF Mono", "Fira Code", Consolas, monospace;
}
.diff-wrapper table td {
padding: 10px 16px;
border-bottom: 1px solid rgba(255,255,255,0.04);
vertical-align: top;
line-height: 1.6;
}
.diff-wrapper .diff-header { background: #0d1117; color: #6b7280; font-size: 11px; }
.diff-wrapper .diff-equal { background: transparent; color: #8b949e; }
.diff-wrapper .diff-remove { background: rgba(248,81,73,0.12); color: #ffa198; }
.diff-wrapper .diff-add { background: rgba(63,185,80,0.12); color: #7ee787; }
.diff-wrapper .diff-equal:hover { background: rgba(255,255,255,0.03); }
.diff-wrapper .diff-remove:hover { background: rgba(248,81,73,0.2); }
.diff-wrapper .diff-add:hover { background: rgba(63,185,80,0.2); }
.toast {
position: fixed;
right: 24px;
bottom: 24px;
background: rgba(17,24,39,0.96);
color: #e5e7eb;
padding: 10px 16px;
border-radius: 10px;
border: 1px solid rgba(34,197,94,0.3);
font-size: 13px;
opacity: 0;
transform: translateY(8px);
transition: all 0.25s ease;
z-index: 9999;
pointer-events: none;
}
.toast.show { opacity: 1; transform: translateY(0); }
@media (max-width: 900px) {
.grid-inputs { grid-template-columns: 1fr; }
body { padding: 24px 16px; }
.card { padding: 18px 16px; }
.input-area { min-height: 180px; }
.btn { width: 100%; justify-content: center; }
.diff-wrapper { max-height: 350px; }
}
</style>
</head>
<body>
<div class="box">
<div class="page-title">📝 文本差异对比</div>
<div class="card">
<div class="section-label">输入文本</div>
<form method="get">
<div class="grid-inputs">
<div>
<textarea class="input-area" name="text1" placeholder="输入第一段文本(原始版本)...">{{ text1 or '' }}</textarea>
</div>
<div>
<textarea class="input-area" name="text2" placeholder="输入第二段文本(对比版本)...">{{ text2 or '' }}</textarea>
</div>
</div>
<button class="btn btn-primary">🔍 开始对比</button>
</form>
</div>
{% if diff_result %}
<div class="diff-card">
<div class="diff-header-bar">
<span>📊 对比结果</span>
</div>
<div class="diff-wrapper">{{ diff_result|safe }}</div>
</div>
{% endif %}
</div>
<div class="toast" id="toast"></div>
<script>
function showToast(msg) {
const t = document.getElementById('toast');
t.innerText = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2000);
}
</script>
</body>
</html>

@ -0,0 +1,186 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>URL 编解码</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
margin: 0;
padding: 28px 20px;
background: #0b0f14;
color: #e5e7eb;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial;
min-height: 100vh;
}
.box {
max-width: 1000px;
margin: auto;
background: rgba(255,255,255,0.04);
padding: 26px;
border-radius: 16px;
border: 1px solid rgba(255,255,255,0.08);
}
.card {
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 14px;
padding: 18px;
margin-bottom: 14px;
}
.card-title { font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #f3f4f6; }
.tool-label { font-size: 13px; color: #9ca3af; margin-bottom: 8px; display: block; }
textarea {
width: 100%;
background: #111827;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 10px;
color: #e5e7eb;
font-family: "SF Mono", "Fira Code", Consolas, monospace;
font-size: 13px;
padding: 12px;
resize: vertical;
outline: none;
transition: border-color 0.2s;
}
textarea:focus { border-color: rgba(59,130,246,0.6); }
textarea::placeholder { color: #6b7280; }
textarea.result-textarea { border-color: rgba(34,197,94,0.3); color: #4ade80; }
.btn-row { display: flex; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 18px;
font-size: 14px;
border-radius: 10px;
border: none;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.btn-primary { background: #2563eb; color: white; }
.btn-primary:hover { background: #1d4ed8; }
.btn-secondary { background: rgba(255,255,255,0.08); color: #e5e7eb; border: 1px solid rgba(255,255,255,0.1); }
.btn-secondary:hover { background: rgba(255,255,255,0.12); }
.result-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.copy-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
font-size: 12px;
border-radius: 8px;
background: rgba(34,197,94,0.15);
color: #22c55e;
border: 1px solid rgba(34,197,94,0.3);
cursor: pointer;
transition: all 0.2s;
}
.copy-btn:hover { background: rgba(34,197,94,0.25); }
.copy-btn.copied { background: rgba(34,197,94,0.4); color: #4ade80; }
.toast {
position: fixed;
right: 20px;
bottom: 20px;
background: rgba(17,24,39,0.95);
color: #e5e7eb;
padding: 10px 16px;
border-radius: 10px;
border: 1px solid rgba(34,197,94,0.3);
font-size: 13px;
opacity: 0;
transform: translateY(10px);
transition: all 0.25s ease;
z-index: 9999;
}
.toast.show { opacity: 1; transform: translateY(0); }
@media (max-width: 600px) {
body { padding: 20px 12px; }
.box { padding: 18px 14px; }
.btn-row { flex-direction: column; }
.btn { width: 100%; justify-content: center; }
}
</style>
</head>
<body>
<div class="box">
<div class="card">
<div class="card-title">🔗 URL 编解码工具</div>
<label class="tool-label">输入内容</label>
<textarea id="inputText" rows="4" placeholder="输入要编码或解码的 URL...">{{ input_text or '' }}</textarea>
<div class="btn-row">
<button class="btn btn-primary" onclick="doEncode()">🔒 编码 Encode</button>
<button class="btn btn-secondary" onclick="doDecode()">🔓 解码 Decode</button>
</div>
</div>
{% if result %}
<div class="card">
<div class="result-header">
<span class="tool-label" style="margin:0">处理结果</span>
<button class="copy-btn" id="copyBtn" onclick="copyResult()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span id="copyText">复制</span>
</button>
</div>
<textarea id="resultText" class="result-textarea" rows="4" readonly>{{ result }}</textarea>
</div>
{% endif %}
</div>
<div class="toast" id="toast"></div>
<script>
function doEncode() {
const text = document.getElementById('inputText').value;
if (!text.trim()) { showToast('请输入内容'); return; }
window.location.href = '/tool/url?action=encode&text=' + encodeURIComponent(text);
}
function doDecode() {
const text = document.getElementById('inputText').value;
if (!text.trim()) { showToast('请输入内容'); return; }
window.location.href = '/tool/url?action=decode&text=' + encodeURIComponent(text);
}
function copyResult() {
const result = document.getElementById('resultText').value;
if (!result) return;
navigator.clipboard.writeText(result).then(() => {
const btn = document.getElementById('copyBtn');
document.getElementById('copyText').textContent = '已复制!';
btn.classList.add('copied');
showToast('已复制到剪贴板');
setTimeout(() => { btn.classList.remove('copied'); document.getElementById('copyText').textContent = '复制'; }, 2000);
}).catch(() => showToast('复制失败'));
}
function showToast(msg) {
const t = document.getElementById('toast');
t.innerText = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2000);
}
</script>
</body>
</html>

@ -0,0 +1,186 @@
<!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>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
margin: 0;
padding: 24px 16px;
background: #0b0f14;
color: #e5e7eb;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial;
min-height: 100vh;
}
.container { max-width: 1100px; margin: auto; }
.card {
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 14px;
padding: 20px;
margin-bottom: 14px;
}
h2 { margin: 0 0 12px 0; font-size: 20px; }
.badge {
display: inline-block;
font-size: 12px;
padding: 3px 10px;
border-radius: 999px;
background: rgba(59,130,246,0.15);
border: 1px solid rgba(59,130,246,0.35);
color: #60a5fa;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 12px;
}
input {
width: 100%;
padding: 10px 12px;
border-radius: 10px;
background: #111827;
border: 1px solid rgba(255,255,255,0.12);
color: #e5e7eb;
outline: none;
font-size: 14px;
}
input:focus { border-color: #2563eb; }
.btn-group { display: flex; gap: 10px; margin-top: 14px; }
button {
flex: 1;
padding: 10px 14px;
border: none;
border-radius: 10px;
cursor: pointer;
font-weight: 600;
font-size: 14px;
transition: 0.15s;
}
.btn-auth { background: #2563eb; color: white; }
.btn-auth:hover { background: #1d4ed8; }
.btn-rebuild { background: #f59e0b; color: #0b0f14; }
.btn-rebuild:hover { background: #d97706; }
.msg { color: #60a5fa; font-size: 13px; }
.error { color: #f87171; }
.sql-header { display: flex; align-items: center; margin-bottom: 10px; gap: 10px; }
.sql-header .msg { font-size: 13px; color: #9ca3af; }
.copy-btn {
margin-left: auto;
padding: 4px 10px;
font-size: 12px;
height: 26px;
border-radius: 999px;
background: rgba(34,197,94,0.15);
color: #22c55e;
border: 1px solid rgba(34,197,94,0.3);
cursor: pointer;
}
.copy-btn:hover { background: rgba(34,197,94,0.25); }
pre {
margin: 0;
padding: 14px;
background: #0f172a;
border: 1px solid rgba(255,255,255,0.08);
border-radius: 12px;
overflow: auto;
color: #e5e7eb;
font-size: 13px;
line-height: 1.5;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas;
}
.hint { font-size: 12px; color: #9ca3af; margin-top: 8px; }
#toast {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
padding: 10px 14px;
border-radius: 10px;
font-size: 13px;
display: none;
z-index: 9999;
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
color: #0b0f14;
}
@media (max-width: 600px) {
.grid { grid-template-columns: 1fr; }
body { padding: 16px 12px; }
.btn-group { flex-direction: column; }
}
</style>
</head>
<body>
<div class="container">
<div class="card">
<h2>📌 每周动态人员授权工具 <span class="badge">MySQL · SQL Generator</span></h2>
<div class="hint">输入中心名称 + 8位工号生成授权或重复处理SQL</div>
</div>
<div class="card">
<form method="post">
<div class="grid">
<input name="center_name" placeholder="中心名称(如:新闻新媒体中心)" value="{{ center_name or '' }}" required>
<input name="emp_id" placeholder="工作证号8位数字如00313390" value="{{ emp_id or '' }}" required>
</div>
<div class="btn-group">
<button class="btn-auth" type="submit" name="action" value="auth">授权SQL</button>
<button class="btn-rebuild" type="submit" name="action" value="rebuild">重复处理SQL</button>
</div>
</form>
{% if message %}
<div class="msg {% if '错误' in message %}error{% endif %}">{{ message }}</div>
{% endif %}
</div>
{% if sql_text %}
<div class="card sql-box">
<div class="sql-header">
<div class="msg">生成结果</div>
<button class="copy-btn" onclick="copySQL()">一键复制</button>
</div>
<pre id="sql">{{ sql_text }}</pre>
<div class="hint">⚠ SQL为模板生成请确认业务环境后执行</div>
</div>
{% endif %}
</div>
<div id="toast"></div>
<script>
function showToast(msg, type="success"){
const t = document.getElementById("toast");
t.innerText = msg;
t.style.background = type === "error" ? "rgba(248,113,113,0.95)" : "rgba(34,197,94,0.95)";
t.style.display = "block";
setTimeout(() => t.style.display = "none", 1500);
}
function copySQL(){
const el = document.getElementById("sql");
if(!el){ showToast("没有可复制内容", "error"); return; }
navigator.clipboard.writeText(el.innerText).then(() => showToast("复制成功")).catch(() => showToast("复制失败", "error"));
}
</script>
</body>
</html>

@ -0,0 +1,31 @@
import base64 as b64
def encode_text(text):
"""Base64 编码"""
return b64.b64encode(text.encode('utf-8')).decode('utf-8')
def decode_text(text):
"""Base64 解码"""
try:
return b64.b64decode(text.encode('utf-8')).decode('utf-8')
except Exception:
return "解码失败,请检查输入是否合法"
def page():
"""工具页面"""
from flask import render_template, request
result = None
input_text = ""
action = request.args.get("action")
raw_text = request.args.get("text", "")
if raw_text:
input_text = raw_text
if action == "encode_text":
result = encode_text(raw_text)
elif action == "decode_text":
result = decode_text(raw_text)
return render_template("base64.html", result=result, input_text=input_text)

@ -0,0 +1,64 @@
# tools/excel_merge.py
from common.logger import tool_logger, error_logger
import pandas as pd
def read_file(file_path):
ext = file_path.lower().split(".")[-1]
if ext in ["xlsx", "xls"]:
df = pd.read_excel(file_path, dtype=str)
elif ext == "csv":
df = None
for enc in ["utf-8", "gbk", "gb18030"]:
try:
df = pd.read_csv(file_path, encoding=enc, dtype=str)
break
except:
pass
if df is None:
tool_logger.exception("CSV编码无法识别")
raise Exception("CSV编码无法识别")
else:
tool_logger.exception("仅支持 xlsx/xls/csv")
raise Exception("仅支持 xlsx/xls/csv")
df.columns = df.columns.astype(str).str.strip()
for c in df.columns:
df[c] = df[c].astype(str).str.strip()
return df
def merge_df(df1, df2, key1, key2, join_type):
if key1 not in df1.columns:
tool_logger.exception(f"文件1不存在字段{key1}")
raise Exception(f"文件1不存在字段{key1}")
if key2 not in df2.columns:
tool_logger.exception(f"文件2不存在字段{key2}")
raise Exception(f"文件2不存在字段{key2}")
merged_df = pd.merge(
df1,
df2,
left_on=key1,
right_on=key2,
how=join_type
)
return merged_df
def export_excel(df, path):
df.to_excel(path, index=False)
def build_preview(df):
return df.head(100).fillna("").to_html(
classes="table table-bordered table-hover preview-table",
table_id="previewTable",
index=False,
border=0
)

@ -0,0 +1,159 @@
import os
import uuid
import pandas as pd
from flask import render_template, request, send_file
from common.logger import system_logger
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
UPLOAD_FOLDER = os.path.join(BASE_DIR, "uploads")
OUTPUT_FOLDER = os.path.join(BASE_DIR, "output")
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
os.makedirs(OUTPUT_FOLDER, exist_ok=True)
# =========================
# 1. 读取文件CSV/TXT/Excel
# =========================
def read_any(file_path):
ext = file_path.lower().split(".")[-1]
# Excel
if ext in ["xls", "xlsx"]:
df = pd.read_excel(file_path, dtype=str)
return df
# CSV / TXT
if ext in ["csv", "txt"]:
seps = [",", "|", "\t", " "]
for sep in seps:
try:
df = pd.read_csv(
file_path,
sep=sep,
engine="python",
dtype=str,
encoding="utf-8"
)
if df.shape[1] > 1:
return df
except:
continue
# fallback GBK
for sep in seps:
try:
df = pd.read_csv(
file_path,
sep=sep,
engine="python",
dtype=str,
encoding="gbk"
)
if df.shape[1] > 1:
return df
except:
continue
raise Exception("无法解析TXT/CSV结构")
raise Exception("不支持的文件类型")
# =========================
# 2. 转换核心
# =========================
def convert_file(df, target):
out_name = f"convert_{uuid.uuid4().hex}"
# Excel
if target == "excel":
path = os.path.join(OUTPUT_FOLDER, out_name + ".xlsx")
df.to_excel(path, index=False)
# CSV
elif target == "csv":
path = os.path.join(OUTPUT_FOLDER, out_name + ".csv")
df.to_csv(path, index=False, encoding="utf-8-sig")
# TXT默认 tab
elif target == "txt":
path = os.path.join(OUTPUT_FOLDER, out_name + ".txt")
df.to_csv(path, index=False, sep="\t", encoding="utf-8")
else:
raise Exception("未知转换类型")
return path, os.path.basename(path)
# =========================
# 3. 页面
# =========================
def file_convert_page():
preview = None
download_file = None
message = None
if request.method == "POST":
try:
file = request.files.get("file")
target = request.form.get("target")
if not file or not file.filename:
raise Exception("请选择文件")
filename = f"{uuid.uuid4()}_{file.filename}"
path = os.path.join(UPLOAD_FOLDER, filename)
file.save(path)
df = read_any(path)
preview = df.head(100).fillna("").to_html(
classes="table table-dark table-hover",
index=False
)
out_path, download_file = convert_file(df, target)
message = f"转换成功:{len(df)}"
system_logger.info(
f"[FileConvert] {file.filename} -> {target}"
)
except Exception as e:
message = str(e)
system_logger.exception("[FileConvert ERROR]")
return render_template(
"file_convert.html",
preview=preview,
download_file=download_file,
message=message
)
# =========================
# download
# =========================
def file_convert_download(filename):
path = os.path.join(OUTPUT_FOLDER, filename)
system_logger.info(f"[FileConvertDownload] {filename}")
return send_file(path, as_attachment=True)

@ -0,0 +1,50 @@
import os, uuid
from flask import render_template, request, send_file
OUTPUT_FOLDER = os.path.join(os.path.dirname(__file__), "..", "output")
os.makedirs(OUTPUT_FOLDER, exist_ok=True)
def compress_image(input_stream, quality=85):
"""使用 PIL 压缩图片"""
try:
from PIL import Image
img = Image.open(input_stream)
# 转为 RGB支持 JPG
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
out_name = f"compressed_{uuid.uuid4().hex}.jpg"
out_path = os.path.join(OUTPUT_FOLDER, out_name)
img.save(out_path, "JPEG", quality=quality, optimize=True)
return out_name
except Exception as e:
raise Exception(f"图片处理失败: {e}")
def page():
from flask import render_template, request
download_file = None
message = None
quality = 85
if request.method == "POST":
file = request.files.get("file")
quality = int(request.form.get("quality", 85))
if file:
try:
suffix = os.path.splitext(file.filename)[-1].lower()
if suffix not in [".jpg", ".jpeg", ".png", ".webp"]:
raise Exception("仅支持 JPG/PNG/WEBP 格式")
tmp_path = os.path.join(OUTPUT_FOLDER, f"tmp_{uuid.uuid4().hex}{suffix}")
file.save(tmp_path)
download_file = compress_image(tmp_path, quality)
os.remove(tmp_path)
message = "✅ 压缩成功"
except Exception as e:
message = f"{e}"
return render_template("image_compress.html", download_file=download_file, message=message, quality=quality)

@ -0,0 +1,42 @@
import json, re
def format_json(text):
try:
obj = json.loads(text)
return json.dumps(obj, indent=2, ensure_ascii=False)
except Exception:
return "JSON 格式错误,请检查输入"
def minify_json(text):
try:
obj = json.loads(text)
return json.dumps(obj, separators=(',', ':'))
except Exception:
return "JSON 格式错误,请检查输入"
def validate_json(text):
try:
json.loads(text)
return "✅ 有效的 JSON"
except Exception as e:
return f"❌ 格式错误: {str(e)}"
def page():
from flask import render_template, request
result = None
action = request.args.get("action")
text = request.args.get("text", "").strip()
if text:
if action == "format":
result = format_json(text)
elif action == "minify":
result = minify_json(text)
elif action == "validate":
result = validate_json(text)
else:
result = format_json(text)
else:
result = None
return render_template("json.html", result=result, text=text)

@ -0,0 +1,377 @@
import os
import re
import json
import time
import uuid
import smtplib
import threading
import traceback
from email.mime.text import MIMEText
from email.header import Header
from flask import render_template, request, jsonify
from common.logger import system_logger
# =====================================
# Path
# =====================================
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
UPLOAD_FOLDER = os.path.join(BASE_DIR, "uploads")
PROGRESS_FOLDER = os.path.join(BASE_DIR, "logs", "progress")
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
os.makedirs(PROGRESS_FOLDER, exist_ok=True)
# =====================================
# 进度文件存储(跨进程共享)
# =====================================
def progress_path(task_id):
return os.path.join(PROGRESS_FOLDER, f"{task_id}.json")
def write_progress(task_id, **kwargs):
path = progress_path(task_id)
data = {}
if os.path.exists(path):
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
# results 列表累积
old_results = data.get("results", [])
except Exception:
old_results = []
else:
old_results = []
data.update(kwargs)
# 处理 results 追加
results_append = data.pop("results_append", None)
if results_append:
old_results.append(results_append)
data["results"] = old_results
elif "results" not in data:
data["results"] = old_results
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
def read_progress(task_id):
path = progress_path(task_id)
if not os.path.exists(path):
return None
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def clean_progress(task_id):
path = progress_path(task_id)
if os.path.exists(path):
os.remove(path)
# =====================================
# TXT 解析
# =====================================
def parse_users(file_path):
users = []
with open(file_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
parts = re.split(r"[\s\t\|:;]+", line)
if len(parts) < 2:
continue
email = parts[0].strip()
password = parts[1].strip()
if not email or "@" not in email:
continue
users.append({"email": email, "password": password})
return users
# =====================================
# 清理 textarea 缩进
# =====================================
def normalize_template(text):
if not text:
return ""
lines = text.splitlines()
while lines and not lines[0].strip():
lines.pop(0)
while lines and not lines[-1].strip():
lines.pop()
return "\n".join(lines)
# =====================================
# 模板变量替换
# =====================================
def render_template_text(template, email, password):
template = normalize_template(template)
return (
str(template)
.replace("{{email}}", str(email or ""))
.replace("{{password}}", str(password or ""))
)
# =====================================
# SMTP 连接
# =====================================
def build_smtp(smtp_server, smtp_port, sender_email, sender_pass):
smtp_port = int(smtp_port)
try:
if smtp_port == 465:
server = smtplib.SMTP_SSL(smtp_server, smtp_port, timeout=30)
else:
server = smtplib.SMTP(smtp_server, smtp_port, timeout=30)
server.ehlo()
if smtp_port == 587:
server.starttls()
server.ehlo()
server.login(sender_email, sender_pass)
return server
except Exception as e:
raise Exception(f"SMTP连接失败{smtp_server}:{smtp_port} {str(e)}")
# =====================================
# 发单封邮件
# =====================================
def send_single_mail(server, sender_email, receiver_email, subject, content, reply_to=None):
msg = MIMEText(content, "plain", "utf-8")
msg["From"] = sender_email
msg["To"] = receiver_email
msg["Subject"] = Header(subject or "邮件通知", "utf-8")
if reply_to:
msg["Reply-To"] = reply_to
server.sendmail(sender_email, [receiver_email], msg.as_string())
def send_with_retry(server, sender_email, receiver_email, subject, content, reply_to=None, max_retries=3):
last_error = None
for attempt in range(1, max_retries + 1):
try:
send_single_mail(server, sender_email, receiver_email, subject, content, reply_to)
return True, None
except smtplib.SMTPServerDisconnected:
last_error = "SMTPServerDisconnected"
if attempt < max_retries:
time.sleep(2 ** attempt)
except smtplib.SMTPResponseException as e:
code = e.smtp_code
if 400 <= code < 500 and attempt < max_retries:
last_error = f"SMTP {code}"
time.sleep(2 ** attempt)
else:
return False, str(e)
except Exception as e:
return False, str(e)
return False, last_error
# =====================================
# 异步后台任务:批量发送
# =====================================
def batch_send_worker(
task_id,
smtp_server,
smtp_port,
sender_email,
sender_pass,
subject,
template_content,
users,
reply_to,
):
server = None
total = len(users)
success_count = 0
failed_count = 0
write_progress(task_id, status="running", total=total, success=0, failed=0, current=0, results=[])
try:
server = build_smtp(smtp_server, smtp_port, sender_email, sender_pass)
for idx, user in enumerate(users):
email = user.get("email", "")
password = user.get("password", "")
ok, err = send_with_retry(
server, sender_email, email,
subject, render_template_text(template_content, email, password),
reply_to=reply_to
)
if ok:
success_count += 1
result = {"email": email, "status": "success"}
write_progress(task_id,
success=success_count,
current=idx + 1,
results_append=result
)
else:
failed_count += 1
result = {"email": email, "status": f"failed: {err}"}
write_progress(task_id,
failed=failed_count,
current=idx + 1,
results_append=result
)
# 每封间隔 1s 防垃圾标记
time.sleep(1)
except Exception as e:
write_progress(task_id, error=str(e))
finally:
if server:
try:
server.quit()
except Exception:
pass
write_progress(task_id, status="done")
# =====================================
# 进度查询接口
# =====================================
def mail_notify_progress(task_id):
data = read_progress(task_id)
if data is None:
return jsonify({"error": "task_id not found"}), 404
# 只返回最近 50 条结果
if "results" in data:
data["results"] = data["results"][-50:]
return jsonify(data)
# =====================================
# 页面入口
# =====================================
def mail_notify_page():
message = None
preview_content = ""
task_id = None
smtp_server = "smtp.ctvit.com.cn"
smtp_port = "25"
reply_to = ""
sender_email = ""
sender_pass = ""
subject = "账号及口令通知"
template_content = """尊敬的老师/同事:
您好
根据系统账号开通安排您的账号已创建完成现将相关登录信息通知如下请妥善保存并注意账号安全
账号邮箱{{email}}
初始口令{{password}}
请在首次登录后及时修改初始口令并妥善保管个人账号信息
此致
敬礼
信息技术支持中心"""
try:
if request.method == "POST":
action = request.form.get("action", "send")
smtp_server = (request.form.get("smtp_server") or smtp_server).strip()
smtp_port = (request.form.get("smtp_port") or smtp_port).strip()
sender_email = (request.form.get("sender_email") or "").strip()
sender_pass = (request.form.get("sender_pass") or "").strip()
subject = (request.form.get("subject") or subject).strip()
reply_to = (request.form.get("reply_to") or "").strip()
template_content = request.form.get("template_content") or template_content
upload_file = request.files.get("user_file") or request.files.get("file")
if not sender_email:
raise Exception("请输入发件邮箱")
if not sender_pass:
raise Exception("请输入SMTP密码")
if action == "test":
preview_content = render_template_text(template_content, sender_email, "123456")
server = None
try:
server = build_smtp(smtp_server, smtp_port, sender_email, sender_pass)
send_single_mail(server, sender_email, sender_email, f"[测试] {subject}", preview_content, reply_to=reply_to)
finally:
if server:
server.quit()
message = "✅ 测试邮件发送成功(已发送至自己邮箱)"
system_logger.info(f"[MailNotify-Test] sender={sender_email}")
elif action == "send":
if not upload_file or not upload_file.filename:
raise Exception("请上传 TXT 文件")
ext = os.path.splitext(upload_file.filename)[1].lower()
if ext != ".txt":
raise Exception("仅支持 txt 文件")
temp_name = f"{uuid.uuid4().hex}.txt"
save_path = os.path.join(UPLOAD_FOLDER, temp_name)
upload_file.save(save_path)
users = parse_users(save_path)
if not users:
raise Exception("未识别到有效用户数据")
task_id = uuid.uuid4().hex
write_progress(task_id,
status="queued", total=len(users),
success=0, failed=0, current=0,
results=[], error=None
)
thread = threading.Thread(
target=batch_send_worker,
args=(
task_id,
smtp_server, smtp_port,
sender_email, sender_pass,
subject, template_content,
users, reply_to,
),
daemon=True,
)
thread.start()
message = f"⏳ 批量发送已启动任务ID{task_id}(共 {len(users)}每封间隔1s"
system_logger.info(f"[MailNotify] task={task_id} total={len(users)} sender={sender_email}")
preview_content = render_template_text(template_content, "demo@test.com", "123456")
except Exception as e:
message = f"❌ 错误:{str(e)}"
system_logger.exception("[MailNotify ERROR]")
# 不回传密码
return render_template(
"mail_notify.html",
message=message,
smtp_server=smtp_server,
smtp_port=smtp_port,
sender_email=sender_email,
sender_pass="",
reply_to=reply_to,
subject=subject,
template_content=template_content,
preview_content=preview_content,
task_id=task_id,
)

@ -0,0 +1,333 @@
import os
import uuid
import pandas as pd
import re
from flask import render_template, request, send_file
from common.logger import system_logger
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
UPLOAD_FOLDER = os.path.join(BASE_DIR, "uploads")
OUTPUT_FOLDER = os.path.join(BASE_DIR, "output")
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
os.makedirs(OUTPUT_FOLDER, exist_ok=True)
# =========================
# 1. 智能读取v2.3 FINAL
# =========================
def smart_read(file_path):
ext = os.path.splitext(file_path)[-1].lower()
# =========================
# 1⃣ CSV / Excel强制 header
# =========================
if ext in [".csv", ".xlsx", ".xls"]:
try:
if ext == ".csv":
df = pd.read_csv(file_path, dtype=str)
else:
df = pd.read_excel(file_path, dtype=str)
except Exception as e:
raise Exception(f"读取失败: {e}")
has_header = True
# 清洗
df.columns = [str(c).strip() for c in df.columns]
for c in df.columns:
df[c] = df[c].astype(str).str.strip()
system_logger.info(
f"[SmartRead-v2.3] file={file_path} type={ext} header=FORCED shape={df.shape}"
)
return df, has_header
# =========================
# 2⃣ TXT / 非结构文件:智能识别
# =========================
seps = [",", "|", "\t", r"\s+"]
df = None
for sep in seps:
try:
df = pd.read_csv(
file_path,
sep=sep,
engine="python",
header=None,
dtype=str
)
if df is not None and df.shape[1] >= 2:
break
except:
continue
if df is None:
raise Exception("无法解析文件")
# =========================
# 3⃣ header 判断(仅 TXT 使用)
# =========================
def is_header_row(row):
row = [str(x).strip() for x in row]
keywords = ["id", "uid", "name", "code", "工号", "编号", "time", "date"]
text_score = 0
structure_score = 0
numeric_penalty = 0
for v in row:
v_low = v.lower()
if any(k in v_low for k in keywords):
text_score += 2
if not re.fullmatch(r"\d+", v):
structure_score += 1
if len(v) <= 20:
structure_score += 1
if re.fullmatch(r"\d{4,20}", v):
numeric_penalty += 1
score = text_score + structure_score - numeric_penalty
return score >= 3
has_header = False
if len(df) >= 2:
row0 = df.iloc[0].tolist()
row1 = df.iloc[1].tolist()
score0 = is_header_row(row0)
score1 = is_header_row(row1)
has_header = score0 and not score1
# =========================
# 4⃣ 应用 schema
# =========================
if has_header:
df.columns = [str(x).strip() for x in df.iloc[0].tolist()]
df = df.iloc[1:].reset_index(drop=True)
else:
df.columns = [f"c{i}" for i in range(df.shape[1])]
# =========================
# 5⃣ 清洗
# =========================
df.columns = [str(c).strip() for c in df.columns]
for c in df.columns:
df[c] = df[c].astype(str).str.strip()
system_logger.info(
f"[SmartRead-v2.3] file={file_path} type=txt header={has_header} shape={df.shape}"
)
return df, has_header
# =========================
# 2. 特征评分(不变)
# =========================
def score_column(series):
s = series.dropna().astype(str).head(30)
if len(s) == 0:
return 0
score = 0
for v in s:
if re.fullmatch(r"\d{6,12}", v):
score += 3
if v.isdigit():
score += 2
if v.startswith("0"):
score += 1
if len(v) in [6, 8, 10, 12]:
score += 1
return score / len(s)
# =========================
# 3. 自动 key不变
# =========================
def detect_key(df):
candidates = ["uid", "id", "user_id", "emp_id", "工号", "编号"]
for col in df.columns:
for c in candidates:
if c in str(col).lower():
return col
best_col = None
best_score = 0
for col in df.columns:
score = score_column(df[col])
if score > best_score:
best_score = score
best_col = col
if best_score > 0.4:
return best_col
return None
# =========================
# 4. join不变
# =========================
def auto_join(df1, df2):
key1 = detect_key(df1)
key2 = detect_key(df2)
if not key1 or not key2:
raise Exception("无法识别匹配字段")
result = pd.merge(
df1,
df2,
left_on=key1,
right_on=key2,
how="left",
indicator=True
)
result["match_status"] = result["_merge"].map({
"both": "matched",
"left_only": "unmatched",
"right_only": "orphan"
}).astype(str)
result.drop(columns=["_merge"], inplace=True)
matched_count = int((result["match_status"] == "matched").sum())
unmatched_count = int((result["match_status"] == "unmatched").sum())
total = len(result)
match_rate = round(matched_count / total * 100, 2) if total else 0
stats = {
"matched_count": matched_count,
"unmatched_count": unmatched_count,
"match_rate": match_rate
}
return result, key1, key2, stats
# =========================
# 5. 页面(不变)
# =========================
def smart_data_page_v2():
preview = None
download_file = None
message = None
match_info = ""
matched_count = 0
unmatched_count = 0
match_rate = 0
if request.method == "POST":
try:
file1 = request.files.get("file1")
file2 = request.files.get("file2")
p1 = os.path.join(UPLOAD_FOLDER, f"{uuid.uuid4()}_{file1.filename}")
p2 = os.path.join(UPLOAD_FOLDER, f"{uuid.uuid4()}_{file2.filename}")
file1.save(p1)
file2.save(p2)
df1, h1 = smart_read(p1)
df2, h2 = smart_read(p2)
result, k1, k2, stats = auto_join(df1, df2)
matched_count = stats["matched_count"]
unmatched_count = stats["unmatched_count"]
match_rate = stats["match_rate"]
match_info = (
f"匹配字段:{k1}{k2} "
f"匹配率:{match_rate}% "
f"来源:{'有表头' if h1 else '无表头'} / {'有表头' if h2 else '无表头'}"
)
out_name = f"v2_{uuid.uuid4().hex}.xlsx"
out_path = os.path.join(OUTPUT_FOLDER, out_name)
result.to_excel(out_path, index=False)
preview = result.head(100).fillna("").to_html(
classes="table table-dark table-hover",
index=False
)
download_file = out_name
message = f"匹配成功,共 {len(result)} 条记录"
system_logger.info(
f"[SmartETL-v2.3] {match_info} "
f"matched={matched_count}, unmatched={unmatched_count}"
)
except Exception as e:
message = str(e)
system_logger.exception("[SmartETL-v2 ERROR]")
return render_template(
"smart_data_v2.html",
preview=preview,
download_file=download_file,
message=message,
match_info=match_info,
matched_count=matched_count,
unmatched_count=unmatched_count,
match_rate=match_rate
)
# =========================
# download
# =========================
def smart_download_v2(filename):
path = os.path.join(OUTPUT_FOLDER, filename)
system_logger.info(f"[SmartDownload-v2] {filename}")
return send_file(path, as_attachment=True)

@ -0,0 +1,140 @@
import os
import json
import time
import uuid
import re
from flask import render_template, request, url_for
from common.logger import system_logger
STORE_FILE = os.path.join(
os.path.dirname(__file__),
"temp_text_store.json"
)
EXPIRE_SECONDS = 3600 # 1小时
# =========================
# load
# =========================
def load_store():
if not os.path.exists(STORE_FILE):
return {}
try:
with open(STORE_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except:
return {}
# =========================
# save
# =========================
def save_store(data):
with open(STORE_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
# =========================
# markdown轻量格式化V2新增
# =========================
def normalize_markdown(text: str) -> str:
if not text:
return ""
text = text.replace("\r\n", "\n").replace("\r", "\n")
# 去掉多余空行最多保留1个空行
text = re.sub(r"\n{3,}", "\n\n", text)
# 行首尾空格清理
lines = [line.rstrip() for line in text.split("\n")]
return "\n".join(lines).strip()
# =========================
# cleanup
# =========================
def cleanup(store):
now = time.time()
expired = [
k for k, v in store.items()
if v["expires_at"] < now
]
for k in expired:
del store[k]
return store
# =========================
# 创建页面
# =========================
def temp_text_create_page():
message = None
share_link = None
if request.method == "POST":
content = request.form.get("content", "")
if not content.strip():
return render_template("temp_text.html", message="内容不能为空")
# ✔ markdown规范化
content = normalize_markdown(content)
store = load_store()
store = cleanup(store)
text_id = uuid.uuid4().hex[:8]
now = time.time()
store[text_id] = {
"content": content,
"created_at": now,
"expires_at": now + EXPIRE_SECONDS
}
save_store(store)
share_link = url_for("temp_text_view_route", text_id=text_id, _external=True)
system_logger.info(f"[TempTextV2] create id={text_id}")
message = "创建成功1小时有效"
return render_template(
"temp_text.html",
message=message,
share_link=share_link
)
# =========================
# 查看页面
# =========================
def temp_text_view(text_id):
store = load_store()
store = cleanup(store)
if text_id not in store:
return "内容不存在或已过期"
content = store[text_id]["content"]
system_logger.info(f"[TempTextV2] view id={text_id}")
return render_template(
"temp_text_view.html",
content=content,
text_id=text_id
)

@ -0,0 +1,185 @@
import os
import time
from flask import render_template, request, send_from_directory
from werkzeug.utils import secure_filename
from common.logger import system_logger
# ==========================
# 路径配置
# ==========================
BASE_DIR = os.path.dirname(
os.path.dirname(os.path.abspath(__file__))
)
TEMP_FOLDER = os.path.join(
BASE_DIR,
"uploads",
"temp"
)
os.makedirs(TEMP_FOLDER, exist_ok=True)
# ==========================
# 限制配置
# ==========================
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
UPLOAD_LIMIT = 5 # 次数
LIMIT_WINDOW = 60 * 60 # 10分钟
UPLOAD_RECORD = {} # IP限流记录
# ==========================
# 工具函数
# ==========================
def get_client_ip():
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
return request.remote_addr
def check_rate_limit(ip):
now = time.time()
if ip not in UPLOAD_RECORD:
UPLOAD_RECORD[ip] = []
# 清理过期记录
UPLOAD_RECORD[ip] = [
t for t in UPLOAD_RECORD[ip]
if now - t < LIMIT_WINDOW
]
if len(UPLOAD_RECORD[ip]) >= UPLOAD_LIMIT:
remain = int(LIMIT_WINDOW - (now - UPLOAD_RECORD[ip][0]))
system_logger.info(
f"[TempUpload] rate limited ip={ip} remain={remain}s"
)
raise Exception(f"上传过于频繁,请 {remain} 秒后重试")
UPLOAD_RECORD[ip].append(now)
# ==========================
# 页面主逻辑
# ==========================
def temp_upload_page():
message = None
ip = get_client_ip()
if request.method == "POST":
try:
file = request.files.get("file")
if not file or not file.filename:
system_logger.info(
f"[TempUpload] upload rejected (no file) ip={ip}"
)
raise Exception("请选择文件")
# 文件大小检测
file.seek(0, os.SEEK_END)
file_size = file.tell()
file.seek(0)
if file_size > MAX_FILE_SIZE:
system_logger.info(
f"[TempUpload] file too large ip={ip} size={file_size}"
)
raise Exception("文件超过50MB限制")
# IP限流
check_rate_limit(ip)
filename = secure_filename(file.filename)
save_path = os.path.join(
TEMP_FOLDER,
filename
)
file.save(save_path)
message = f"上传成功:{filename}"
system_logger.info(
f"[TempUpload] upload success "
f"file={filename} ip={ip} size={file_size}"
)
except Exception as e:
message = str(e)
system_logger.info(
f"[TempUpload] upload failed ip={ip} err={e}"
)
# ==========================
# 文件列表
# ==========================
files = []
for filename in os.listdir(TEMP_FOLDER):
file_path = os.path.join(TEMP_FOLDER, filename)
if os.path.isfile(file_path):
size = os.path.getsize(file_path)
files.append({
"name": filename,
"size": round(size / 1024 / 1024, 2)
})
files.sort(key=lambda x: x["name"], reverse=True)
system_logger.info(
f"[TempUpload] list files ip={ip} count={len(files)}"
)
return render_template(
"temp_upload.html",
files=files,
message=message
)
# ==========================
# 下载
# ==========================
def download_temp_file(filename):
ip = get_client_ip()
system_logger.info(
f"[TempUpload] download file={filename} ip={ip}"
)
return send_from_directory(
TEMP_FOLDER,
filename,
as_attachment=True
)

@ -0,0 +1,17 @@
import difflib
def text_diff(text1, text2):
d = difflib.HtmlDiff()
html = d.make_table(text1.splitlines(), text2.splitlines(), context=True)
return html
def page():
from flask import render_template, request
diff_result = None
text1 = request.args.get("text1", "").strip()
text2 = request.args.get("text2", "").strip()
if text1 and text2:
diff_result = text_diff(text1, text2)
return render_template("text_diff.html", diff_result=diff_result, text1=text1, text2=text2)

@ -0,0 +1,26 @@
from urllib.parse import quote, unquote
def encode_url(text):
return quote(text, safe='')
def decode_url(text):
try:
return unquote(text)
except Exception:
return "解码失败"
def page():
from flask import render_template, request
result = None
input_text = ""
action = request.args.get("action")
text = request.args.get("text", "").strip()
if text:
input_text = text
if action == "decode":
result = decode_url(text)
else:
result = encode_url(text)
return render_template("url.html", result=result, input_text=input_text)

@ -0,0 +1,236 @@
import os
import re
from datetime import datetime
from flask import render_template, request
from common.logger import system_logger
# =========================
# 路径
# =========================
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
UPLOAD_FOLDER = os.path.join(BASE_DIR, "uploads")
OUTPUT_FOLDER = os.path.join(BASE_DIR, "output")
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
os.makedirs(OUTPUT_FOLDER, exist_ok=True)
# =========================
# 工具:时间
# =========================
def now_str():
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# =========================
# 校验工号
# =========================
def validate_emp_id(emp_id: str):
if not emp_id:
return False, "工作证号不能为空"
if not re.fullmatch(r"\d{8}", emp_id):
return False, "工作证号必须为8位数字如 00313390"
return True, None
# =========================
# SQL授权模式原逻辑
# =========================
def build_auth_sql(center_name: str, emp_id: str):
like_center = f"%{center_name}%"
ts = now_str()
return f"""-- =========================================
-- 每周动态人员授权 SQL标准版
-- 生成时间: {ts}
-- 中心: {center_name}
-- 工号: {emp_id}
-- =========================================
-- =========================
-- Step 1: 获取部门ID
-- =========================
select f_id as department_id
from wd_department
where f_name like '{like_center}';
-- =========================
-- Step 2: 查询部门下已有人员配置
-- =========================
select *
from wd_user
where f_department = (
select f_id
from wd_department
where f_name like '{like_center}'
);
-- =========================
-- Step 3: 查询人员基础信息
-- =========================
select
f_user_id,
f_user_name,
t.f_phone
from t_wxdept_users t
where f_user_id = '{emp_id}';
-- =========================
-- Step 4: 获取新增主键
-- =========================
select max(f_id) + 1 as f_id
from wd_user;
-- =========================
-- Step 5: 新增用户授权核心
-- =========================
insert into wd_user values(
'主键',
'人员姓名',
'{emp_id}',
(
select f_id
from wd_department
where f_name like '{like_center}'
),
0,
'手机号',
now()
);
commit;
-- =========================
-- Step 6: 更新用户信息兜底同步
-- =========================
update wd_user
set
f_name = '人员姓名',
f_uid = '{emp_id}',
f_desc = '{emp_id}'
where f_id = 10182;
commit;
"""
# =========================
# SQL重复动态处理新增核心
# =========================
def build_rebuild_sql(center_name: str):
like_center = f"%{center_name}%"
ts = now_str()
return f"""-- =========================================
-- 重复动态处理 SQL
-- 生成时间: {ts}
-- 中心: {center_name}
-- =========================================
-- 1. 获取部门ID
select * from wd_department where f_name like '{like_center}';
-- 2. 获取最新关联动态ID
select * from wd_department where f_name like '{like_center}';
-- 3. 查最新通知记录按时间
select *
from wd_document
where f_department_id = (
select f_id from wd_department where f_name like '{like_center}'
)
order by f_create_date desc;
-- 4. 查重复通知ID示例
select *
from wd_document
where f_department_id = (
select f_id from wd_department where f_name like '{like_center}'
)
and f_id in (44992, 44991)
order by f_create_date desc;
-- 5. 查附件
select *
from wd_attachment
where f_document in (44992, 44991);
-- 6. 删除重复通知保留最新
delete from wd_document
where f_department_id = (
select f_id from wd_department where f_name like '{like_center}'
)
and f_id in (44991);
delete from wd_attachment
where f_document in (44991);
-- 7. 重新确认
select * from wd_document
where f_department_id = (
select f_id from wd_department where f_name like '{like_center}'
);
select * from wd_attachment
where f_document in (44991);
"""
# =========================
# 页面入口
# =========================
def weekly_permission_page():
sql_text = None
message = None
center_name = ""
emp_id = ""
try:
if request.method == "POST":
center_name = (request.form.get("center_name") or "").strip()
emp_id = (request.form.get("emp_id") or "").strip()
action = (request.form.get("action") or "auth").strip()
# ===== 校验 =====
if not center_name:
raise Exception("中心名称不能为空")
ok, err = validate_emp_id(emp_id)
if not ok:
raise Exception(err)
# ===== 分流核心 =====
if action == "auth":
sql_text = build_auth_sql(center_name, emp_id)
message = "授权SQL生成成功"
elif action == "rebuild":
sql_text = build_rebuild_sql(center_name)
message = "重复动态处理SQL生成成功"
else:
raise Exception("未知操作类型")
system_logger.info(
f"[WeeklySQL] action={action}, center={center_name}, emp_id={emp_id}"
)
except Exception as e:
message = f"错误:{str(e)}"
system_logger.exception("[WeeklyPermission ERROR]")
return render_template(
"weekly_permission.html",
sql_text=sql_text,
message=message,
center_name=center_name,
emp_id=emp_id
)

@ -0,0 +1,57 @@
[uwsgi]
uid = uwsgi
gid = uwsgi
# 监听地址和端口
http-socket = 0.0.0.0:8209
# 虚拟环境
virtualenv = /opt/service/python_prj/pictoHub.env
# Flask 应用入口
wsgi-file = /opt/service/python_prj/toolHub/app.py
callable = app
# 静态文件
static-map = /static=/opt/service/python_prj/toolHub/static/
# 日志
logto = /var/log/uwsgi/tool-project.log
# 进程
processes = 4
# 虚拟环境路径
home = /opt/service/python_prj/pictoHub.env
# ========== 冷启动优化 ==========
# worker 预加载应用,不在请求时懒加载
lazy-apps = false
# 单实例模式,避免重复加载 Python 解释器
single-interpreter = true
# 启用线程
enable-threads = true
# 最后一个 worker 不会在所有请求完成后退出,保持热状态
max-worker-lifetime = 3600
reload-mercy = 8
# 内存超过 256MB 时自动回收 worker
reload-on-rss = 256
evil-reload-on-rss = 512
# 碰后的平滑重启
honour-stdin = false
# 定时触发,保持至少一个 worker 热载
# cheaper-algo = busyness
# cheaper = 1
# cheaper-initial = 1
# cheaper-step = 1
# cheaper-busy-timeout = 30
# 热更新
touch-reload = /opt/service/python_prj/toolHub/app.py
Loading…
Cancel
Save