From 8945073ec643c009059457e8c19e33909d487650 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 8 Jul 2026 11:25:50 +0800 Subject: [PATCH] del --- palladium-monitor/README.md => README.md | 0 palladium-monitor/app.py => app.py | 0 palladium-monitor/collect.py => collect.py | 0 ...install_z1_agent.sh => install_z1_agent.sh | 0 palladium-monitor/mailer.py => mailer.py | 0 .../requirements.txt => requirements.txt | 0 .../static => static}/style.css | 0 .../templates => templates}/base.html | 0 .../templates => templates}/dashboard.html | 0 .../templates => templates}/history.html | 0 .../templates => templates}/snapshot.html | 0 ticket-system/README.md | 236 ------ ticket-system/app.py | 775 ------------------ ticket-system/requirements.txt | 3 - ticket-system/static/style.css | 482 ----------- ticket-system/templates/base.html | 39 - ticket-system/templates/dashboard.html | 81 -- ticket-system/templates/error.html | 18 - ticket-system/templates/login.html | 31 - ticket-system/templates/notifications.html | 27 - ticket-system/templates/register.html | 35 - ticket-system/templates/ticket_detail.html | 298 ------- ticket-system/templates/ticket_form.html | 367 --------- ticket-system/templates/ticket_list.html | 91 -- ticket-system/wsgi.py | 14 - 25 files changed, 2497 deletions(-) rename palladium-monitor/README.md => README.md (100%) rename palladium-monitor/app.py => app.py (100%) rename palladium-monitor/collect.py => collect.py (100%) rename palladium-monitor/install_z1_agent.sh => install_z1_agent.sh (100%) rename palladium-monitor/mailer.py => mailer.py (100%) rename palladium-monitor/requirements.txt => requirements.txt (100%) rename {palladium-monitor/static => static}/style.css (100%) rename {palladium-monitor/templates => templates}/base.html (100%) rename {palladium-monitor/templates => templates}/dashboard.html (100%) rename {palladium-monitor/templates => templates}/history.html (100%) rename {palladium-monitor/templates => templates}/snapshot.html (100%) delete mode 100644 ticket-system/README.md delete mode 100644 ticket-system/app.py delete mode 100644 ticket-system/requirements.txt delete mode 100644 ticket-system/static/style.css delete mode 100644 ticket-system/templates/base.html delete mode 100644 ticket-system/templates/dashboard.html delete mode 100644 ticket-system/templates/error.html delete mode 100644 ticket-system/templates/login.html delete mode 100644 ticket-system/templates/notifications.html delete mode 100644 ticket-system/templates/register.html delete mode 100644 ticket-system/templates/ticket_detail.html delete mode 100644 ticket-system/templates/ticket_form.html delete mode 100644 ticket-system/templates/ticket_list.html delete mode 100644 ticket-system/wsgi.py diff --git a/palladium-monitor/README.md b/README.md similarity index 100% rename from palladium-monitor/README.md rename to README.md diff --git a/palladium-monitor/app.py b/app.py similarity index 100% rename from palladium-monitor/app.py rename to app.py diff --git a/palladium-monitor/collect.py b/collect.py similarity index 100% rename from palladium-monitor/collect.py rename to collect.py diff --git a/palladium-monitor/install_z1_agent.sh b/install_z1_agent.sh similarity index 100% rename from palladium-monitor/install_z1_agent.sh rename to install_z1_agent.sh diff --git a/palladium-monitor/mailer.py b/mailer.py similarity index 100% rename from palladium-monitor/mailer.py rename to mailer.py diff --git a/palladium-monitor/requirements.txt b/requirements.txt similarity index 100% rename from palladium-monitor/requirements.txt rename to requirements.txt diff --git a/palladium-monitor/static/style.css b/static/style.css similarity index 100% rename from palladium-monitor/static/style.css rename to static/style.css diff --git a/palladium-monitor/templates/base.html b/templates/base.html similarity index 100% rename from palladium-monitor/templates/base.html rename to templates/base.html diff --git a/palladium-monitor/templates/dashboard.html b/templates/dashboard.html similarity index 100% rename from palladium-monitor/templates/dashboard.html rename to templates/dashboard.html diff --git a/palladium-monitor/templates/history.html b/templates/history.html similarity index 100% rename from palladium-monitor/templates/history.html rename to templates/history.html diff --git a/palladium-monitor/templates/snapshot.html b/templates/snapshot.html similarity index 100% rename from palladium-monitor/templates/snapshot.html rename to templates/snapshot.html diff --git a/ticket-system/README.md b/ticket-system/README.md deleted file mode 100644 index cfe6022..0000000 --- a/ticket-system/README.md +++ /dev/null @@ -1,236 +0,0 @@ -# 工单系统 (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 deleted file mode 100644 index 13fda51..0000000 --- a/ticket-system/app.py +++ /dev/null @@ -1,775 +0,0 @@ -""" -轻量级工单系统 - 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 deleted file mode 100644 index 0a31475..0000000 --- a/ticket-system/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 70c44b4..0000000 --- a/ticket-system/static/style.css +++ /dev/null @@ -1,482 +0,0 @@ -/* ── 全局 ─────────────────────────────────────────── */ -: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 deleted file mode 100644 index b96a4f6..0000000 --- a/ticket-system/templates/base.html +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - {% block title %}工单系统{% endblock %} - - - - -
- {% block content %}{% endblock %} -
-
-

轻量级工单系统 · Flask + SQLite

-
- - diff --git a/ticket-system/templates/dashboard.html b/ticket-system/templates/dashboard.html deleted file mode 100644 index fd6bba1..0000000 --- a/ticket-system/templates/dashboard.html +++ /dev/null @@ -1,81 +0,0 @@ -{% 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 deleted file mode 100644 index 6fc6bb0..0000000 --- a/ticket-system/templates/error.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - {{ code }} - 工单系统 - - - -
-
-

{{ code }}

-

{{ message }}

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

📋 工单系统

- {% if error %} -
{{ error }}
- {% endif %} -
-
- - -
-
- - -
- -
- -
-
- - diff --git a/ticket-system/templates/notifications.html b/ticket-system/templates/notifications.html deleted file mode 100644 index 68c9f2f..0000000 --- a/ticket-system/templates/notifications.html +++ /dev/null @@ -1,27 +0,0 @@ -{% 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 deleted file mode 100644 index b447848..0000000 --- a/ticket-system/templates/register.html +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - 注册 - 工单系统 - - - -
-
-

📋 创建账号

- {% if error %} -
{{ error }}
- {% endif %} -
-
- - -
-
- - -
-
- - -
- -
- -
-
- - diff --git a/ticket-system/templates/ticket_detail.html b/ticket-system/templates/ticket_detail.html deleted file mode 100644 index 83b9321..0000000 --- a/ticket-system/templates/ticket_detail.html +++ /dev/null @@ -1,298 +0,0 @@ -{% 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 deleted file mode 100644 index 1f39a99..0000000 --- a/ticket-system/templates/ticket_form.html +++ /dev/null @@ -1,367 +0,0 @@ -{% 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 deleted file mode 100644 index d67d58e..0000000 --- a/ticket-system/templates/ticket_list.html +++ /dev/null @@ -1,91 +0,0 @@ -{% 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 deleted file mode 100644 index 90dde29..0000000 --- a/ticket-system/wsgi.py +++ /dev/null @@ -1,14 +0,0 @@ -""" -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()