commit 26940c457463d55c423d7b4d0328681cb249407b Author: AI Agent Date: Thu Jul 2 15:31:27 2026 +0800 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 diff --git a/ticket-system/README.md b/ticket-system/README.md new file mode 100644 index 0000000..cfe6022 --- /dev/null +++ b/ticket-system/README.md @@ -0,0 +1,236 @@ +# 工单系统 (Ticket System) + +轻量级工单系统,基于 Flask + SQLite,适合个人或小团队使用。 + +## 功能特性 + +### 工单管理 +- 创建、编辑、删除、关闭工单 +- 状态流转:新建 → 进行中 → 待处理 → 已完成 → 已关闭 +- 分类管理:技术故障、功能需求、咨询、事件处理、其他 +- 优先级设置:低、中、高、紧急 +- 自由标签支持 + +### 富文本描述 +- **内容可编辑**:contenteditable 富文本编辑器 +- **图片内联显示**:支持 Ctrl+V 粘贴截图,图片直接嵌入描述中 +- **插入图片按钮**:📎 点击选择图片上传 +- **基础格式**:支持加粗、斜体 +- **粘贴上传**:粘贴的图片会自动上传到服务器 + +### 附件管理 +- 支持多种文件类型:图片、文档、压缩包等 +- 最大文件大小:20 MB +- 图片预览:支持点击放大查看 +- 附件下载:点击即可下载 + +### 用户系统 +- 用户注册、登录、登出 +- 默认管理员账号:`admin / admin` +- 密码安全存储(哈希) + +### 权限控制 +- **管理员**:可查看所有工单 +- **普通用户**:只能查看自己参与(提交人/处理人/抄送人)的工单 +- 无权限访问时返回 403 错误 + +### 抄送功能 +- 支持多选抄送人 +- 自定义下拉选择器 +- 抄送人自动收到通知 +- 抄送人可查看工单和附件 + +### 通知系统 +- 站内通知:工单分配、评论、关闭等操作自动通知相关人员 +- 未读角标:导航栏显示未读通知数量 +- 通知列表:可查看通知详情 + +### 统计看板 +- 工单总数统计 +- 按状态、优先级、分类聚合 +- 最近工单列表 +- 我的工单计数 + +## 技术栈 + +- **后端**:Flask + SQLAlchemy +- **数据库**:SQLite +- **前端**:Jinja2 模板 + 原生 JavaScript +- **部署**:Gunicorn + systemd + +## 快速开始 + +### 安装依赖 +```bash +pip install -r requirements.txt --break-system-packages +``` + +### 启动开发服务器 +```bash +python app.py +``` + +### 启动生产服务器 +```bash +gunicorn --workers 2 --bind 0.0.0.0:5000 wsgi:app +``` + +### 访问系统 +打开浏览器访问:`http://localhost:5000` + +默认管理员账号: +- 用户名:`admin` +- 密码:`admin` + +## 项目结构 + +``` +ticket-system/ +├── app.py # Flask 主程序(模型 + 路由) +├── wsgi.py # Gunicorn WSGI 入口 +├── requirements.txt # 依赖列表 +├── README.md # 项目说明 +├── instance/ +│ └── tickets.db # SQLite 数据库(自动创建) +├── uploads/ # 上传文件目录 +│ └── / # 每个工单独立的附件目录 +├── templates/ # Jinja2 模板 +│ ├── base.html # 基础布局模板 +│ ├── login.html # 登录页 +│ ├── register.html # 注册页 +│ ├── dashboard.html # 统计看板 +│ ├── ticket_list.html # 工单列表 +│ ├── ticket_form.html # 新建/编辑工单 +│ ├── ticket_detail.html # 工单详情 +│ ├── notifications.html # 通知列表 +│ └── error.html # 错误页 +└── static/ + └── style.css # 样式文件 +``` + +## 数据模型 + +### User(用户) +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Integer | 主键 | +| username | String(80) | 用户名(唯一) | +| password_hash | String(200) | 密码哈希 | +| role | String(20) | 角色(admin/user) | +| created_at | DateTime | 创建时间 | + +### Ticket(工单) +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Integer | 主键 | +| title | String(200) | 标题 | +| description | Text | 描述(支持 HTML) | +| status | String(20) | 状态 | +| priority | String(20) | 优先级 | +| category | String(20) | 分类 | +| assignee | String(100) | 处理人 | +| requester | String(100) | 提交人 | +| cc_users | String(500) | 抄送人(逗号分隔) | +| tags | String(500) | 标签(逗号分隔) | +| created_at | DateTime | 创建时间 | +| updated_at | DateTime | 更新时间 | + +### Comment(评论) +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Integer | 主键 | +| ticket_id | Integer | 所属工单 | +| author | String(100) | 评论人 | +| content | Text | 评论内容 | +| created_at | DateTime | 创建时间 | + +### Attachment(附件) +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Integer | 主键 | +| ticket_id | Integer | 所属工单 | +| filename | String(200) | 存储文件名(UUID) | +| original_name | String(300) | 原始文件名 | +| content_type | String(100) | MIME 类型 | +| size | Integer | 文件大小(字节) | +| is_image | Boolean | 是否为图片 | +| uploaded_by | String(100) | 上传人 | +| uploaded_at | DateTime | 上传时间 | + +### Notification(通知) +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Integer | 主键 | +| user_id | Integer | 接收用户 | +| title | String(200) | 通知标题 | +| content | Text | 通知内容 | +| ticket_id | Integer | 关联工单 | +| read | Boolean | 是否已读 | +| created_at | DateTime | 创建时间 | + +## 部署说明 + +### systemd 服务部署 + +1. 创建服务文件:`/etc/systemd/system/ticket-system.service` + +```ini +[Unit] +Description=Ticket System +After=network.target + +[Service] +Type=exec +WorkingDirectory=/path/to/ticket-system +Environment="PATH=/usr/local/bin:/usr/bin:/bin" +Environment="PYTHONUNBUFFERED=1" +ExecStart=/usr/local/bin/gunicorn --workers 2 --bind 0.0.0.0:5000 --access-logfile /var/log/ticket-system/access.log --error-logfile /var/log/ticket-system/error.log wsgi:app +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +2. 创建日志目录 +```bash +mkdir -p /var/log/ticket-system +``` + +3. 启动服务 +```bash +systemctl daemon-reload +systemctl enable --now ticket-system +systemctl status ticket-system +``` + +### Nginx 反向代理(可选) + +```nginx +server { + listen 80; + server_name ticket.example.com; + + location / { + proxy_pass http://127.0.0.1:5000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location /uploads/ { + alias /path/to/ticket-system/uploads/; + } +} +``` + +## 注意事项 + +1. **首次启动**:会自动创建默认管理员账号(admin/admin) +2. **数据库迁移**:新增字段需要手动添加到 SQLite 数据库 +3. **文件上传**:需要确保 `uploads/` 目录存在且可写 +4. **安全配置**:生产环境请修改 `SECRET_KEY` 和默认管理员密码 +5. **最大上传大小**:默认 20 MB,可在 `app.py` 中修改 `MAX_CONTENT_LENGTH` + +## 许可证 + +MIT License diff --git a/ticket-system/app.py b/ticket-system/app.py new file mode 100644 index 0000000..13fda51 --- /dev/null +++ b/ticket-system/app.py @@ -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/", 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) diff --git a/ticket-system/requirements.txt b/ticket-system/requirements.txt new file mode 100644 index 0000000..0a31475 --- /dev/null +++ b/ticket-system/requirements.txt @@ -0,0 +1,3 @@ +Flask==3.0.0 +Flask-SQLAlchemy==3.1.1 +gunicorn==21.2.0 diff --git a/ticket-system/static/style.css b/ticket-system/static/style.css new file mode 100644 index 0000000..70c44b4 --- /dev/null +++ b/ticket-system/static/style.css @@ -0,0 +1,482 @@ +/* ── 全局 ─────────────────────────────────────────── */ +:root { + --bg: #f5f6fa; + --card: #ffffff; + --border: #e1e4ea; + --text: #1a1d23; + --text-muted: #6b7280; + --primary: #4f46e5; + --primary-light: #eef2ff; + --danger: #ef4444; + --warning: #f59e0b; + --success: #10b981; + --info: #3b82f6; + --radius: 8px; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans SC", sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.6; +} + +a { color: var(--primary); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* ── 导航 ─────────────────────────────────────────── */ +.navbar { + background: var(--card); + border-bottom: 1px solid var(--border); + padding: 0 24px; + height: 56px; + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + top: 0; + z-index: 100; +} +.nav-brand a { font-size: 18px; font-weight: 700; color: var(--text); text-decoration: none; } +.nav-links { display: flex; gap: 8px; align-items: center; } +.nav-link { padding: 6px 14px; border-radius: 6px; font-size: 14px; color: var(--text-muted); } +.nav-link:hover, .nav-link.active { background: var(--primary-light); color: var(--primary); text-decoration: none; } +.btn-new { background: var(--primary) !important; color: #fff !important; font-weight: 600; } +.btn-new:hover { background: #4338ca !important; text-decoration: none; } + +/* ── 容器 ─────────────────────────────────────────── */ +.container { max-width: 1100px; margin: 0 auto; padding: 24px; } + +/* ── 卡片 ─────────────────────────────────────────── */ +.card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; +} + +/* ── 看板 ─────────────────────────────────────────── */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 12px; + margin-bottom: 28px; +} +.stat-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + text-align: center; +} +.stat-card .num { font-size: 32px; font-weight: 700; } +.stat-card .label { font-size: 13px; color: var(--text-muted); margin-top: 4px; } +.stat-card.total .num { color: var(--primary); } +.stat-card.open .num { color: var(--info); } +.stat-card.in_progress .num { color: var(--warning); } +.stat-card.pending .num { color: #8b5cf6; } +.stat-card.resolved .num { color: var(--success); } +.stat-card.closed .num { color: #6b7280; } + +h2.section-title { font-size: 16px; margin-bottom: 14px; color: var(--text-muted); } + +/* ── 工单列表 ─────────────────────────────────────── */ +.filters { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 16px; + align-items: center; +} +.filters label { font-size: 13px; color: var(--text-muted); } +.filters select, .filters input { + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 14px; + background: var(--card); +} +.filters input { width: 180px; } +.filters form { display: inline-flex; align-items: center; gap: 6px; } + +.ticket-table { width: 100%; border-collapse: collapse; } +.ticket-table th { + text-align: left; + padding: 10px 12px; + font-size: 12px; + text-transform: uppercase; + color: var(--text-muted); + border-bottom: 2px solid var(--border); +} +.ticket-table td { + padding: 10px 12px; + border-bottom: 1px solid var(--border); + font-size: 14px; +} +.ticket-table tr:hover { background: #fafbff; } + +/* ── 徽章 ─────────────────────────────────────────── */ +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 12px; + font-weight: 600; + line-height: 1.4; +} +.badge-open { background: #dbeafe; color: #1e40af; } +.badge-in_progress { background: #fef3c7; color: #92400e; } +.badge-pending { background: #ede9fe; color: #5b21b6; } +.badge-resolved { background: #d1fae5; color: #065f46; } +.badge-closed { background: #e5e7eb; color: #374151; } + +.badge-urgent { background: #fee2e2; color: #991b1b; } +.badge-high { background: #ffedd5; color: #9a3412; } +.badge-medium { background: #fef3c7; color: #92400e; } +.badge-low { background: #dbeafe; color: #1e40af; } + +.badge-bug { background: #fee2e2; color: #991b1b; } +.badge-feature { background: #dbeafe; color: #1e40af; } +.badge-consult { background: #d1fae5; color: #065f46; } +.badge-incident { background: #fef3c7; color: #92400e; } +.badge-other { background: #e5e7eb; color: #374151; } + +/* ── 标签 ─────────────────────────────────────────── */ +.tag { + display: inline-block; + padding: 1px 7px; + border-radius: 4px; + font-size: 11px; + background: #f0f0f0; + color: #555; + margin-right: 3px; +} + +/* ── 表单 ─────────────────────────────────────────── */ +.form-group { margin-bottom: 16px; } +.form-group label { display: block; font-weight: 600; margin-bottom: 4px; font-size: 14px; } +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 14px; + background: var(--card); +} +.form-group textarea { min-height: 100px; resize: vertical; } +.form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; } + +.btn { + display: inline-block; + padding: 8px 18px; + border-radius: 6px; + font-size: 14px; + font-weight: 600; + border: none; + cursor: pointer; + text-decoration: none; +} +.btn-primary { background: var(--primary); color: #fff; } +.btn-primary:hover { background: #4338ca; text-decoration: none; } +.btn-danger { background: var(--danger); color: #fff; } +.btn-danger:hover { background: #dc2626; text-decoration: none; } +.btn-secondary { background: #e5e7eb; color: #374151; } + +/* ── 详情页 ───────────────────────────────────────── */ +.detail-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 20px; + flex-wrap: wrap; + gap: 12px; +} +.detail-header h1 { font-size: 22px; } +.detail-meta { font-size: 13px; color: var(--text-muted); margin-top: 4px; } +.detail-grid { display: grid; grid-template-columns: 1fr 280px; gap: 20px; } +.detail-grid > div:first-child { min-width: 0; } +.comment-list { margin-top: 8px; } +.comment { + border-left: 3px solid var(--primary); + padding: 10px 14px; + margin-bottom: 12px; + background: var(--primary-light); + border-radius: 4px; +} +.comment .author { font-weight: 600; font-size: 13px; } +.comment .time { font-size: 12px; color: var(--text-muted); margin-left: 8px; } +.comment .content { margin-top: 4px; white-space: pre-wrap; } +.sidebar .card { margin-bottom: 14px; } + +/* ── 通知角标 ───────────────────────────────────────── */ +.notif-link { position: relative; } +.notif-badge { + position: absolute; + top: -4px; + right: -4px; + background: var(--danger); + color: #fff; + font-size: 10px; + font-weight: 700; + padding: 1px 5px; + border-radius: 10px; + min-width: 16px; + text-align: center; +} +.user-info { + font-size: 14px; + padding: 6px 12px; + color: var(--text); + font-weight: 600; +} + +/* ── 登录页 ─────────────────────────────────────────── */ +.auth-page { + display: flex; + justify-content: center; + align-items: center; + min-height: 80vh; +} +.auth-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 32px; + width: 100%; + max-width: 400px; + box-shadow: 0 2px 8px rgba(0,0,0,0.05); +} +.auth-card h1 { text-align: center; margin-bottom: 24px; font-size: 22px; } +.auth-card .error { + background: #fee2e2; + color: #991b1b; + padding: 8px 12px; + border-radius: 6px; + font-size: 13px; + margin-bottom: 14px; +} +.auth-link { text-align: center; margin-top: 16px; font-size: 14px; color: var(--text-muted); } + +/* ── 通知列表 ───────────────────────────────────────── */ +.notif-item { + padding: 12px 16px; + border-left: 3px solid var(--primary); + background: var(--primary-light); + border-radius: 4px; + margin-bottom: 10px; +} +.notif-item.unread { border-left-color: var(--danger); background: #fef2f2; } +.notif-item .notif-title { font-weight: 600; font-size: 14px; } +.notif-item .notif-content { font-size: 13px; color: var(--text-muted); margin-top: 2px; } +.notif-item .notif-time { font-size: 12px; color: var(--text-muted); margin-top: 4px; } + +/* ── 通用表单下拉优化 ─────────────────────────────── */ +.form-row-select { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; } + +/* ── 附件上传区 ─────────────────────────────────── */ +.attachment-zone { + border: 2px dashed var(--border); + border-radius: var(--radius); + padding: 24px; + text-align: center; + cursor: pointer; + transition: border-color 0.2s, background 0.2s; + background: #fafbff; + margin-top: 12px; +} +.attachment-zone:hover, +.attachment-zone.drag-over { + border-color: var(--primary); + background: var(--primary-light); +} +.attachment-zone .az-label { color: var(--text-muted); font-size: 14px; } +.attachment-zone .az-hint { color: var(--text-muted); font-size: 12px; margin-top: 4px; } +.attachment-zone input[type="file"] { display: none; } + +/* 附件列表 */ +.attachment-list { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; } +.attachment-item { + position: relative; + background: var(--card); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px; + max-width: 200px; + display: flex; + flex-direction: column; + gap: 4px; +} +.attachment-item .att-thumb { + width: 100%; + max-height: 150px; + object-fit: cover; + border-radius: 4px; + cursor: pointer; +} +.attachment-item .att-info { + font-size: 12px; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.attachment-item .att-delete { + position: absolute; + top: 4px; + right: 4px; + background: rgba(0,0,0,0.5); + color: #fff; + border: none; + border-radius: 50%; + width: 20px; + height: 20px; + font-size: 11px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s; +} +.attachment-item:hover .att-delete { opacity: 1; } +.attachment-item .att-link { font-size: 13px; word-break: break-all; } +.attachment-item .att-icon { + font-size: 32px; + text-align: center; + padding: 8px 0; +} + +/* 图片预览模态框 */ +.image-modal { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,0.85); + z-index: 1000; + justify-content: center; + align-items: center; + cursor: pointer; +} +.image-modal.show { display: flex; } +.image-modal img { + max-width: 90vw; + max-height: 90vh; + border-radius: 8px; + box-shadow: 0 8px 40px rgba(0,0,0,0.5); +} + +/* 上传进度条 */ +.upload-progress { + height: 3px; + background: var(--border); + border-radius: 2px; + margin-top: 8px; + overflow: hidden; + display: none; +} +.upload-progress.active { display: block; } +.upload-progress-bar { + height: 100%; + background: var(--primary); + width: 0%; + transition: width 0.3s; +} + +/* ── 富文本编辑器 ─────────────────────────────────── */ +.rich-editor { + min-height: 100px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--card); + outline: none; + overflow-y: auto; + max-height: 300px; + line-height: 1.6; +} +.rich-editor:focus { border-color: var(--primary); } +.rich-editor:empty::before { + content: attr(data-placeholder); + color: var(--text-muted); +} +.rich-toolbar { + display: flex; + gap: 4px; + margin-bottom: 6px; + flex-wrap: wrap; +} +.rich-toolbar button { + padding: 4px 10px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--card); + cursor: pointer; + font-size: 13px; +} +.rich-toolbar button:hover { background: var(--primary-light); border-color: var(--primary); } +.inline-image { max-width: 100%; max-height: 300px; border-radius: 4px; margin: 4px 0; } + +/* ── 抄送人选择器 ─────────────────────────────────── */ +.cc-select-wrap { position: relative; } +.cc-input { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 14px; + background: var(--card); + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; + min-height: 38px; + flex-wrap: wrap; +} +.cc-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + background: var(--primary-light); + color: var(--primary); + border-radius: 12px; + font-size: 12px; + font-weight: 600; +} +.cc-chip .chip-x { + cursor: pointer; + font-weight: 700; + color: var(--primary); +} +.cc-dropdown { + display: none; + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--card); + border: 1px solid var(--border); + border-radius: 6px; + max-height: 200px; + overflow-y: auto; + z-index: 50; + box-shadow: 0 4px 12px rgba(0,0,0,0.1); +} +.cc-dropdown.show { display: block; } +.cc-option { + padding: 8px 12px; + cursor: pointer; + font-size: 14px; + display: flex; + justify-content: space-between; + align-items: center; +} +.cc-option:hover { background: var(--primary-light); } +.cc-option.selected { background: var(--primary-light); color: var(--primary); font-weight: 600; } +.cc-option .check { font-weight: 700; } +.cc-placeholder { color: var(--text-muted); font-size: 13px; padding: 8px 12px; } +.footer { text-align: center; padding: 20px; color: var(--text-muted); font-size: 12px; } diff --git a/ticket-system/templates/base.html b/ticket-system/templates/base.html new file mode 100644 index 0000000..b96a4f6 --- /dev/null +++ b/ticket-system/templates/base.html @@ -0,0 +1,39 @@ + + + + + + {% block title %}工单系统{% endblock %} + + + + +
+ {% block content %}{% endblock %} +
+
+

轻量级工单系统 · Flask + SQLite

+
+ + diff --git a/ticket-system/templates/dashboard.html b/ticket-system/templates/dashboard.html new file mode 100644 index 0000000..fd6bba1 --- /dev/null +++ b/ticket-system/templates/dashboard.html @@ -0,0 +1,81 @@ +{% extends "base.html" %} +{% block title %}工单看板{% endblock %} +{% block content %} +

📊 工单看板

+ + +
+
+
{{ total }}
+
总工单数
+
+
+
{{ my_tickets }}
+
我的工单
+
+
+ + +

按状态

+
+ {% for k, v in by_status.items() %} +
+
{{ v.count }}
+
{{ v.label }}
+
+ {% endfor %} +
+ + +

按优先级

+
+ {% for k, v in by_priority.items() %} +
+
{{ v.count }}
+
{{ v.label }}
+
+ {% endfor %} +
+ + +

按分类

+
+ {% for k, v in by_category.items() %} +
+
{{ v.count }}
+
{{ v.label }}
+
+ {% endfor %} +
+ + +

最近工单

+
+ {% if recent %} + + + + + + + + + + + + {% for t in recent %} + + + + + + + + {% endfor %} + +
ID标题状态优先级更新
#{{ t.id }}{{ t.title }}{{ status_label(t.status) }}{{ priority_label(t.priority) }}{{ t.updated_at.strftime('%m-%d %H:%M') }}
+ {% else %} +

暂无工单

+ {% endif %} +
+{% endblock %} diff --git a/ticket-system/templates/error.html b/ticket-system/templates/error.html new file mode 100644 index 0000000..6fc6bb0 --- /dev/null +++ b/ticket-system/templates/error.html @@ -0,0 +1,18 @@ + + + + + + {{ code }} - 工单系统 + + + +
+
+

{{ code }}

+

{{ message }}

+ 返回首页 +
+
+ + diff --git a/ticket-system/templates/login.html b/ticket-system/templates/login.html new file mode 100644 index 0000000..e6e6edc --- /dev/null +++ b/ticket-system/templates/login.html @@ -0,0 +1,31 @@ + + + + + + 登录 - 工单系统 + + + +
+
+

📋 工单系统

+ {% if error %} +
{{ error }}
+ {% endif %} +
+
+ + +
+
+ + +
+ +
+ +
+
+ + diff --git a/ticket-system/templates/notifications.html b/ticket-system/templates/notifications.html new file mode 100644 index 0000000..68c9f2f --- /dev/null +++ b/ticket-system/templates/notifications.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}通知{% endblock %} +{% block content %} +

🔔 通知

+ +{% if notifications %} +{% for n in notifications %} +
+
+ {% if n.ticket_id %} + {{ n.title }} + {% else %} + {{ n.title }} + {% endif %} +
+ {% if n.content %} +
{{ n.content }}
+ {% endif %} +
{{ n.created_at.strftime('%Y-%m-%d %H:%M') }}
+
+{% endfor %} +{% else %} +
+ 暂无通知 +
+{% endif %} +{% endblock %} diff --git a/ticket-system/templates/register.html b/ticket-system/templates/register.html new file mode 100644 index 0000000..b447848 --- /dev/null +++ b/ticket-system/templates/register.html @@ -0,0 +1,35 @@ + + + + + + 注册 - 工单系统 + + + +
+
+

📋 创建账号

+ {% if error %} +
{{ error }}
+ {% endif %} +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ + diff --git a/ticket-system/templates/ticket_detail.html b/ticket-system/templates/ticket_detail.html new file mode 100644 index 0000000..83b9321 --- /dev/null +++ b/ticket-system/templates/ticket_detail.html @@ -0,0 +1,298 @@ +{% extends "base.html" %} +{% block title %}工单 #{{ ticket.id }}{% endblock %} +{% block content %} +
+
+

#{{ ticket.id }} {{ ticket.title }}

+
+ 创建 {{ ticket.created_at.strftime('%Y-%m-%d %H:%M') }} · + 更新 {{ ticket.updated_at.strftime('%Y-%m-%d %H:%M') }} +
+
+
+ 编辑 + {% if ticket.status != 'closed' and ticket.status != 'resolved' %} +
+ +
+ {% endif %} +
+ +
+
+
+ +
+ +
+ +
+

描述

+
{{ ticket.description | render_desc }}
+
+ + +
+

+ 附件({{ attachments|length }}) +

+ + +
+ +
📎 点击选择、拖拽或粘贴(Ctrl+V)上传图片/附件
+
支持图片、文档、压缩包等,最大 20 MB
+
+ + +
+ {% for att in attachments %} +
+ {% if att.is_image %} + {{ att.original_name }} + {% else %} +
+ {% if att.content_type == 'application/pdf' %}📄 + {% elif att.content_type.startswith('text/') or att.content_type == 'application/json' %}📝 + {% elif att.content_type.startswith('application/') %}📦 + {% else %}📎{% endif %} +
+ + {{ att.original_name }} + + {% endif %} +
+ {{ att.uploaded_by }} · {{ att.uploaded_at.strftime('%m-%d %H:%M') }} + · {{ att.size / 1024 | round(1) }} KB +
+ +
+ {% endfor %} +
+ +
+
+
+
+ + +
+

+ 评论({{ ticket.comments|length }}) +

+
+ {% for c in ticket.comments %} +
+
+ {{ c.author }} + {{ c.created_at.strftime('%m-%d %H:%M') }} +
+
{{ c.content }}
+
+ {% endfor %} +
+ +
+
+ + +
+ +
+
+
+ + + +
+ + +
+ +
+ + +{% endblock %} diff --git a/ticket-system/templates/ticket_form.html b/ticket-system/templates/ticket_form.html new file mode 100644 index 0000000..1f39a99 --- /dev/null +++ b/ticket-system/templates/ticket_form.html @@ -0,0 +1,367 @@ +{% extends "base.html" %} +{% block title %}{% if is_edit %}编辑工单{% else %}新建工单{% endif %}{% endblock %} +{% block content %} +

{% if is_edit %}✏️ 编辑工单{% else %}➕ 新建工单{% endif %}

+ +
+
+ + +
+ + +
+ +
+ + + + + 可直接 Ctrl+V 粘贴截图,图片会内联显示 +
+
+ {{ (ticket.description | safe) if is_edit else '' }} +
+ +
+ + +
+ +
+ +
📎 点击选择文件上传(图片/文档/压缩包等,最大 20 MB)
+
+
+
+
+
+
+ +
+
+ + +
+
+ + +
+ {% if is_edit %} +
+ + +
+ {% endif %} +
+ +
+
+ + +
+ {% if not is_edit %} +
+ + +
+ {% endif %} +
+ + +
+
+ + +
+ +
+
+ 点击选择抄送人… +
+ +
+ {% for u in all_users() %} + {% if u.username != current_username %} +
+ {{ u.username }} + +
+ {% endif %} + {% endfor %} +
+
+
+ +
+ + 取消 +
+
+ + + + +{% endblock %} diff --git a/ticket-system/templates/ticket_list.html b/ticket-system/templates/ticket_list.html new file mode 100644 index 0000000..d67d58e --- /dev/null +++ b/ticket-system/templates/ticket_list.html @@ -0,0 +1,91 @@ +{% extends "base.html" %} +{% block title %}工单列表{% endblock %} +{% block content %} +
+

📋 工单列表

+
+ + +
+
+ + + + + + + + + + + + + + + + + 重置 +
+
+ + +
+ {% if tickets %} + + + + + + + + + + + + + + + {% for t in tickets %} + + + + + + + + + + + {% endfor %} + +
ID标题状态优先级分类处理人标签更新
#{{ t.id }}{{ t.title[:40] }}{% if t.title|length > 40 %}…{% endif %}{{ status_label(t.status) }}{{ priority_label(t.priority) }}{{ category_label(t.category) }}{{ t.assignee or '—' }} + {% for tag in parse_tags(t.tags) %} + {{ tag }} + {% endfor %} + {{ t.updated_at.strftime('%m-%d %H:%M') }}
+ {% else %} +

+ {% if q_status or q_priority or q_category or q_tag or q_keyword %} + 没有匹配的工单,查看全部 + {% else %} + 暂无工单,创建第一个 + {% endif %} +

+ {% endif %} +
+{% endblock %} diff --git a/ticket-system/wsgi.py b/ticket-system/wsgi.py new file mode 100644 index 0000000..90dde29 --- /dev/null +++ b/ticket-system/wsgi.py @@ -0,0 +1,14 @@ +""" +Gunicorn WSGI entry point. +DB initialization + upload folder setup. +""" + +import os + +os.makedirs(os.path.join(os.path.dirname(__file__), "uploads"), exist_ok=True) + +from app import app, db, seed_admin + +with app.app_context(): + db.create_all() + seed_admin()