""" 轻量级工单系统 - 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/", 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//") @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/") @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//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//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//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//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 包装。 只允许
标签,其他 HTML 转义。 contenteditable 默认会用
包裹段落,需要去掉。 """ if not html: return "" from markupsafe import Markup, escape import re # Step 1: 将
...
替换为内容 +
,模拟内容editable的行为 def replace_div(m): content = m.group(1) return content + "\n" html = re.sub(r']*>(.*?)
', replace_div, html, flags=re.DOTALL) # Step 2: 将

...

替换为内容 +
html = re.sub(r']*>(.*?)

', replace_div, html, flags=re.DOTALL) # Step 3: 将

替换为 \n html = re.sub(r'', '\n', html) # Step 4: 转义所有文本,但保留 标签 parts = [] last = 0 for m in re.finditer(r']*>', 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 为
用于显示 return Markup(result).replace("\n", "
") @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)