This commit is contained in:
Your Name
2026-07-08 11:25:50 +08:00
parent 535e5832ab
commit 8945073ec6
25 changed files with 0 additions and 2497 deletions
View File
-236
View File
@@ -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/ # 上传文件目录
│ └── <ticket_id>/ # 每个工单独立的附件目录
├── 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
-775
View File
@@ -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/<int:aid>", methods=["POST"])
@login_required
def api_delete_attachment(aid):
"""删除附件"""
att = db.get_or_404(Attachment, aid)
os.remove(os.path.join(app.config["UPLOAD_FOLDER"], str(att.ticket_id), att.filename))
db.session.delete(att)
db.session.commit()
return jsonify({"ok": True})
@app.route("/uploads/<int:ticket_id>/<path:filename>")
@login_required
def serve_upload(ticket_id, filename):
"""提供上传文件下载"""
ticket = db.get_or_404(Ticket, ticket_id)
user = current_user()
if not can_access_ticket(user, ticket):
abort(403)
safe_dir = os.path.join(app.config["UPLOAD_FOLDER"], str(ticket_id))
return send_from_directory(safe_dir, filename)
@app.route("/login", methods=["GET", "POST"])
def login():
if "user_id" in session:
return redirect(url_for("dashboard"))
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
session["user_id"] = user.id
session["username"] = user.username
next_page = request.args.get("next") or url_for("dashboard")
return redirect(next_page)
return render_template(
"login.html", error="用户名或密码错误", username=username,
)
return render_template("login.html")
@app.route("/register", methods=["GET", "POST"])
def register():
if "user_id" in session:
return redirect(url_for("dashboard"))
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
password2 = request.form.get("password2", "")
if not username or not password:
return render_template(
"register.html", error="用户名和密码不能为空", username=username,
)
if password != password2:
return render_template(
"register.html", error="两次输入的密码不一致", username=username,
)
if User.query.filter_by(username=username).first():
return render_template(
"register.html", error="用户名已存在", username=username,
)
user = User(username=username, role="user")
user.set_password(password)
db.session.add(user)
db.session.commit()
session["user_id"] = user.id
session["username"] = user.username
return redirect(url_for("dashboard"))
return render_template("register.html")
@app.route("/logout")
def logout():
session.pop("user_id", None)
session.pop("username", None)
return redirect(url_for("login"))
# ── 通知路由 ──────────────────────────────────────────
@app.route("/notifications")
@login_required
def notifications():
user = current_user()
notifs = (
Notification.query.filter_by(user_id=user.id)
.order_by(Notification.created_at.desc())
.limit(50)
.all()
)
# 标记为已读
for n in notifs:
n.read = True
db.session.commit()
return render_template(
"notifications.html", notifications=notifs, datetime=datetime,
status_label=status_label,
)
@app.route("/notifications/count")
def notifications_count():
"""返回未读通知数(供前端角标使用)"""
count = 0
if "user_id" in session:
count = Notification.query.filter_by(
user_id=session["user_id"], read=False,
).count()
return str(count)
# ── 工单路由(全部需要登录) ──────────────────────────
@app.route("/")
@login_required
def dashboard():
"""统计看板"""
user = current_user()
me = user.username
base_query = accessible_tickets_query(user)
# 我的工单:提交人 + 处理人 + 抄送
my_tickets = base_query.count()
total = base_query.count()
by_status = {}
for key, label in STATUS.items():
by_status[key] = {"label": label, "count": base_query.filter_by(status=key).count()}
by_priority = {}
for key, label in PRIORITY.items():
by_priority[key] = {"label": label, "count": base_query.filter_by(priority=key).count()}
by_category = {}
for key, label in CATEGORY.items():
by_category[key] = {"label": label, "count": base_query.filter_by(category=key).count()}
recent = base_query.order_by(Ticket.updated_at.desc()).limit(10).all()
return render_template(
"dashboard.html",
total=total,
my_tickets=my_tickets,
by_status=by_status,
by_priority=by_priority,
by_category=by_category,
recent=recent,
STATUS=STATUS,
PRIORITY=PRIORITY,
CATEGORY=CATEGORY,
status_label=status_label,
priority_label=priority_label,
category_label=category_label,
)
@app.route("/tickets")
@login_required
def ticket_list():
"""工单列表(支持筛选)"""
user = current_user()
q_status = request.args.get("status", "")
q_priority = request.args.get("priority", "")
q_category = request.args.get("category", "")
q_tag = request.args.get("tag", "")
q_keyword = request.args.get("q", "")
query = accessible_tickets_query(user)
if q_status:
query = query.filter_by(status=q_status)
if q_priority:
query = query.filter_by(priority=q_priority)
if q_category:
query = query.filter_by(category=q_category)
if q_tag:
query = query.filter(Ticket.tags.like(f"%{q_tag}%"))
if q_keyword:
kw = f"%{q_keyword}%"
query = query.filter(
db.or_(Ticket.title.like(kw), Ticket.description.like(kw)),
)
tickets = query.order_by(Ticket.updated_at.desc()).all()
return render_template(
"ticket_list.html",
tickets=tickets,
STATUS=STATUS,
PRIORITY=PRIORITY,
CATEGORY=CATEGORY,
status_label=status_label,
priority_label=priority_label,
category_label=category_label,
parse_tags=parse_tags,
q_status=q_status,
q_priority=q_priority,
q_category=q_category,
q_tag=q_tag,
q_keyword=q_keyword,
)
@app.route("/ticket/<int:tid>")
@login_required
def ticket_detail(tid):
t = db.get_or_404(Ticket, tid)
user = current_user()
if not can_access_ticket(user, t):
abort(403)
atts = Attachment.query.filter_by(ticket_id=tid).order_by(
Attachment.uploaded_at.desc()
).all()
return render_template(
"ticket_detail.html",
ticket=t,
attachments=atts,
STATUS=STATUS,
PRIORITY=PRIORITY,
CATEGORY=CATEGORY,
status_label=status_label,
priority_label=priority_label,
category_label=category_label,
parse_tags=parse_tags,
parse_cc=parse_cc,
datetime=datetime,
all_users=all_users,
)
@app.route("/ticket/new", methods=["GET", "POST"])
@login_required
def ticket_new():
return _ticket_form(None)
@app.route("/ticket/<int:tid>/edit", methods=["GET", "POST"])
@login_required
def ticket_edit(tid):
t = db.get_or_404(Ticket, tid)
user = current_user()
if not can_access_ticket(user, t):
abort(403)
return _ticket_form(t)
def _ticket_form(ticket):
me = current_user()
if request.method == "POST":
title = request.form.get("title", "").strip()
desc = request.form.get("description", "").strip()
status = request.form.get("status", "open")
priority = request.form.get("priority", "medium")
category = request.form.get("category", "other")
assignee = request.form.get("assignee", "").strip()
requester = request.form.get("requester", "").strip()
cc_str = request.form.get("cc_users", "").strip()
tags = request.form.get("tags", "").strip()
if not title:
return "标题不能为空", 400
if ticket:
ticket.title = title
ticket.description = desc
ticket.status = status
ticket.priority = priority
ticket.category = category
ticket.assignee = assignee or None
ticket.requester = requester or None
ticket.cc_users = cc_str
ticket.tags = tags
# 分配人变更时通知新处理人
if assignee and assignee != me.username:
u = User.query.filter_by(username=assignee).first()
if u:
create_notification(
u.id,
f"工单 #{ticket.id} 已分配给你",
title,
ticket_id=ticket.id,
)
else:
ticket = Ticket(
title=title,
description=desc,
status=status,
priority=priority,
category=category,
assignee=assignee or None,
requester=me.username,
cc_users=cc_str,
tags=tags,
)
db.session.add(ticket)
db.session.commit()
# 通知新增的抄送人
cc_list = parse_cc(cc_str)
for uname in cc_list:
u = User.query.filter_by(username=uname).first()
if u and u.username != me.username:
create_notification(
u.id,
f"工单 #{ticket.id} 被抄送给你",
title,
ticket_id=ticket.id,
)
return redirect(f"/ticket/{ticket.id}")
return render_template(
"ticket_form.html",
ticket=ticket,
STATUS=STATUS,
PRIORITY=PRIORITY,
CATEGORY=CATEGORY,
is_edit=bool(ticket),
all_users=all_users,
current_username=me.username if me else "",
parse_cc=parse_cc,
)
@app.route("/ticket/<int:tid>/comment", methods=["POST"])
@login_required
def add_comment(tid):
t = db.get_or_404(Ticket, tid)
me = current_user()
content = request.form.get("content", "").strip()
if not content:
return "评论内容不能为空", 400
c = Comment(ticket_id=tid, author=me.username, content=content)
db.session.add(c)
# 通知:工单处理人 + 提交人 + 抄送人(避免通知自己)
notify_targets = set()
if t.assignee and t.assignee != me.username:
notify_targets.add(t.assignee)
if t.requester and t.requester != me.username:
notify_targets.add(t.requester)
for uname in parse_cc(t.cc_users):
if uname != me.username:
notify_targets.add(uname)
for uname in notify_targets:
u = User.query.filter_by(username=uname).first()
if u:
create_notification(
u.id,
f"工单 #{tid} 有新评论",
f"{me.username}: {content[:50]}{'...' if len(content) > 50 else ''}",
ticket_id=tid,
)
db.session.commit()
return redirect(f"/ticket/{tid}")
@app.route("/ticket/<int:tid>/close", methods=["POST"])
@login_required
def close_ticket(tid):
t = db.get_or_404(Ticket, tid)
t.status = "closed"
db.session.commit()
return redirect(f"/ticket/{tid}")
@app.route("/ticket/<int:tid>/delete", methods=["POST"])
@login_required
def delete_ticket(tid):
t = db.get_or_404(Ticket, tid)
db.session.delete(t)
db.session.commit()
return redirect("/tickets")
@app.template_filter("render_desc")
def render_desc_html(html):
"""
渲染描述中的内联图片,处理 contenteditable 的 div 包装。
只允许 <img> 和 <br> 标签,其他 HTML 转义。
contenteditable 默认会用 <div> 包裹段落,需要去掉。
"""
if not html:
return ""
from markupsafe import Markup, escape
import re
# Step 1: 将 <div>...</div> 替换为内容 + <br>,模拟内容editable的行为
def replace_div(m):
content = m.group(1)
return content + "\n"
html = re.sub(r'<div[^>]*>(.*?)</div>', replace_div, html, flags=re.DOTALL)
# Step 2: 将 <p>...</p> 替换为内容 + <br>
html = re.sub(r'<p[^>]*>(.*?)</p>', replace_div, html, flags=re.DOTALL)
# Step 3: 将 <br> 和 <br/> 替换为 \n
html = re.sub(r'<br\s*/?>', '\n', html)
# Step 4: 转义所有文本,但保留 <img> 标签
parts = []
last = 0
for m in re.finditer(r'<img[^>]*>', html):
if m.start() > last:
parts.append(escape(html[last:m.start()]))
parts.append(Markup(m.group(0)))
last = m.end()
if last < len(html):
parts.append(escape(html[last:]))
result = "".join(parts)
# 替换 \n 为 <br> 用于显示
return Markup(result).replace("\n", "<br>")
@app.errorhandler(403)
def forbidden(e):
return render_template("error.html", code=403, message="无权限访问此工单"), 403
@app.errorhandler(404)
def not_found(e):
return render_template("error.html", code=404, message="页面不存在"), 404
# ── 全局模板上下文 ────────────────────────────────────
@app.context_processor
def inject_globals():
"""每个模板都能访问的用户信息 + 未读通知数"""
unread = 0
if "user_id" in session:
unread = Notification.query.filter_by(
user_id=session["user_id"], read=False,
).count()
return dict(current_user=current_user, unread_count=unread)
# ── 启动 ──────────────────────────────────────────────
def seed_admin():
"""确保存在 admin 用户(密码 admin"""
if not User.query.filter_by(username="admin").first():
admin = User(username="admin", role="admin")
admin.set_password("admin")
db.session.add(admin)
db.session.commit()
print("✅ 已创建默认管理员: admin / admin")
if __name__ == "__main__":
os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
with app.app_context():
db.create_all()
seed_admin()
app.run(host="0.0.0.0", port=5000, debug=True)
-3
View File
@@ -1,3 +0,0 @@
Flask==3.0.0
Flask-SQLAlchemy==3.1.1
gunicorn==21.2.0
-482
View File
@@ -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; }
-39
View File
@@ -1,39 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}工单系统{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav class="navbar">
<div class="nav-brand"><a href="/">📋 工单系统</a></div>
<div class="nav-links">
<a href="/" class="nav-link{% if request.path == '/' %} active{% endif %}">看板</a>
<a href="/tickets" class="nav-link{% if '/tickets' in request.path and '/ticket/' not in request.path %} active{% endif %}">工单列表</a>
<a href="/ticket/new" class="nav-link btn-new"> 新建工单</a>
<!-- 通知 -->
<a href="/notifications" class="nav-link notif-link">
🔔
{% if unread_count > 0 %}
<span class="notif-badge">{{ unread_count }}</span>
{% endif %}
</a>
<!-- 用户菜单 -->
{% if current_user %}
<span class="user-info">👤 {{ current_user.username }}</span>
<a href="/logout" class="nav-link">退出</a>
{% endif %}
</div>
</nav>
<main class="container">
{% block content %}{% endblock %}
</main>
<footer class="footer">
<p>轻量级工单系统 &middot; Flask + SQLite</p>
</footer>
</body>
</html>
-81
View File
@@ -1,81 +0,0 @@
{% extends "base.html" %}
{% block title %}工单看板{% endblock %}
{% block content %}
<h1 style="margin-bottom:20px;font-size:24px;">📊 工单看板</h1>
<!-- 工单总数 + 我的工单 -->
<div style="display:flex;gap:12px;margin-bottom:20px;flex-wrap:wrap;">
<div class="stat-card total" style="max-width:200px;flex:1;">
<div class="num">{{ total }}</div>
<div class="label">总工单数</div>
</div>
<div class="stat-card" style="max-width:200px;flex:1;">
<div class="num" style="color:var(--primary)">{{ my_tickets }}</div>
<div class="label">我的工单</div>
</div>
</div>
<!-- 状态分布 -->
<h2 class="section-title">按状态</h2>
<div class="stats-grid">
{% for k, v in by_status.items() %}
<div class="stat-card {{ k }}">
<div class="num">{{ v.count }}</div>
<div class="label">{{ v.label }}</div>
</div>
{% endfor %}
</div>
<!-- 优先级分布 -->
<h2 class="section-title">按优先级</h2>
<div class="stats-grid">
{% for k, v in by_priority.items() %}
<div class="stat-card">
<div class="num" style="color:var(--primary)">{{ v.count }}</div>
<div class="label">{{ v.label }}</div>
</div>
{% endfor %}
</div>
<!-- 分类分布 -->
<h2 class="section-title">按分类</h2>
<div class="stats-grid">
{% for k, v in by_category.items() %}
<div class="stat-card">
<div class="num" style="color:var(--primary)">{{ v.count }}</div>
<div class="label">{{ v.label }}</div>
</div>
{% endfor %}
</div>
<!-- 最近工单 -->
<h2 class="section-title">最近工单</h2>
<div class="card" style="padding:0;overflow:hidden;">
{% if recent %}
<table class="ticket-table">
<thead>
<tr>
<th>ID</th>
<th>标题</th>
<th>状态</th>
<th>优先级</th>
<th>更新</th>
</tr>
</thead>
<tbody>
{% for t in recent %}
<tr>
<td><a href="/ticket/{{ t.id }}">#{{ t.id }}</a></td>
<td><a href="/ticket/{{ t.id }}">{{ t.title }}</a></td>
<td><span class="badge badge-{{ t.status }}">{{ status_label(t.status) }}</span></td>
<td><span class="badge badge-{{ t.priority }}">{{ priority_label(t.priority) }}</span></td>
<td style="color:var(--text-muted);font-size:13px;">{{ t.updated_at.strftime('%m-%d %H:%M') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="padding:20px;color:var(--text-muted);text-align:center;">暂无工单</p>
{% endif %}
</div>
{% endblock %}
-18
View File
@@ -1,18 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ code }} - 工单系统</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="auth-page">
<div class="auth-card" style="text-align:center;">
<h1 style="font-size:64px;color:var(--danger);">{{ code }}</h1>
<p style="font-size:16px;margin-top:16px;color:var(--text-muted);">{{ message }}</p>
<a href="/" class="btn btn-primary" style="margin-top:24px;">返回首页</a>
</div>
</div>
</body>
</html>
-31
View File
@@ -1,31 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - 工单系统</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="auth-page">
<div class="auth-card">
<h1>📋 工单系统</h1>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<form method="post">
<div class="form-group">
<label>用户名</label>
<input name="username" type="text" required value="{{ username or '' }}" autofocus>
</div>
<div class="form-group">
<label>密码</label>
<input name="password" type="password" required>
</div>
<button type="submit" class="btn btn-primary" style="width:100%;">登录</button>
</form>
<p class="auth-link">没有账号?<a href="/register">注册</a></p>
</div>
</div>
</body>
</html>
@@ -1,27 +0,0 @@
{% extends "base.html" %}
{% block title %}通知{% endblock %}
{% block content %}
<h1 style="margin-bottom:20px;font-size:24px;">🔔 通知</h1>
{% if notifications %}
{% for n in notifications %}
<div class="notif-item{% if not n.read %} unread{% endif %}">
<div class="notif-title">
{% if n.ticket_id %}
<a href="/ticket/{{ n.ticket_id }}">{{ n.title }}</a>
{% else %}
{{ n.title }}
{% endif %}
</div>
{% if n.content %}
<div class="notif-content">{{ n.content }}</div>
{% endif %}
<div class="notif-time">{{ n.created_at.strftime('%Y-%m-%d %H:%M') }}</div>
</div>
{% endfor %}
{% else %}
<div class="card" style="text-align:center;padding:40px;color:var(--text-muted);">
暂无通知
</div>
{% endif %}
{% endblock %}
-35
View File
@@ -1,35 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>注册 - 工单系统</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="auth-page">
<div class="auth-card">
<h1>📋 创建账号</h1>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<form method="post">
<div class="form-group">
<label>用户名</label>
<input name="username" type="text" required value="{{ username or '' }}" autofocus>
</div>
<div class="form-group">
<label>密码</label>
<input name="password" type="password" required>
</div>
<div class="form-group">
<label>确认密码</label>
<input name="password2" type="password" required>
</div>
<button type="submit" class="btn btn-primary" style="width:100%;">注册</button>
</form>
<p class="auth-link">已有账号?<a href="/login">登录</a></p>
</div>
</div>
</body>
</html>
-298
View File
@@ -1,298 +0,0 @@
{% extends "base.html" %}
{% block title %}工单 #{{ ticket.id }}{% endblock %}
{% block content %}
<div class="detail-header">
<div>
<h1>#{{ ticket.id }} {{ ticket.title }}</h1>
<div class="detail-meta">
创建 {{ ticket.created_at.strftime('%Y-%m-%d %H:%M') }} &middot;
更新 {{ ticket.updated_at.strftime('%Y-%m-%d %H:%M') }}
</div>
</div>
<div style="display:flex;gap:8px;">
<a href="/ticket/{{ ticket.id }}/edit" class="btn btn-secondary">编辑</a>
{% if ticket.status != 'closed' and ticket.status != 'resolved' %}
<form method="post" action="/ticket/{{ ticket.id }}/close" style="display:inline;">
<button type="submit" class="btn btn-primary" onclick="return confirm('确认关闭?')">关闭</button>
</form>
{% endif %}
<form method="post" action="/ticket/{{ ticket.id }}/delete" style="display:inline;">
<button type="submit" class="btn btn-danger" onclick="return confirm('确认删除?')">删除</button>
</form>
</div>
</div>
<div class="detail-grid">
<!-- 左侧:内容 + 评论 + 附件 -->
<div>
<!-- 描述 -->
<div class="card" style="margin-bottom:16px;">
<h2 style="font-size:15px;margin-bottom:8px;color:var(--text-muted);">描述</h2>
<div style="white-space:pre-wrap;line-height:1.7;">{{ ticket.description | render_desc }}</div>
</div>
<!-- 附件 -->
<div>
<h2 style="font-size:15px;margin-bottom:12px;color:var(--text-muted);">
附件({{ attachments|length }}
</h2>
<!-- 上传区域 -->
<div class="attachment-zone" id="uploadZone"
ondrop="handleDrop(event)"
ondragover="handleDragOver(event)"
ondragleave="handleDragLeave(event)"
onclick="document.getElementById('fileInput').click()">
<input type="file" id="fileInput" multiple accept="*/*"
onchange="handleFileSelect(this.files)">
<div class="az-label">📎 点击选择、拖拽或粘贴(Ctrl+V)上传图片/附件</div>
<div class="az-hint">支持图片、文档、压缩包等,最大 20 MB</div>
</div>
<!-- 附件列表 -->
<div class="attachment-list" id="attachmentList">
{% for att in attachments %}
<div class="attachment-item" data-aid="{{ att.id }}">
{% if att.is_image %}
<img class="att-thumb"
src="/uploads/{{ ticket.id }}/{{ att.filename }}"
alt="{{ att.original_name }}"
onclick="showImage(this.src)" loading="lazy">
{% else %}
<div class="att-icon">
{% 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 %}
</div>
<a class="att-link" href="/uploads/{{ ticket.id }}/{{ att.filename }}" download>
{{ att.original_name }}
</a>
{% endif %}
<div class="att-info">
{{ att.uploaded_by }} · {{ att.uploaded_at.strftime('%m-%d %H:%M') }}
· {{ att.size / 1024 | round(1) }} KB
</div>
<button class="att-delete" title="删除"
onclick="deleteAttachment({{ att.id }}, this)">×</button>
</div>
{% endfor %}
</div>
<div class="upload-progress" id="uploadProgress">
<div class="upload-progress-bar" id="uploadProgressBar"></div>
</div>
</div>
<!-- 评论 -->
<div style="margin-top:20px;">
<h2 style="font-size:15px;margin-bottom:12px;color:var(--text-muted);">
评论({{ ticket.comments|length }}
</h2>
<div class="comment-list">
{% for c in ticket.comments %}
<div class="comment">
<div>
<span class="author">{{ c.author }}</span>
<span class="time">{{ c.created_at.strftime('%m-%d %H:%M') }}</span>
</div>
<div class="content">{{ c.content }}</div>
</div>
{% endfor %}
</div>
<!-- 添加评论 -->
<form method="post" action="/ticket/{{ ticket.id }}/comment" class="card" style="margin-top:12px;">
<div class="form-group" style="margin-bottom:10px;">
<label>以 {{ current_user.username }} 身份评论</label>
<textarea name="content" placeholder="输入评论…(提示:可在评论框内粘贴截图)" rows="3"></textarea>
</div>
<button type="submit" class="btn btn-primary">发表评论</button>
</form>
</div>
</div>
<!-- 右侧:侧边栏 -->
<div class="sidebar">
<div class="card">
<div class="form-group" style="margin-bottom:10px;">
<label>状态</label>
<span class="badge badge-{{ ticket.status }}">{{ status_label(ticket.status) }}</span>
</div>
<div class="form-group" style="margin-bottom:10px;">
<label>优先级</label>
<span class="badge badge-{{ ticket.priority }}">{{ priority_label(ticket.priority) }}</span>
</div>
<div class="form-group" style="margin-bottom:10px;">
<label>分类</label>
<span class="badge badge-{{ ticket.category }}">{{ category_label(ticket.category) }}</span>
</div>
<div class="form-group" style="margin-bottom:10px;">
<label>处理人</label>
<p>{{ ticket.assignee or '—' }}</p>
</div>
<div class="form-group">
<label>提交人</label>
<p>{{ ticket.requester or '—' }}</p>
</div>
{% if ticket.cc_users %}
<div class="form-group">
<label>抄送人</label>
<p>
{% for uname in parse_cc(ticket.cc_users) %}
<span class="tag" style="margin:1px;">{{ uname }}</span>
{% endfor %}
</p>
</div>
{% endif %}
</div>
{% if ticket.tags %}
<div class="card">
<label style="font-size:13px;font-weight:600;color:var(--text-muted);">标签</label>
<div style="margin-top:6px;">
{% for tag in parse_tags(ticket.tags) %}
<span class="tag">{{ tag }}</span>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
<!-- 图片预览模态框 -->
<div class="image-modal" id="imageModal" onclick="closeImage()">
<img id="modalImage" src="">
</div>
<script>
const TICKET_ID = {{ ticket.id }};
const uploadList = document.getElementById('attachmentList');
const uploadZone = document.getElementById('uploadZone');
const progressBar = document.getElementById('uploadProgressBar');
const progressEl = document.getElementById('uploadProgress');
function showImage(src) {
document.getElementById('modalImage').src = src;
document.getElementById('imageModal').classList.add('show');
}
function closeImage() {
document.getElementById('imageModal').classList.remove('show');
}
// 显示上传进度
function showProgress(pct) {
progressEl.classList.add('active');
progressBar.style.width = pct + '%';
}
function hideProgress() {
progressEl.classList.remove('active');
progressBar.style.width = '0%';
}
// 上传文件到服务器
async function uploadFiles(files) {
for (const file of files) {
showProgress(0);
const formData = new FormData();
formData.append('file', file);
try {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', e => {
if (e.lengthComputable) {
showProgress(Math.round(e.loaded / e.total * 100));
}
});
const resp = await fetch(`/api/upload?t=${TICKET_ID}`, {
method: 'POST',
body: formData,
});
if (resp.ok) {
const data = await resp.json();
addAttachment(data);
} else {
const data = await resp.json();
alert('上传失败:' + (data.error || '未知错误'));
}
} catch (e) {
alert('上传失败:' + e.message);
}
hideProgress();
}
}
// 处理粘贴图片
document.addEventListener('paste', function (e) {
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
const files = [];
for (const item of items) {
if (item.type.indexOf('image') !== -1) {
const blob = item.getAsFile();
if (blob) files.push(blob);
}
}
if (files.length > 0) {
e.preventDefault();
uploadFiles(files);
}
});
// 拖拽上传
function handleDragOver(e) {
e.preventDefault();
uploadZone.classList.add('drag-over');
}
function handleDragLeave(e) {
uploadZone.classList.remove('drag-over');
}
function handleDrop(e) {
e.preventDefault();
uploadZone.classList.remove('drag-over');
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) uploadFiles(files);
}
// 文件选择
function handleFileSelect(files) {
if (files && files.length > 0) {
uploadFiles(files);
}
document.getElementById('fileInput').value = '';
}
// 新增附件到列表(DOM
function addAttachment(data) {
const item = document.createElement('div');
item.className = 'attachment-item';
item.dataset.aid = '';
if (data.is_image) {
item.innerHTML = `
<img class="att-thumb" src="${data.url}" alt="${data.filename}" onclick="showImage(this.src)">
<div class="att-info">${data.filename} · ${formatSize(data.size)}</div>`;
} else {
item.innerHTML = `
<div class="att-icon">📎</div>
<a class="att-link" href="${data.url}" download>${data.filename}</a>
<div class="att-info">${formatSize(data.size)}</div>`;
}
uploadList.appendChild(item);
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
// 删除附件
async function deleteAttachment(aid, btn) {
if (!confirm('确认删除此附件?')) return;
try {
const resp = await fetch(`/api/upload/delete/${aid}`, { method: 'POST' });
if (resp.ok) {
btn.closest('.attachment-item').remove();
}
} catch (e) {
alert('删除失败:' + e.message);
}
}
</script>
{% endblock %}
-367
View File
@@ -1,367 +0,0 @@
{% extends "base.html" %}
{% block title %}{% if is_edit %}编辑工单{% else %}新建工单{% endif %}{% endblock %}
{% block content %}
<h1 style="margin-bottom:20px;font-size:24px;">{% if is_edit %}✏️ 编辑工单{% else %} 新建工单{% endif %}</h1>
<form id="ticketForm" method="post" class="card">
<div class="form-group">
<label>标题 *</label>
<input name="title" required value="{{ ticket.title if is_edit else '' }}" placeholder="简洁描述工单问题">
</div>
<!-- 富文本描述 -->
<div class="form-group">
<label>描述</label>
<div class="rich-toolbar">
<button type="button" onclick="descUploadClick()">📎 插入图片</button>
<button type="button" onclick="document.execCommand('bold',false,'')">𝐁 加粗</button>
<button type="button" onclick="document.execCommand('italic',false,'')">𝐼 斜体</button>
<input type="file" id="descFileInput" accept="image/*" style="display:none" onchange="descInsertUploadedImage(this.files)">
<span style="margin-left:auto;font-size:12px;color:var(--text-muted);">可直接 Ctrl+V 粘贴截图,图片会内联显示</span>
</div>
<div contenteditable="true"
class="rich-editor"
id="descEditor"
data-placeholder="详细描述问题…(支持粘贴截图、插入图片,图片将内联显示)"
onpaste="handleDescPaste(event)">
{{ (ticket.description | safe) if is_edit else '' }}
</div>
<input type="hidden" name="description" id="descHidden">
</div>
<!-- 附件上传 -->
<div class="form-group">
<label>附件</label>
<div class="attachment-zone" onclick="document.getElementById('formFileInput').click()">
<input type="file" id="formFileInput" multiple accept="*/*" onchange="handleFormFileSelect(this.files)">
<div class="az-label">📎 点击选择文件上传(图片/文档/压缩包等,最大 20 MB)</div>
</div>
<div class="attachment-list" id="formAttachmentList"></div>
<div class="upload-progress" id="formUploadProgress">
<div class="upload-progress-bar" id="formUploadProgressBar"></div>
</div>
</div>
<div class="form-row-select">
<div class="form-group">
<label>分类</label>
<select name="category">
{% for k, v in CATEGORY.items() %}
<option value="{{ k }}" {% if is_edit and ticket.category == k %}selected{% endif %}>{{ v }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>优先级</label>
<select name="priority">
{% for k, v in PRIORITY.items() %}
<option value="{{ k }}" {% if is_edit and ticket.priority == k %}selected{% endif %}>{{ v }}</option>
{% endfor %}
</select>
</div>
{% if is_edit %}
<div class="form-group">
<label>状态</label>
<select name="status">
{% for k, v in STATUS.items() %}
<option value="{{ k }}" {% if ticket.status == k %}selected{% endif %}>{{ v }}</option>
{% endfor %}
</select>
</div>
{% endif %}
</div>
<div class="form-row-select">
<div class="form-group">
<label>处理人</label>
<select name="assignee">
<option value="">— 未分配 —</option>
{% for u in all_users() %}
<option value="{{ u.username }}" {% if is_edit and ticket.assignee == u.username %}selected{% elif not is_edit and current_username == u.username %}selected{% endif %}>{{ u.username }}</option>
{% endfor %}
</select>
</div>
{% if not is_edit %}
<div class="form-group">
<label>提交人</label>
<input name="requester" value="{{ current_username }}" readonly style="background:#f5f5f5;">
</div>
{% endif %}
<div class="form-group">
<label>标签</label>
<input name="tags" value="{{ ticket.tags if is_edit else '' }}" placeholder="用逗号分隔,如:网络,紧急">
</div>
</div>
<!-- 抄送人(多选) -->
<div class="form-group">
<label>抄送人(可选,可多选)</label>
<div class="cc-select-wrap" id="ccSelectWrap">
<div class="cc-input" id="ccInput" onclick="toggleCcDropdown()">
<span class="cc-placeholder">点击选择抄送人…</span>
</div>
<input type="hidden" name="cc_users" id="ccHidden">
<div class="cc-dropdown" id="ccDropdown">
{% for u in all_users() %}
{% if u.username != current_username %}
<div class="cc-option" data-username="{{ u.username }}" onclick="toggleCcUser('{{ u.username }}', event)">
<span>{{ u.username }}</span>
<span class="check"></span>
</div>
{% endif %}
{% endfor %}
</div>
</div>
</div>
<div style="display:flex;gap:10px;margin-top:8px;">
<button type="submit" class="btn btn-primary" id="submitBtn">{% if is_edit %}保存修改{% else %}创建工单{% endif %}</button>
<a href="{% if is_edit %}/ticket/{{ ticket.id }}{% else %}/tickets{% endif %}" class="btn btn-secondary">取消</a>
</div>
</form>
<!-- 附件上传已移到描述下方 -->
<script>
const FORM_TICKET_ID = {{ ticket.id if is_edit else 'null' }};
const isEdit = {{ 'true' if is_edit else 'false' }};
// Pending blobs to upload after ticket creation (new ticket only)
const pendingBlobs = [];
const pendingAttachments = [];
let uploadedDescImages = [];
// ── 表单提交:上传描述图片 + 附件 ──────────────────────
document.getElementById('submitBtn').addEventListener('click', async function(e) {
if (!isEdit && (pendingBlobs.length > 0 || pendingAttachments.length > 0)) {
e.preventDefault();
this.disabled = true;
this.textContent = '上传中…';
// Create ticket first
const formData = new FormData(document.getElementById('ticketForm'));
formData.set('description', document.getElementById('descEditor').innerHTML);
let ticketId;
try {
const createResp = await fetch('/ticket/new', {
method: 'POST',
body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
if (!createResp.ok) throw new Error('创建工单失败');
const url = new URL(createResp.url, window.location.origin);
const match = url.pathname.match(/\/ticket\/(\d+)/);
ticketId = match ? match[1] : null;
} catch (err) {
alert('创建工单失败:' + err.message);
this.disabled = false;
this.textContent = '创建工单';
return;
}
if (!ticketId) {
alert('获取工单 ID 失败');
this.disabled = false;
this.textContent = '创建工单';
return;
}
// Upload all pending blobs (description images)
for (const blob of pendingBlobs) {
const f = new File([blob], 'img.png', { type: blob.type || 'image/png' });
const uf = new FormData();
uf.append('file', f);
try { await fetch(`/api/upload?t=${ticketId}`, { method: 'POST', body: uf }); } catch (err) { console.error(err); }
}
// Upload all pending attachments
for (const f of pendingAttachments) {
const uf = new FormData();
uf.append('file', f);
try { await fetch(`/api/upload?t=${ticketId}`, { method: 'POST', body: uf }); } catch (err) { console.error(err); }
}
pendingBlobs.length = 0;
pendingAttachments.length = 0;
window.location.href = `/ticket/${ticketId}`;
return;
}
// Normal submit (edit mode or no images/attachments)
document.getElementById('descHidden').value = document.getElementById('descEditor').innerHTML;
});
// ── 富文本编辑器:粘贴图片 ───────────────────────────
function handleDescPaste(e) {
const clipboardData = e.clipboardData || (e.originalEvent && e.originalEvent.clipboardData);
if (!clipboardData) return;
const items = clipboardData.items;
let hasImage = false;
for (const item of items) {
if (item.type.indexOf('image') !== -1) {
hasImage = true;
const file = item.getAsFile();
if (file) insertDescImage(file);
}
}
if (hasImage) {
e.preventDefault();
e.stopPropagation();
}
}
function insertDescImage(file) {
const editor = document.getElementById('descEditor');
const reader = new FileReader();
reader.onload = function(e) {
const img = document.createElement('img');
img.src = e.target.result;
img.className = 'inline-image';
img.alt = file.name || 'pasted-image';
img.dataset.pending = 'true';
insertNodeAtCursor(img);
if (!isEdit) {
pendingBlobs.push(file);
}
};
reader.readAsDataURL(file);
}
function insertNodeAtCursor(node) {
const sel = window.getSelection();
const editor = document.getElementById('descEditor');
if (sel.rangeCount) {
const range = sel.getRangeAt(0);
range.collapse(false);
range.insertNode(node);
range.setStartAfter(node);
range.setEndAfter(node);
sel.removeAllRanges();
sel.addRange(range);
} else {
editor.appendChild(node);
}
}
// ── 工具栏:插入图片 ─────────────────────────────────
function descUploadClick() {
document.getElementById('descFileInput').click();
}
function descInsertUploadedImage(files) {
if (!files || !files.length) return;
for (const file of files) {
if (isEdit) {
uploadAndInsertDescImage(file, FORM_TICKET_ID);
} else {
insertDescImage(file);
}
}
document.getElementById('descFileInput').value = '';
}
async function uploadAndInsertDescImage(file, ticketId) {
const formData = new FormData();
formData.append('file', file);
try {
const resp = await fetch(`/api/upload?t=${ticketId}`, { method: 'POST', body: formData });
if (resp.ok) {
const data = await resp.json();
const editor = document.getElementById('descEditor');
const img = document.createElement('img');
img.src = data.url;
img.className = 'inline-image';
img.alt = data.filename;
insertNodeAtCursor(img);
}
} catch (e) {
alert('插入图片失败:' + e.message);
}
}
// ── 抄送人多选 ───────────────────────────────────────
let ccSelected = [];
function toggleCcDropdown() {
document.getElementById('ccDropdown').classList.toggle('show');
}
function toggleCcUser(username, event) {
event.stopPropagation();
if (ccSelected.includes(username)) {
ccSelected = ccSelected.filter(u => u !== username);
} else {
ccSelected.push(username);
}
renderCcInput();
updateCcOptions();
document.getElementById('ccHidden').value = ccSelected.join(',');
}
function removeCcUser(username) {
ccSelected = ccSelected.filter(u => u !== username);
renderCcInput();
updateCcOptions();
document.getElementById('ccHidden').value = ccSelected.join(',');
}
function renderCcInput() {
const input = document.getElementById('ccInput');
if (ccSelected.length === 0) {
input.innerHTML = '<span class="cc-placeholder">点击选择抄送人…</span>';
} else {
input.innerHTML = ccSelected.map(u =>
`<span class="cc-chip">${u}<span class="chip-x" onclick="event.stopPropagation(); removeCcUser('${u}')">×</span></span>`
).join('');
}
}
function updateCcOptions() {
document.querySelectorAll('#ccDropdown .cc-option').forEach(el => {
const uname = el.dataset.username;
if (ccSelected.includes(uname)) {
el.classList.add('selected');
el.querySelector('.check').textContent = '✓';
} else {
el.classList.remove('selected');
el.querySelector('.check').textContent = '';
}
});
}
document.addEventListener('click', function (e) {
if (!e.target.closest('#ccSelectWrap')) {
document.getElementById('ccDropdown').classList.remove('show');
}
});
{% if is_edit and ticket.cc_users %}
{% for uname in parse_cc(ticket.cc_users) %}
ccSelected.push('{{ uname }}');
{% endfor %}
renderCcInput();
updateCcOptions();
document.getElementById('ccHidden').value = ccSelected.join(',');
{% endif %}
// ── 附件上传 ────────────────────────────────────────
async function handleFormFileSelect(files) {
if (!files || !files.length) return;
for (const file of files) {
if (isEdit) {
// Edit mode: upload directly
const formData = new FormData();
formData.append('file', file);
try {
const resp = await fetch(`/api/upload?t=${FORM_TICKET_ID}`, { method: 'POST', body: formData });
if (resp.ok) { location.reload(); }
} catch (e) { alert('上传失败:' + e.message); }
} else {
// New ticket: store pending, will upload after creation
pendingAttachments.push(file);
}
}
document.getElementById('formFileInput').value = '';
}
</script>
{% endblock %}
-91
View File
@@ -1,91 +0,0 @@
{% extends "base.html" %}
{% block title %}工单列表{% endblock %}
{% block content %}
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;flex-wrap:wrap;gap:10px;">
<h1 style="font-size:24px;">📋 工单列表</h1>
</div>
<!-- 筛选器 -->
<div class="card filters">
<form method="get" style="display:flex;flex-wrap:wrap;gap:10px;align-items:center;">
<label>关键字</label>
<input name="q" value="{{ q_keyword }}" placeholder="标题 / 描述">
<label>状态</label>
<select name="status">
<option value="">全部</option>
{% for k, v in STATUS.items() %}
<option value="{{ k }}" {% if q_status == k %}selected{% endif %}>{{ v }}</option>
{% endfor %}
</select>
<label>优先级</label>
<select name="priority">
<option value="">全部</option>
{% for k, v in PRIORITY.items() %}
<option value="{{ k }}" {% if q_priority == k %}selected{% endif %}>{{ v }}</option>
{% endfor %}
</select>
<label>分类</label>
<select name="category">
<option value="">全部</option>
{% for k, v in CATEGORY.items() %}
<option value="{{ k }}" {% if q_category == k %}selected{% endif %}>{{ v }}</option>
{% endfor %}
</select>
<label>标签</label>
<input name="tag" value="{{ q_tag }}" placeholder="标签">
<button type="submit" class="btn btn-primary">筛选</button>
<a href="/tickets" class="btn btn-secondary">重置</a>
</form>
</div>
<!-- 列表 -->
<div class="card" style="padding:0;overflow-x:auto;margin-top:16px;">
{% if tickets %}
<table class="ticket-table">
<thead>
<tr>
<th>ID</th>
<th>标题</th>
<th>状态</th>
<th>优先级</th>
<th>分类</th>
<th>处理人</th>
<th>标签</th>
<th>更新</th>
</tr>
</thead>
<tbody>
{% for t in tickets %}
<tr>
<td><a href="/ticket/{{ t.id }}">#{{ t.id }}</a></td>
<td><a href="/ticket/{{ t.id }}">{{ t.title[:40] }}{% if t.title|length > 40 %}…{% endif %}</a></td>
<td><span class="badge badge-{{ t.status }}">{{ status_label(t.status) }}</span></td>
<td><span class="badge badge-{{ t.priority }}">{{ priority_label(t.priority) }}</span></td>
<td><span class="badge badge-{{ t.category }}">{{ category_label(t.category) }}</span></td>
<td style="color:var(--text-muted)">{{ t.assignee or '—' }}</td>
<td>
{% for tag in parse_tags(t.tags) %}
<span class="tag">{{ tag }}</span>
{% endfor %}
</td>
<td style="color:var(--text-muted);font-size:13px;">{{ t.updated_at.strftime('%m-%d %H:%M') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="padding:40px;text-align:center;color:var(--text-muted);">
{% if q_status or q_priority or q_category or q_tag or q_keyword %}
没有匹配的工单,<a href="/tickets">查看全部</a>
{% else %}
暂无工单,<a href="/ticket/new">创建第一个</a>
{% endif %}
</p>
{% endif %}
</div>
{% endblock %}
-14
View File
@@ -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()