feat: initial commit - lightweight ticket system with Flask+SQLite
Features: - User authentication (login/register) - Ticket CRUD with status/priority/category - Rich text description with paste-to-image - File attachment upload (images/documents) - Multi-select CC recipients - Role-based permission control (admin/user) - In-app notifications - Statistics dashboard - Gunicorn production deployment
This commit is contained in:
@@ -0,0 +1,775 @@
|
||||
"""
|
||||
轻量级工单系统 - Flask + SQLite
|
||||
增强版:分类 / 优先级 / 标签 / 统计看板 / 登录 / 站内通知
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from flask import (
|
||||
Flask, request, redirect, render_template, session, abort, url_for,
|
||||
jsonify, send_from_directory,
|
||||
)
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["SECRET_KEY"] = "ticket-system-secret-key-change-in-production"
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///tickets.db"
|
||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||
app.config["UPLOAD_FOLDER"] = os.path.join(os.path.dirname(os.path.abspath(__file__)), "uploads")
|
||||
app.config["MAX_CONTENT_LENGTH"] = 20 * 1024 * 1024 # 20 MB
|
||||
|
||||
db = SQLAlchemy(app)
|
||||
|
||||
# ── 枚举定义 ──────────────────────────────────────────
|
||||
|
||||
STATUS = {
|
||||
"open": "新建",
|
||||
"in_progress": "进行中",
|
||||
"pending": "待处理",
|
||||
"resolved": "已完成",
|
||||
"closed": "已关闭",
|
||||
}
|
||||
|
||||
PRIORITY = {
|
||||
"low": "低",
|
||||
"medium": "中",
|
||||
"high": "高",
|
||||
"urgent": "紧急",
|
||||
}
|
||||
|
||||
CATEGORY = {
|
||||
"bug": "技术故障",
|
||||
"feature": "功能需求",
|
||||
"consult": "咨询",
|
||||
"incident": "事件处理",
|
||||
"other": "其他",
|
||||
}
|
||||
|
||||
|
||||
# ── 数据模型 ──────────────────────────────────────────
|
||||
|
||||
class User(db.Model):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(80), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(200), nullable=False)
|
||||
role = db.Column(db.String(20), default="user") # admin / user
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
def set_password(self, password):
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
def check_password(self, password):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
|
||||
class Notification(db.Model):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
content = db.Column(db.Text)
|
||||
ticket_id = db.Column(db.Integer) # 关联工单(可选)
|
||||
read = db.Column(db.Boolean, default=False)
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
user = db.relationship("User", backref=db.backref("notifications", lazy=True))
|
||||
|
||||
|
||||
class Ticket(db.Model):
|
||||
__tablename__ = "tickets"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
description = db.Column(db.Text) # 支持 HTML(内联图片)
|
||||
status = db.Column(db.String(20), default="open", nullable=False)
|
||||
priority = db.Column(db.String(20), default="medium", nullable=False)
|
||||
category = db.Column(db.String(20), default="other", nullable=False)
|
||||
assignee = db.Column(db.String(100))
|
||||
requester = db.Column(db.String(100))
|
||||
cc_users = db.Column(db.String(500), default="") # 抄送人(逗号分隔)
|
||||
tags = db.Column(db.String(500), default="")
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = db.Column(
|
||||
db.DateTime,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
comments = db.relationship(
|
||||
"Comment", backref="ticket", lazy=True, cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class Comment(db.Model):
|
||||
__tablename__ = "comments"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
ticket_id = db.Column(db.Integer, db.ForeignKey("tickets.id"), nullable=False)
|
||||
author = db.Column(db.String(100), nullable=False)
|
||||
content = db.Column(db.Text, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class Attachment(db.Model):
|
||||
"""工单附件(图片 / 文件)"""
|
||||
__tablename__ = "attachments"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
ticket_id = db.Column(db.Integer, db.ForeignKey("tickets.id"), nullable=False)
|
||||
filename = db.Column(db.String(200), nullable=False) # 存储文件名(uuid)
|
||||
original_name = db.Column(db.String(300), nullable=False) # 原始文件名
|
||||
content_type = db.Column(db.String(100)) # MIME type
|
||||
size = db.Column(db.Integer, default=0) # 字节
|
||||
is_image = db.Column(db.Boolean, default=False)
|
||||
uploaded_by = db.Column(db.String(100))
|
||||
uploaded_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
ticket = db.relationship("Ticket", backref=db.backref(
|
||||
"attachments", lazy=True, cascade="all, delete-orphan",
|
||||
))
|
||||
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────
|
||||
|
||||
def status_label(s):
|
||||
return STATUS.get(s, s)
|
||||
|
||||
|
||||
def priority_label(p):
|
||||
return PRIORITY.get(p, p)
|
||||
|
||||
|
||||
def category_label(c):
|
||||
return CATEGORY.get(c, c)
|
||||
|
||||
|
||||
def parse_tags(tags):
|
||||
if not tags:
|
||||
return []
|
||||
return [t.strip() for t in tags.split(",") if t.strip()]
|
||||
|
||||
|
||||
def current_user():
|
||||
"""返回当前登录用户或 None"""
|
||||
if "user_id" not in session:
|
||||
return None
|
||||
return db.session.get(User, session["user_id"])
|
||||
|
||||
|
||||
def login_required(func):
|
||||
"""登录守卫装饰器"""
|
||||
def wrapper(*args, **kwargs):
|
||||
if "user_id" not in session:
|
||||
return redirect(url_for("login", next=request.path))
|
||||
return func(*args, **kwargs)
|
||||
wrapper.__name__ = func.__name__
|
||||
return wrapper
|
||||
|
||||
|
||||
def create_notification(user_id, title, content="", ticket_id=None):
|
||||
"""为指定用户创建一条通知"""
|
||||
n = Notification(
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
content=content,
|
||||
ticket_id=ticket_id,
|
||||
)
|
||||
db.session.add(n)
|
||||
|
||||
|
||||
def all_users():
|
||||
"""返回所有用户列表,供下拉选择"""
|
||||
return User.query.order_by(User.username).all()
|
||||
|
||||
|
||||
def parse_cc(cc_str):
|
||||
"""解析抄送人字段,返回列表"""
|
||||
if not cc_str:
|
||||
return []
|
||||
return [u.strip() for u in cc_str.split(",") if u.strip()]
|
||||
|
||||
|
||||
def can_access_ticket(user, ticket):
|
||||
"""
|
||||
权限控制:
|
||||
- admin 可见所有工单
|
||||
- 普通用户只能看到:自己是提交人 / 处理人 / 抄送人的工单
|
||||
"""
|
||||
if user.role == "admin":
|
||||
return True
|
||||
if ticket.requester == user.username:
|
||||
return True
|
||||
if ticket.assignee == user.username:
|
||||
return True
|
||||
if user.username in parse_cc(ticket.cc_users):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def accessible_tickets_query(me):
|
||||
"""
|
||||
返回当前用户可见的工单 Query。
|
||||
- admin: 全部
|
||||
- 普通用户: 提交人=me OR 处理人=me OR 在抄送人里
|
||||
"""
|
||||
if me.role == "admin":
|
||||
return Ticket.query
|
||||
return Ticket.query.filter(
|
||||
db.or_(
|
||||
Ticket.requester == me.username,
|
||||
Ticket.assignee == me.username,
|
||||
Ticket.cc_users.like(f"%{me.username}%"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _save_upload(file, ticket_id):
|
||||
"""保存上传文件,返回 Attachment 实例或 None"""
|
||||
me = current_user()
|
||||
if not file or file.filename == "":
|
||||
return None
|
||||
|
||||
ext = os.path.splitext(file.filename)[1].lower()
|
||||
safe_name = f"{uuid.uuid4().hex}{ext}"
|
||||
user_dir = os.path.join(app.config["UPLOAD_FOLDER"], str(ticket_id))
|
||||
os.makedirs(user_dir, exist_ok=True)
|
||||
path = os.path.join(user_dir, safe_name)
|
||||
file.save(path)
|
||||
|
||||
content_type = file.content_type or "application/octet-stream"
|
||||
is_image = content_type.startswith("image/")
|
||||
|
||||
att = Attachment(
|
||||
ticket_id=ticket_id,
|
||||
filename=safe_name,
|
||||
original_name=file.filename,
|
||||
content_type=content_type,
|
||||
size=os.path.getsize(path),
|
||||
is_image=is_image,
|
||||
uploaded_by=me.username if me else "system",
|
||||
)
|
||||
db.session.add(att)
|
||||
db.session.commit()
|
||||
return att
|
||||
|
||||
|
||||
@app.route("/api/upload", methods=["POST"])
|
||||
@login_required
|
||||
def api_upload():
|
||||
"""JSON API:上传文件(含二进制流 / 粘贴图片)"""
|
||||
ticket_id = request.args.get("t")
|
||||
if not ticket_id:
|
||||
return jsonify({"error": "缺少 ticket 参数"}), 400
|
||||
ticket = db.get_or_404(Ticket, int(ticket_id))
|
||||
user = current_user()
|
||||
if not can_access_ticket(user, ticket):
|
||||
return jsonify({"error": "无权限"}), 403
|
||||
|
||||
# 1) 普通文件上传 (multipart)
|
||||
if "file" in request.files:
|
||||
att = _save_upload(request.files["file"], ticket_id)
|
||||
# 2) 二进制流(粘贴图片、拖拽)
|
||||
elif request.data:
|
||||
import base64
|
||||
data = request.data
|
||||
# 尝试 base64 解码(前端 paste 用 base64 传)
|
||||
try:
|
||||
if request.headers.get("Content-Type", "").startswith("image/"):
|
||||
raw = data
|
||||
elif request.headers.get("Content-Type") == "application/octet-stream":
|
||||
raw = data
|
||||
else:
|
||||
raw = data
|
||||
ext = ".png"
|
||||
# 猜测扩展名
|
||||
ct = request.headers.get("Content-Type", "")
|
||||
ext_map = {
|
||||
"image/jpeg": ".jpg", "image/png": ".png",
|
||||
"image/gif": ".gif", "image/webp": ".webp",
|
||||
}
|
||||
ext = ext_map.get(ct, ".png")
|
||||
safe_name = f"{uuid.uuid4().hex}{ext}"
|
||||
user_dir = os.path.join(app.config["UPLOAD_FOLDER"], str(ticket_id))
|
||||
os.makedirs(user_dir, exist_ok=True)
|
||||
path = os.path.join(user_dir, safe_name)
|
||||
with open(path, "wb") as f:
|
||||
f.write(raw)
|
||||
att = Attachment(
|
||||
ticket_id=ticket_id, filename=safe_name,
|
||||
original_name="pasted-image.png", content_type=ct or "image/png",
|
||||
size=os.path.getsize(path), is_image=True,
|
||||
uploaded_by=current_user().username if current_user() else "system",
|
||||
)
|
||||
db.session.add(att)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
else:
|
||||
return jsonify({"error": "没有上传文件"}), 400
|
||||
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"url": f"/uploads/{ticket_id}/{att.filename}",
|
||||
"filename": att.original_name,
|
||||
"is_image": att.is_image,
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/upload/delete/<int:aid>", methods=["POST"])
|
||||
@login_required
|
||||
def api_delete_attachment(aid):
|
||||
"""删除附件"""
|
||||
att = db.get_or_404(Attachment, aid)
|
||||
os.remove(os.path.join(app.config["UPLOAD_FOLDER"], str(att.ticket_id), att.filename))
|
||||
db.session.delete(att)
|
||||
db.session.commit()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.route("/uploads/<int:ticket_id>/<path:filename>")
|
||||
@login_required
|
||||
def serve_upload(ticket_id, filename):
|
||||
"""提供上传文件下载"""
|
||||
ticket = db.get_or_404(Ticket, ticket_id)
|
||||
user = current_user()
|
||||
if not can_access_ticket(user, ticket):
|
||||
abort(403)
|
||||
safe_dir = os.path.join(app.config["UPLOAD_FOLDER"], str(ticket_id))
|
||||
return send_from_directory(safe_dir, filename)
|
||||
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if "user_id" in session:
|
||||
return redirect(url_for("dashboard"))
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user and user.check_password(password):
|
||||
session["user_id"] = user.id
|
||||
session["username"] = user.username
|
||||
next_page = request.args.get("next") or url_for("dashboard")
|
||||
return redirect(next_page)
|
||||
return render_template(
|
||||
"login.html", error="用户名或密码错误", username=username,
|
||||
)
|
||||
return render_template("login.html")
|
||||
|
||||
|
||||
@app.route("/register", methods=["GET", "POST"])
|
||||
def register():
|
||||
if "user_id" in session:
|
||||
return redirect(url_for("dashboard"))
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
password2 = request.form.get("password2", "")
|
||||
if not username or not password:
|
||||
return render_template(
|
||||
"register.html", error="用户名和密码不能为空", username=username,
|
||||
)
|
||||
if password != password2:
|
||||
return render_template(
|
||||
"register.html", error="两次输入的密码不一致", username=username,
|
||||
)
|
||||
if User.query.filter_by(username=username).first():
|
||||
return render_template(
|
||||
"register.html", error="用户名已存在", username=username,
|
||||
)
|
||||
user = User(username=username, role="user")
|
||||
user.set_password(password)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
session["user_id"] = user.id
|
||||
session["username"] = user.username
|
||||
return redirect(url_for("dashboard"))
|
||||
return render_template("register.html")
|
||||
|
||||
|
||||
@app.route("/logout")
|
||||
def logout():
|
||||
session.pop("user_id", None)
|
||||
session.pop("username", None)
|
||||
return redirect(url_for("login"))
|
||||
|
||||
|
||||
# ── 通知路由 ──────────────────────────────────────────
|
||||
|
||||
@app.route("/notifications")
|
||||
@login_required
|
||||
def notifications():
|
||||
user = current_user()
|
||||
notifs = (
|
||||
Notification.query.filter_by(user_id=user.id)
|
||||
.order_by(Notification.created_at.desc())
|
||||
.limit(50)
|
||||
.all()
|
||||
)
|
||||
# 标记为已读
|
||||
for n in notifs:
|
||||
n.read = True
|
||||
db.session.commit()
|
||||
return render_template(
|
||||
"notifications.html", notifications=notifs, datetime=datetime,
|
||||
status_label=status_label,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/notifications/count")
|
||||
def notifications_count():
|
||||
"""返回未读通知数(供前端角标使用)"""
|
||||
count = 0
|
||||
if "user_id" in session:
|
||||
count = Notification.query.filter_by(
|
||||
user_id=session["user_id"], read=False,
|
||||
).count()
|
||||
return str(count)
|
||||
|
||||
|
||||
# ── 工单路由(全部需要登录) ──────────────────────────
|
||||
|
||||
@app.route("/")
|
||||
@login_required
|
||||
def dashboard():
|
||||
"""统计看板"""
|
||||
user = current_user()
|
||||
me = user.username
|
||||
base_query = accessible_tickets_query(user)
|
||||
|
||||
# 我的工单:提交人 + 处理人 + 抄送
|
||||
my_tickets = base_query.count()
|
||||
total = base_query.count()
|
||||
by_status = {}
|
||||
for key, label in STATUS.items():
|
||||
by_status[key] = {"label": label, "count": base_query.filter_by(status=key).count()}
|
||||
by_priority = {}
|
||||
for key, label in PRIORITY.items():
|
||||
by_priority[key] = {"label": label, "count": base_query.filter_by(priority=key).count()}
|
||||
by_category = {}
|
||||
for key, label in CATEGORY.items():
|
||||
by_category[key] = {"label": label, "count": base_query.filter_by(category=key).count()}
|
||||
recent = base_query.order_by(Ticket.updated_at.desc()).limit(10).all()
|
||||
return render_template(
|
||||
"dashboard.html",
|
||||
total=total,
|
||||
my_tickets=my_tickets,
|
||||
by_status=by_status,
|
||||
by_priority=by_priority,
|
||||
by_category=by_category,
|
||||
recent=recent,
|
||||
STATUS=STATUS,
|
||||
PRIORITY=PRIORITY,
|
||||
CATEGORY=CATEGORY,
|
||||
status_label=status_label,
|
||||
priority_label=priority_label,
|
||||
category_label=category_label,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/tickets")
|
||||
@login_required
|
||||
def ticket_list():
|
||||
"""工单列表(支持筛选)"""
|
||||
user = current_user()
|
||||
q_status = request.args.get("status", "")
|
||||
q_priority = request.args.get("priority", "")
|
||||
q_category = request.args.get("category", "")
|
||||
q_tag = request.args.get("tag", "")
|
||||
q_keyword = request.args.get("q", "")
|
||||
|
||||
query = accessible_tickets_query(user)
|
||||
|
||||
if q_status:
|
||||
query = query.filter_by(status=q_status)
|
||||
if q_priority:
|
||||
query = query.filter_by(priority=q_priority)
|
||||
if q_category:
|
||||
query = query.filter_by(category=q_category)
|
||||
if q_tag:
|
||||
query = query.filter(Ticket.tags.like(f"%{q_tag}%"))
|
||||
if q_keyword:
|
||||
kw = f"%{q_keyword}%"
|
||||
query = query.filter(
|
||||
db.or_(Ticket.title.like(kw), Ticket.description.like(kw)),
|
||||
)
|
||||
|
||||
tickets = query.order_by(Ticket.updated_at.desc()).all()
|
||||
|
||||
return render_template(
|
||||
"ticket_list.html",
|
||||
tickets=tickets,
|
||||
STATUS=STATUS,
|
||||
PRIORITY=PRIORITY,
|
||||
CATEGORY=CATEGORY,
|
||||
status_label=status_label,
|
||||
priority_label=priority_label,
|
||||
category_label=category_label,
|
||||
parse_tags=parse_tags,
|
||||
q_status=q_status,
|
||||
q_priority=q_priority,
|
||||
q_category=q_category,
|
||||
q_tag=q_tag,
|
||||
q_keyword=q_keyword,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/ticket/<int:tid>")
|
||||
@login_required
|
||||
def ticket_detail(tid):
|
||||
t = db.get_or_404(Ticket, tid)
|
||||
user = current_user()
|
||||
if not can_access_ticket(user, t):
|
||||
abort(403)
|
||||
atts = Attachment.query.filter_by(ticket_id=tid).order_by(
|
||||
Attachment.uploaded_at.desc()
|
||||
).all()
|
||||
return render_template(
|
||||
"ticket_detail.html",
|
||||
ticket=t,
|
||||
attachments=atts,
|
||||
STATUS=STATUS,
|
||||
PRIORITY=PRIORITY,
|
||||
CATEGORY=CATEGORY,
|
||||
status_label=status_label,
|
||||
priority_label=priority_label,
|
||||
category_label=category_label,
|
||||
parse_tags=parse_tags,
|
||||
parse_cc=parse_cc,
|
||||
datetime=datetime,
|
||||
all_users=all_users,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/ticket/new", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def ticket_new():
|
||||
return _ticket_form(None)
|
||||
|
||||
|
||||
@app.route("/ticket/<int:tid>/edit", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def ticket_edit(tid):
|
||||
t = db.get_or_404(Ticket, tid)
|
||||
user = current_user()
|
||||
if not can_access_ticket(user, t):
|
||||
abort(403)
|
||||
return _ticket_form(t)
|
||||
|
||||
|
||||
def _ticket_form(ticket):
|
||||
me = current_user()
|
||||
if request.method == "POST":
|
||||
title = request.form.get("title", "").strip()
|
||||
desc = request.form.get("description", "").strip()
|
||||
status = request.form.get("status", "open")
|
||||
priority = request.form.get("priority", "medium")
|
||||
category = request.form.get("category", "other")
|
||||
assignee = request.form.get("assignee", "").strip()
|
||||
requester = request.form.get("requester", "").strip()
|
||||
cc_str = request.form.get("cc_users", "").strip()
|
||||
tags = request.form.get("tags", "").strip()
|
||||
|
||||
if not title:
|
||||
return "标题不能为空", 400
|
||||
|
||||
if ticket:
|
||||
ticket.title = title
|
||||
ticket.description = desc
|
||||
ticket.status = status
|
||||
ticket.priority = priority
|
||||
ticket.category = category
|
||||
ticket.assignee = assignee or None
|
||||
ticket.requester = requester or None
|
||||
ticket.cc_users = cc_str
|
||||
ticket.tags = tags
|
||||
# 分配人变更时通知新处理人
|
||||
if assignee and assignee != me.username:
|
||||
u = User.query.filter_by(username=assignee).first()
|
||||
if u:
|
||||
create_notification(
|
||||
u.id,
|
||||
f"工单 #{ticket.id} 已分配给你",
|
||||
title,
|
||||
ticket_id=ticket.id,
|
||||
)
|
||||
else:
|
||||
ticket = Ticket(
|
||||
title=title,
|
||||
description=desc,
|
||||
status=status,
|
||||
priority=priority,
|
||||
category=category,
|
||||
assignee=assignee or None,
|
||||
requester=me.username,
|
||||
cc_users=cc_str,
|
||||
tags=tags,
|
||||
)
|
||||
db.session.add(ticket)
|
||||
db.session.commit()
|
||||
|
||||
# 通知新增的抄送人
|
||||
cc_list = parse_cc(cc_str)
|
||||
for uname in cc_list:
|
||||
u = User.query.filter_by(username=uname).first()
|
||||
if u and u.username != me.username:
|
||||
create_notification(
|
||||
u.id,
|
||||
f"工单 #{ticket.id} 被抄送给你",
|
||||
title,
|
||||
ticket_id=ticket.id,
|
||||
)
|
||||
|
||||
return redirect(f"/ticket/{ticket.id}")
|
||||
|
||||
return render_template(
|
||||
"ticket_form.html",
|
||||
ticket=ticket,
|
||||
STATUS=STATUS,
|
||||
PRIORITY=PRIORITY,
|
||||
CATEGORY=CATEGORY,
|
||||
is_edit=bool(ticket),
|
||||
all_users=all_users,
|
||||
current_username=me.username if me else "",
|
||||
parse_cc=parse_cc,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/ticket/<int:tid>/comment", methods=["POST"])
|
||||
@login_required
|
||||
def add_comment(tid):
|
||||
t = db.get_or_404(Ticket, tid)
|
||||
me = current_user()
|
||||
content = request.form.get("content", "").strip()
|
||||
if not content:
|
||||
return "评论内容不能为空", 400
|
||||
c = Comment(ticket_id=tid, author=me.username, content=content)
|
||||
db.session.add(c)
|
||||
|
||||
# 通知:工单处理人 + 提交人 + 抄送人(避免通知自己)
|
||||
notify_targets = set()
|
||||
if t.assignee and t.assignee != me.username:
|
||||
notify_targets.add(t.assignee)
|
||||
if t.requester and t.requester != me.username:
|
||||
notify_targets.add(t.requester)
|
||||
for uname in parse_cc(t.cc_users):
|
||||
if uname != me.username:
|
||||
notify_targets.add(uname)
|
||||
for uname in notify_targets:
|
||||
u = User.query.filter_by(username=uname).first()
|
||||
if u:
|
||||
create_notification(
|
||||
u.id,
|
||||
f"工单 #{tid} 有新评论",
|
||||
f"{me.username}: {content[:50]}{'...' if len(content) > 50 else ''}",
|
||||
ticket_id=tid,
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
return redirect(f"/ticket/{tid}")
|
||||
|
||||
|
||||
@app.route("/ticket/<int:tid>/close", methods=["POST"])
|
||||
@login_required
|
||||
def close_ticket(tid):
|
||||
t = db.get_or_404(Ticket, tid)
|
||||
t.status = "closed"
|
||||
db.session.commit()
|
||||
return redirect(f"/ticket/{tid}")
|
||||
|
||||
|
||||
@app.route("/ticket/<int:tid>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def delete_ticket(tid):
|
||||
t = db.get_or_404(Ticket, tid)
|
||||
db.session.delete(t)
|
||||
db.session.commit()
|
||||
return redirect("/tickets")
|
||||
|
||||
|
||||
@app.template_filter("render_desc")
|
||||
def render_desc_html(html):
|
||||
"""
|
||||
渲染描述中的内联图片,处理 contenteditable 的 div 包装。
|
||||
只允许 <img> 和 <br> 标签,其他 HTML 转义。
|
||||
contenteditable 默认会用 <div> 包裹段落,需要去掉。
|
||||
"""
|
||||
if not html:
|
||||
return ""
|
||||
from markupsafe import Markup, escape
|
||||
import re
|
||||
|
||||
# Step 1: 将 <div>...</div> 替换为内容 + <br>,模拟内容editable的行为
|
||||
def replace_div(m):
|
||||
content = m.group(1)
|
||||
return content + "\n"
|
||||
html = re.sub(r'<div[^>]*>(.*?)</div>', replace_div, html, flags=re.DOTALL)
|
||||
|
||||
# Step 2: 将 <p>...</p> 替换为内容 + <br>
|
||||
html = re.sub(r'<p[^>]*>(.*?)</p>', replace_div, html, flags=re.DOTALL)
|
||||
|
||||
# Step 3: 将 <br> 和 <br/> 替换为 \n
|
||||
html = re.sub(r'<br\s*/?>', '\n', html)
|
||||
|
||||
# Step 4: 转义所有文本,但保留 <img> 标签
|
||||
parts = []
|
||||
last = 0
|
||||
for m in re.finditer(r'<img[^>]*>', html):
|
||||
if m.start() > last:
|
||||
parts.append(escape(html[last:m.start()]))
|
||||
parts.append(Markup(m.group(0)))
|
||||
last = m.end()
|
||||
if last < len(html):
|
||||
parts.append(escape(html[last:]))
|
||||
|
||||
result = "".join(parts)
|
||||
# 替换 \n 为 <br> 用于显示
|
||||
return Markup(result).replace("\n", "<br>")
|
||||
|
||||
|
||||
@app.errorhandler(403)
|
||||
def forbidden(e):
|
||||
return render_template("error.html", code=403, message="无权限访问此工单"), 403
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(e):
|
||||
return render_template("error.html", code=404, message="页面不存在"), 404
|
||||
|
||||
|
||||
# ── 全局模板上下文 ────────────────────────────────────
|
||||
|
||||
@app.context_processor
|
||||
def inject_globals():
|
||||
"""每个模板都能访问的用户信息 + 未读通知数"""
|
||||
unread = 0
|
||||
if "user_id" in session:
|
||||
unread = Notification.query.filter_by(
|
||||
user_id=session["user_id"], read=False,
|
||||
).count()
|
||||
return dict(current_user=current_user, unread_count=unread)
|
||||
|
||||
|
||||
# ── 启动 ──────────────────────────────────────────────
|
||||
|
||||
def seed_admin():
|
||||
"""确保存在 admin 用户(密码 admin)"""
|
||||
if not User.query.filter_by(username="admin").first():
|
||||
admin = User(username="admin", role="admin")
|
||||
admin.set_password("admin")
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
print("✅ 已创建默认管理员: admin / admin")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
seed_admin()
|
||||
app.run(host="0.0.0.0", port=5000, debug=True)
|
||||
Reference in New Issue
Block a user