feat: initial commit - lightweight ticket system with Flask+SQLite
Features: - User authentication (login/register) - Ticket CRUD with status/priority/category - Rich text description with paste-to-image - File attachment upload (images/documents) - Multi-select CC recipients - Role-based permission control (admin/user) - In-app notifications - Statistics dashboard - Gunicorn production deployment
This commit is contained in:
@@ -0,0 +1,236 @@
|
|||||||
|
# 工单系统 (Ticket System)
|
||||||
|
|
||||||
|
轻量级工单系统,基于 Flask + SQLite,适合个人或小团队使用。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### 工单管理
|
||||||
|
- 创建、编辑、删除、关闭工单
|
||||||
|
- 状态流转:新建 → 进行中 → 待处理 → 已完成 → 已关闭
|
||||||
|
- 分类管理:技术故障、功能需求、咨询、事件处理、其他
|
||||||
|
- 优先级设置:低、中、高、紧急
|
||||||
|
- 自由标签支持
|
||||||
|
|
||||||
|
### 富文本描述
|
||||||
|
- **内容可编辑**:contenteditable 富文本编辑器
|
||||||
|
- **图片内联显示**:支持 Ctrl+V 粘贴截图,图片直接嵌入描述中
|
||||||
|
- **插入图片按钮**:📎 点击选择图片上传
|
||||||
|
- **基础格式**:支持加粗、斜体
|
||||||
|
- **粘贴上传**:粘贴的图片会自动上传到服务器
|
||||||
|
|
||||||
|
### 附件管理
|
||||||
|
- 支持多种文件类型:图片、文档、压缩包等
|
||||||
|
- 最大文件大小:20 MB
|
||||||
|
- 图片预览:支持点击放大查看
|
||||||
|
- 附件下载:点击即可下载
|
||||||
|
|
||||||
|
### 用户系统
|
||||||
|
- 用户注册、登录、登出
|
||||||
|
- 默认管理员账号:`admin / admin`
|
||||||
|
- 密码安全存储(哈希)
|
||||||
|
|
||||||
|
### 权限控制
|
||||||
|
- **管理员**:可查看所有工单
|
||||||
|
- **普通用户**:只能查看自己参与(提交人/处理人/抄送人)的工单
|
||||||
|
- 无权限访问时返回 403 错误
|
||||||
|
|
||||||
|
### 抄送功能
|
||||||
|
- 支持多选抄送人
|
||||||
|
- 自定义下拉选择器
|
||||||
|
- 抄送人自动收到通知
|
||||||
|
- 抄送人可查看工单和附件
|
||||||
|
|
||||||
|
### 通知系统
|
||||||
|
- 站内通知:工单分配、评论、关闭等操作自动通知相关人员
|
||||||
|
- 未读角标:导航栏显示未读通知数量
|
||||||
|
- 通知列表:可查看通知详情
|
||||||
|
|
||||||
|
### 统计看板
|
||||||
|
- 工单总数统计
|
||||||
|
- 按状态、优先级、分类聚合
|
||||||
|
- 最近工单列表
|
||||||
|
- 我的工单计数
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **后端**:Flask + SQLAlchemy
|
||||||
|
- **数据库**:SQLite
|
||||||
|
- **前端**:Jinja2 模板 + 原生 JavaScript
|
||||||
|
- **部署**:Gunicorn + systemd
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt --break-system-packages
|
||||||
|
```
|
||||||
|
|
||||||
|
### 启动开发服务器
|
||||||
|
```bash
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 启动生产服务器
|
||||||
|
```bash
|
||||||
|
gunicorn --workers 2 --bind 0.0.0.0:5000 wsgi:app
|
||||||
|
```
|
||||||
|
|
||||||
|
### 访问系统
|
||||||
|
打开浏览器访问:`http://localhost:5000`
|
||||||
|
|
||||||
|
默认管理员账号:
|
||||||
|
- 用户名:`admin`
|
||||||
|
- 密码:`admin`
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
ticket-system/
|
||||||
|
├── app.py # Flask 主程序(模型 + 路由)
|
||||||
|
├── wsgi.py # Gunicorn WSGI 入口
|
||||||
|
├── requirements.txt # 依赖列表
|
||||||
|
├── README.md # 项目说明
|
||||||
|
├── instance/
|
||||||
|
│ └── tickets.db # SQLite 数据库(自动创建)
|
||||||
|
├── uploads/ # 上传文件目录
|
||||||
|
│ └── <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
|
||||||
@@ -0,0 +1,775 @@
|
|||||||
|
"""
|
||||||
|
轻量级工单系统 - Flask + SQLite
|
||||||
|
增强版:分类 / 优先级 / 标签 / 统计看板 / 登录 / 站内通知
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
from flask import (
|
||||||
|
Flask, request, redirect, render_template, session, abort, url_for,
|
||||||
|
jsonify, send_from_directory,
|
||||||
|
)
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config["SECRET_KEY"] = "ticket-system-secret-key-change-in-production"
|
||||||
|
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///tickets.db"
|
||||||
|
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||||
|
app.config["UPLOAD_FOLDER"] = os.path.join(os.path.dirname(os.path.abspath(__file__)), "uploads")
|
||||||
|
app.config["MAX_CONTENT_LENGTH"] = 20 * 1024 * 1024 # 20 MB
|
||||||
|
|
||||||
|
db = SQLAlchemy(app)
|
||||||
|
|
||||||
|
# ── 枚举定义 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
STATUS = {
|
||||||
|
"open": "新建",
|
||||||
|
"in_progress": "进行中",
|
||||||
|
"pending": "待处理",
|
||||||
|
"resolved": "已完成",
|
||||||
|
"closed": "已关闭",
|
||||||
|
}
|
||||||
|
|
||||||
|
PRIORITY = {
|
||||||
|
"low": "低",
|
||||||
|
"medium": "中",
|
||||||
|
"high": "高",
|
||||||
|
"urgent": "紧急",
|
||||||
|
}
|
||||||
|
|
||||||
|
CATEGORY = {
|
||||||
|
"bug": "技术故障",
|
||||||
|
"feature": "功能需求",
|
||||||
|
"consult": "咨询",
|
||||||
|
"incident": "事件处理",
|
||||||
|
"other": "其他",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 数据模型 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
class User(db.Model):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
username = db.Column(db.String(80), unique=True, nullable=False)
|
||||||
|
password_hash = db.Column(db.String(200), nullable=False)
|
||||||
|
role = db.Column(db.String(20), default="user") # admin / user
|
||||||
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
def set_password(self, password):
|
||||||
|
self.password_hash = generate_password_hash(password)
|
||||||
|
|
||||||
|
def check_password(self, password):
|
||||||
|
return check_password_hash(self.password_hash, password)
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(db.Model):
|
||||||
|
__tablename__ = "notifications"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
|
||||||
|
title = db.Column(db.String(200), nullable=False)
|
||||||
|
content = db.Column(db.Text)
|
||||||
|
ticket_id = db.Column(db.Integer) # 关联工单(可选)
|
||||||
|
read = db.Column(db.Boolean, default=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
user = db.relationship("User", backref=db.backref("notifications", lazy=True))
|
||||||
|
|
||||||
|
|
||||||
|
class Ticket(db.Model):
|
||||||
|
__tablename__ = "tickets"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
title = db.Column(db.String(200), nullable=False)
|
||||||
|
description = db.Column(db.Text) # 支持 HTML(内联图片)
|
||||||
|
status = db.Column(db.String(20), default="open", nullable=False)
|
||||||
|
priority = db.Column(db.String(20), default="medium", nullable=False)
|
||||||
|
category = db.Column(db.String(20), default="other", nullable=False)
|
||||||
|
assignee = db.Column(db.String(100))
|
||||||
|
requester = db.Column(db.String(100))
|
||||||
|
cc_users = db.Column(db.String(500), default="") # 抄送人(逗号分隔)
|
||||||
|
tags = db.Column(db.String(500), default="")
|
||||||
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||||
|
updated_at = db.Column(
|
||||||
|
db.DateTime,
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
comments = db.relationship(
|
||||||
|
"Comment", backref="ticket", lazy=True, cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Comment(db.Model):
|
||||||
|
__tablename__ = "comments"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
ticket_id = db.Column(db.Integer, db.ForeignKey("tickets.id"), nullable=False)
|
||||||
|
author = db.Column(db.String(100), nullable=False)
|
||||||
|
content = db.Column(db.Text, nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
class Attachment(db.Model):
|
||||||
|
"""工单附件(图片 / 文件)"""
|
||||||
|
__tablename__ = "attachments"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
ticket_id = db.Column(db.Integer, db.ForeignKey("tickets.id"), nullable=False)
|
||||||
|
filename = db.Column(db.String(200), nullable=False) # 存储文件名(uuid)
|
||||||
|
original_name = db.Column(db.String(300), nullable=False) # 原始文件名
|
||||||
|
content_type = db.Column(db.String(100)) # MIME type
|
||||||
|
size = db.Column(db.Integer, default=0) # 字节
|
||||||
|
is_image = db.Column(db.Boolean, default=False)
|
||||||
|
uploaded_by = db.Column(db.String(100))
|
||||||
|
uploaded_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
ticket = db.relationship("Ticket", backref=db.backref(
|
||||||
|
"attachments", lazy=True, cascade="all, delete-orphan",
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
# ── 工具函数 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def status_label(s):
|
||||||
|
return STATUS.get(s, s)
|
||||||
|
|
||||||
|
|
||||||
|
def priority_label(p):
|
||||||
|
return PRIORITY.get(p, p)
|
||||||
|
|
||||||
|
|
||||||
|
def category_label(c):
|
||||||
|
return CATEGORY.get(c, c)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_tags(tags):
|
||||||
|
if not tags:
|
||||||
|
return []
|
||||||
|
return [t.strip() for t in tags.split(",") if t.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def current_user():
|
||||||
|
"""返回当前登录用户或 None"""
|
||||||
|
if "user_id" not in session:
|
||||||
|
return None
|
||||||
|
return db.session.get(User, session["user_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def login_required(func):
|
||||||
|
"""登录守卫装饰器"""
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
if "user_id" not in session:
|
||||||
|
return redirect(url_for("login", next=request.path))
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
wrapper.__name__ = func.__name__
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def create_notification(user_id, title, content="", ticket_id=None):
|
||||||
|
"""为指定用户创建一条通知"""
|
||||||
|
n = Notification(
|
||||||
|
user_id=user_id,
|
||||||
|
title=title,
|
||||||
|
content=content,
|
||||||
|
ticket_id=ticket_id,
|
||||||
|
)
|
||||||
|
db.session.add(n)
|
||||||
|
|
||||||
|
|
||||||
|
def all_users():
|
||||||
|
"""返回所有用户列表,供下拉选择"""
|
||||||
|
return User.query.order_by(User.username).all()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cc(cc_str):
|
||||||
|
"""解析抄送人字段,返回列表"""
|
||||||
|
if not cc_str:
|
||||||
|
return []
|
||||||
|
return [u.strip() for u in cc_str.split(",") if u.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def can_access_ticket(user, ticket):
|
||||||
|
"""
|
||||||
|
权限控制:
|
||||||
|
- admin 可见所有工单
|
||||||
|
- 普通用户只能看到:自己是提交人 / 处理人 / 抄送人的工单
|
||||||
|
"""
|
||||||
|
if user.role == "admin":
|
||||||
|
return True
|
||||||
|
if ticket.requester == user.username:
|
||||||
|
return True
|
||||||
|
if ticket.assignee == user.username:
|
||||||
|
return True
|
||||||
|
if user.username in parse_cc(ticket.cc_users):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def accessible_tickets_query(me):
|
||||||
|
"""
|
||||||
|
返回当前用户可见的工单 Query。
|
||||||
|
- admin: 全部
|
||||||
|
- 普通用户: 提交人=me OR 处理人=me OR 在抄送人里
|
||||||
|
"""
|
||||||
|
if me.role == "admin":
|
||||||
|
return Ticket.query
|
||||||
|
return Ticket.query.filter(
|
||||||
|
db.or_(
|
||||||
|
Ticket.requester == me.username,
|
||||||
|
Ticket.assignee == me.username,
|
||||||
|
Ticket.cc_users.like(f"%{me.username}%"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _save_upload(file, ticket_id):
|
||||||
|
"""保存上传文件,返回 Attachment 实例或 None"""
|
||||||
|
me = current_user()
|
||||||
|
if not file or file.filename == "":
|
||||||
|
return None
|
||||||
|
|
||||||
|
ext = os.path.splitext(file.filename)[1].lower()
|
||||||
|
safe_name = f"{uuid.uuid4().hex}{ext}"
|
||||||
|
user_dir = os.path.join(app.config["UPLOAD_FOLDER"], str(ticket_id))
|
||||||
|
os.makedirs(user_dir, exist_ok=True)
|
||||||
|
path = os.path.join(user_dir, safe_name)
|
||||||
|
file.save(path)
|
||||||
|
|
||||||
|
content_type = file.content_type or "application/octet-stream"
|
||||||
|
is_image = content_type.startswith("image/")
|
||||||
|
|
||||||
|
att = Attachment(
|
||||||
|
ticket_id=ticket_id,
|
||||||
|
filename=safe_name,
|
||||||
|
original_name=file.filename,
|
||||||
|
content_type=content_type,
|
||||||
|
size=os.path.getsize(path),
|
||||||
|
is_image=is_image,
|
||||||
|
uploaded_by=me.username if me else "system",
|
||||||
|
)
|
||||||
|
db.session.add(att)
|
||||||
|
db.session.commit()
|
||||||
|
return att
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/upload", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_upload():
|
||||||
|
"""JSON API:上传文件(含二进制流 / 粘贴图片)"""
|
||||||
|
ticket_id = request.args.get("t")
|
||||||
|
if not ticket_id:
|
||||||
|
return jsonify({"error": "缺少 ticket 参数"}), 400
|
||||||
|
ticket = db.get_or_404(Ticket, int(ticket_id))
|
||||||
|
user = current_user()
|
||||||
|
if not can_access_ticket(user, ticket):
|
||||||
|
return jsonify({"error": "无权限"}), 403
|
||||||
|
|
||||||
|
# 1) 普通文件上传 (multipart)
|
||||||
|
if "file" in request.files:
|
||||||
|
att = _save_upload(request.files["file"], ticket_id)
|
||||||
|
# 2) 二进制流(粘贴图片、拖拽)
|
||||||
|
elif request.data:
|
||||||
|
import base64
|
||||||
|
data = request.data
|
||||||
|
# 尝试 base64 解码(前端 paste 用 base64 传)
|
||||||
|
try:
|
||||||
|
if request.headers.get("Content-Type", "").startswith("image/"):
|
||||||
|
raw = data
|
||||||
|
elif request.headers.get("Content-Type") == "application/octet-stream":
|
||||||
|
raw = data
|
||||||
|
else:
|
||||||
|
raw = data
|
||||||
|
ext = ".png"
|
||||||
|
# 猜测扩展名
|
||||||
|
ct = request.headers.get("Content-Type", "")
|
||||||
|
ext_map = {
|
||||||
|
"image/jpeg": ".jpg", "image/png": ".png",
|
||||||
|
"image/gif": ".gif", "image/webp": ".webp",
|
||||||
|
}
|
||||||
|
ext = ext_map.get(ct, ".png")
|
||||||
|
safe_name = f"{uuid.uuid4().hex}{ext}"
|
||||||
|
user_dir = os.path.join(app.config["UPLOAD_FOLDER"], str(ticket_id))
|
||||||
|
os.makedirs(user_dir, exist_ok=True)
|
||||||
|
path = os.path.join(user_dir, safe_name)
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(raw)
|
||||||
|
att = Attachment(
|
||||||
|
ticket_id=ticket_id, filename=safe_name,
|
||||||
|
original_name="pasted-image.png", content_type=ct or "image/png",
|
||||||
|
size=os.path.getsize(path), is_image=True,
|
||||||
|
uploaded_by=current_user().username if current_user() else "system",
|
||||||
|
)
|
||||||
|
db.session.add(att)
|
||||||
|
db.session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
else:
|
||||||
|
return jsonify({"error": "没有上传文件"}), 400
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"ok": True,
|
||||||
|
"url": f"/uploads/{ticket_id}/{att.filename}",
|
||||||
|
"filename": att.original_name,
|
||||||
|
"is_image": att.is_image,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/upload/delete/<int:aid>", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_delete_attachment(aid):
|
||||||
|
"""删除附件"""
|
||||||
|
att = db.get_or_404(Attachment, aid)
|
||||||
|
os.remove(os.path.join(app.config["UPLOAD_FOLDER"], str(att.ticket_id), att.filename))
|
||||||
|
db.session.delete(att)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/uploads/<int:ticket_id>/<path:filename>")
|
||||||
|
@login_required
|
||||||
|
def serve_upload(ticket_id, filename):
|
||||||
|
"""提供上传文件下载"""
|
||||||
|
ticket = db.get_or_404(Ticket, ticket_id)
|
||||||
|
user = current_user()
|
||||||
|
if not can_access_ticket(user, ticket):
|
||||||
|
abort(403)
|
||||||
|
safe_dir = os.path.join(app.config["UPLOAD_FOLDER"], str(ticket_id))
|
||||||
|
return send_from_directory(safe_dir, filename)
|
||||||
|
|
||||||
|
@app.route("/login", methods=["GET", "POST"])
|
||||||
|
def login():
|
||||||
|
if "user_id" in session:
|
||||||
|
return redirect(url_for("dashboard"))
|
||||||
|
if request.method == "POST":
|
||||||
|
username = request.form.get("username", "").strip()
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
user = User.query.filter_by(username=username).first()
|
||||||
|
if user and user.check_password(password):
|
||||||
|
session["user_id"] = user.id
|
||||||
|
session["username"] = user.username
|
||||||
|
next_page = request.args.get("next") or url_for("dashboard")
|
||||||
|
return redirect(next_page)
|
||||||
|
return render_template(
|
||||||
|
"login.html", error="用户名或密码错误", username=username,
|
||||||
|
)
|
||||||
|
return render_template("login.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/register", methods=["GET", "POST"])
|
||||||
|
def register():
|
||||||
|
if "user_id" in session:
|
||||||
|
return redirect(url_for("dashboard"))
|
||||||
|
if request.method == "POST":
|
||||||
|
username = request.form.get("username", "").strip()
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
password2 = request.form.get("password2", "")
|
||||||
|
if not username or not password:
|
||||||
|
return render_template(
|
||||||
|
"register.html", error="用户名和密码不能为空", username=username,
|
||||||
|
)
|
||||||
|
if password != password2:
|
||||||
|
return render_template(
|
||||||
|
"register.html", error="两次输入的密码不一致", username=username,
|
||||||
|
)
|
||||||
|
if User.query.filter_by(username=username).first():
|
||||||
|
return render_template(
|
||||||
|
"register.html", error="用户名已存在", username=username,
|
||||||
|
)
|
||||||
|
user = User(username=username, role="user")
|
||||||
|
user.set_password(password)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
session["user_id"] = user.id
|
||||||
|
session["username"] = user.username
|
||||||
|
return redirect(url_for("dashboard"))
|
||||||
|
return render_template("register.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/logout")
|
||||||
|
def logout():
|
||||||
|
session.pop("user_id", None)
|
||||||
|
session.pop("username", None)
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
|
||||||
|
|
||||||
|
# ── 通知路由 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.route("/notifications")
|
||||||
|
@login_required
|
||||||
|
def notifications():
|
||||||
|
user = current_user()
|
||||||
|
notifs = (
|
||||||
|
Notification.query.filter_by(user_id=user.id)
|
||||||
|
.order_by(Notification.created_at.desc())
|
||||||
|
.limit(50)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
# 标记为已读
|
||||||
|
for n in notifs:
|
||||||
|
n.read = True
|
||||||
|
db.session.commit()
|
||||||
|
return render_template(
|
||||||
|
"notifications.html", notifications=notifs, datetime=datetime,
|
||||||
|
status_label=status_label,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/notifications/count")
|
||||||
|
def notifications_count():
|
||||||
|
"""返回未读通知数(供前端角标使用)"""
|
||||||
|
count = 0
|
||||||
|
if "user_id" in session:
|
||||||
|
count = Notification.query.filter_by(
|
||||||
|
user_id=session["user_id"], read=False,
|
||||||
|
).count()
|
||||||
|
return str(count)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 工单路由(全部需要登录) ──────────────────────────
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
@login_required
|
||||||
|
def dashboard():
|
||||||
|
"""统计看板"""
|
||||||
|
user = current_user()
|
||||||
|
me = user.username
|
||||||
|
base_query = accessible_tickets_query(user)
|
||||||
|
|
||||||
|
# 我的工单:提交人 + 处理人 + 抄送
|
||||||
|
my_tickets = base_query.count()
|
||||||
|
total = base_query.count()
|
||||||
|
by_status = {}
|
||||||
|
for key, label in STATUS.items():
|
||||||
|
by_status[key] = {"label": label, "count": base_query.filter_by(status=key).count()}
|
||||||
|
by_priority = {}
|
||||||
|
for key, label in PRIORITY.items():
|
||||||
|
by_priority[key] = {"label": label, "count": base_query.filter_by(priority=key).count()}
|
||||||
|
by_category = {}
|
||||||
|
for key, label in CATEGORY.items():
|
||||||
|
by_category[key] = {"label": label, "count": base_query.filter_by(category=key).count()}
|
||||||
|
recent = base_query.order_by(Ticket.updated_at.desc()).limit(10).all()
|
||||||
|
return render_template(
|
||||||
|
"dashboard.html",
|
||||||
|
total=total,
|
||||||
|
my_tickets=my_tickets,
|
||||||
|
by_status=by_status,
|
||||||
|
by_priority=by_priority,
|
||||||
|
by_category=by_category,
|
||||||
|
recent=recent,
|
||||||
|
STATUS=STATUS,
|
||||||
|
PRIORITY=PRIORITY,
|
||||||
|
CATEGORY=CATEGORY,
|
||||||
|
status_label=status_label,
|
||||||
|
priority_label=priority_label,
|
||||||
|
category_label=category_label,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/tickets")
|
||||||
|
@login_required
|
||||||
|
def ticket_list():
|
||||||
|
"""工单列表(支持筛选)"""
|
||||||
|
user = current_user()
|
||||||
|
q_status = request.args.get("status", "")
|
||||||
|
q_priority = request.args.get("priority", "")
|
||||||
|
q_category = request.args.get("category", "")
|
||||||
|
q_tag = request.args.get("tag", "")
|
||||||
|
q_keyword = request.args.get("q", "")
|
||||||
|
|
||||||
|
query = accessible_tickets_query(user)
|
||||||
|
|
||||||
|
if q_status:
|
||||||
|
query = query.filter_by(status=q_status)
|
||||||
|
if q_priority:
|
||||||
|
query = query.filter_by(priority=q_priority)
|
||||||
|
if q_category:
|
||||||
|
query = query.filter_by(category=q_category)
|
||||||
|
if q_tag:
|
||||||
|
query = query.filter(Ticket.tags.like(f"%{q_tag}%"))
|
||||||
|
if q_keyword:
|
||||||
|
kw = f"%{q_keyword}%"
|
||||||
|
query = query.filter(
|
||||||
|
db.or_(Ticket.title.like(kw), Ticket.description.like(kw)),
|
||||||
|
)
|
||||||
|
|
||||||
|
tickets = query.order_by(Ticket.updated_at.desc()).all()
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"ticket_list.html",
|
||||||
|
tickets=tickets,
|
||||||
|
STATUS=STATUS,
|
||||||
|
PRIORITY=PRIORITY,
|
||||||
|
CATEGORY=CATEGORY,
|
||||||
|
status_label=status_label,
|
||||||
|
priority_label=priority_label,
|
||||||
|
category_label=category_label,
|
||||||
|
parse_tags=parse_tags,
|
||||||
|
q_status=q_status,
|
||||||
|
q_priority=q_priority,
|
||||||
|
q_category=q_category,
|
||||||
|
q_tag=q_tag,
|
||||||
|
q_keyword=q_keyword,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/ticket/<int:tid>")
|
||||||
|
@login_required
|
||||||
|
def ticket_detail(tid):
|
||||||
|
t = db.get_or_404(Ticket, tid)
|
||||||
|
user = current_user()
|
||||||
|
if not can_access_ticket(user, t):
|
||||||
|
abort(403)
|
||||||
|
atts = Attachment.query.filter_by(ticket_id=tid).order_by(
|
||||||
|
Attachment.uploaded_at.desc()
|
||||||
|
).all()
|
||||||
|
return render_template(
|
||||||
|
"ticket_detail.html",
|
||||||
|
ticket=t,
|
||||||
|
attachments=atts,
|
||||||
|
STATUS=STATUS,
|
||||||
|
PRIORITY=PRIORITY,
|
||||||
|
CATEGORY=CATEGORY,
|
||||||
|
status_label=status_label,
|
||||||
|
priority_label=priority_label,
|
||||||
|
category_label=category_label,
|
||||||
|
parse_tags=parse_tags,
|
||||||
|
parse_cc=parse_cc,
|
||||||
|
datetime=datetime,
|
||||||
|
all_users=all_users,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/ticket/new", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
def ticket_new():
|
||||||
|
return _ticket_form(None)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/ticket/<int:tid>/edit", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
def ticket_edit(tid):
|
||||||
|
t = db.get_or_404(Ticket, tid)
|
||||||
|
user = current_user()
|
||||||
|
if not can_access_ticket(user, t):
|
||||||
|
abort(403)
|
||||||
|
return _ticket_form(t)
|
||||||
|
|
||||||
|
|
||||||
|
def _ticket_form(ticket):
|
||||||
|
me = current_user()
|
||||||
|
if request.method == "POST":
|
||||||
|
title = request.form.get("title", "").strip()
|
||||||
|
desc = request.form.get("description", "").strip()
|
||||||
|
status = request.form.get("status", "open")
|
||||||
|
priority = request.form.get("priority", "medium")
|
||||||
|
category = request.form.get("category", "other")
|
||||||
|
assignee = request.form.get("assignee", "").strip()
|
||||||
|
requester = request.form.get("requester", "").strip()
|
||||||
|
cc_str = request.form.get("cc_users", "").strip()
|
||||||
|
tags = request.form.get("tags", "").strip()
|
||||||
|
|
||||||
|
if not title:
|
||||||
|
return "标题不能为空", 400
|
||||||
|
|
||||||
|
if ticket:
|
||||||
|
ticket.title = title
|
||||||
|
ticket.description = desc
|
||||||
|
ticket.status = status
|
||||||
|
ticket.priority = priority
|
||||||
|
ticket.category = category
|
||||||
|
ticket.assignee = assignee or None
|
||||||
|
ticket.requester = requester or None
|
||||||
|
ticket.cc_users = cc_str
|
||||||
|
ticket.tags = tags
|
||||||
|
# 分配人变更时通知新处理人
|
||||||
|
if assignee and assignee != me.username:
|
||||||
|
u = User.query.filter_by(username=assignee).first()
|
||||||
|
if u:
|
||||||
|
create_notification(
|
||||||
|
u.id,
|
||||||
|
f"工单 #{ticket.id} 已分配给你",
|
||||||
|
title,
|
||||||
|
ticket_id=ticket.id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ticket = Ticket(
|
||||||
|
title=title,
|
||||||
|
description=desc,
|
||||||
|
status=status,
|
||||||
|
priority=priority,
|
||||||
|
category=category,
|
||||||
|
assignee=assignee or None,
|
||||||
|
requester=me.username,
|
||||||
|
cc_users=cc_str,
|
||||||
|
tags=tags,
|
||||||
|
)
|
||||||
|
db.session.add(ticket)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# 通知新增的抄送人
|
||||||
|
cc_list = parse_cc(cc_str)
|
||||||
|
for uname in cc_list:
|
||||||
|
u = User.query.filter_by(username=uname).first()
|
||||||
|
if u and u.username != me.username:
|
||||||
|
create_notification(
|
||||||
|
u.id,
|
||||||
|
f"工单 #{ticket.id} 被抄送给你",
|
||||||
|
title,
|
||||||
|
ticket_id=ticket.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return redirect(f"/ticket/{ticket.id}")
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"ticket_form.html",
|
||||||
|
ticket=ticket,
|
||||||
|
STATUS=STATUS,
|
||||||
|
PRIORITY=PRIORITY,
|
||||||
|
CATEGORY=CATEGORY,
|
||||||
|
is_edit=bool(ticket),
|
||||||
|
all_users=all_users,
|
||||||
|
current_username=me.username if me else "",
|
||||||
|
parse_cc=parse_cc,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/ticket/<int:tid>/comment", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def add_comment(tid):
|
||||||
|
t = db.get_or_404(Ticket, tid)
|
||||||
|
me = current_user()
|
||||||
|
content = request.form.get("content", "").strip()
|
||||||
|
if not content:
|
||||||
|
return "评论内容不能为空", 400
|
||||||
|
c = Comment(ticket_id=tid, author=me.username, content=content)
|
||||||
|
db.session.add(c)
|
||||||
|
|
||||||
|
# 通知:工单处理人 + 提交人 + 抄送人(避免通知自己)
|
||||||
|
notify_targets = set()
|
||||||
|
if t.assignee and t.assignee != me.username:
|
||||||
|
notify_targets.add(t.assignee)
|
||||||
|
if t.requester and t.requester != me.username:
|
||||||
|
notify_targets.add(t.requester)
|
||||||
|
for uname in parse_cc(t.cc_users):
|
||||||
|
if uname != me.username:
|
||||||
|
notify_targets.add(uname)
|
||||||
|
for uname in notify_targets:
|
||||||
|
u = User.query.filter_by(username=uname).first()
|
||||||
|
if u:
|
||||||
|
create_notification(
|
||||||
|
u.id,
|
||||||
|
f"工单 #{tid} 有新评论",
|
||||||
|
f"{me.username}: {content[:50]}{'...' if len(content) > 50 else ''}",
|
||||||
|
ticket_id=tid,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return redirect(f"/ticket/{tid}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/ticket/<int:tid>/close", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def close_ticket(tid):
|
||||||
|
t = db.get_or_404(Ticket, tid)
|
||||||
|
t.status = "closed"
|
||||||
|
db.session.commit()
|
||||||
|
return redirect(f"/ticket/{tid}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/ticket/<int:tid>/delete", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def delete_ticket(tid):
|
||||||
|
t = db.get_or_404(Ticket, tid)
|
||||||
|
db.session.delete(t)
|
||||||
|
db.session.commit()
|
||||||
|
return redirect("/tickets")
|
||||||
|
|
||||||
|
|
||||||
|
@app.template_filter("render_desc")
|
||||||
|
def render_desc_html(html):
|
||||||
|
"""
|
||||||
|
渲染描述中的内联图片,处理 contenteditable 的 div 包装。
|
||||||
|
只允许 <img> 和 <br> 标签,其他 HTML 转义。
|
||||||
|
contenteditable 默认会用 <div> 包裹段落,需要去掉。
|
||||||
|
"""
|
||||||
|
if not html:
|
||||||
|
return ""
|
||||||
|
from markupsafe import Markup, escape
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Step 1: 将 <div>...</div> 替换为内容 + <br>,模拟内容editable的行为
|
||||||
|
def replace_div(m):
|
||||||
|
content = m.group(1)
|
||||||
|
return content + "\n"
|
||||||
|
html = re.sub(r'<div[^>]*>(.*?)</div>', replace_div, html, flags=re.DOTALL)
|
||||||
|
|
||||||
|
# Step 2: 将 <p>...</p> 替换为内容 + <br>
|
||||||
|
html = re.sub(r'<p[^>]*>(.*?)</p>', replace_div, html, flags=re.DOTALL)
|
||||||
|
|
||||||
|
# Step 3: 将 <br> 和 <br/> 替换为 \n
|
||||||
|
html = re.sub(r'<br\s*/?>', '\n', html)
|
||||||
|
|
||||||
|
# Step 4: 转义所有文本,但保留 <img> 标签
|
||||||
|
parts = []
|
||||||
|
last = 0
|
||||||
|
for m in re.finditer(r'<img[^>]*>', html):
|
||||||
|
if m.start() > last:
|
||||||
|
parts.append(escape(html[last:m.start()]))
|
||||||
|
parts.append(Markup(m.group(0)))
|
||||||
|
last = m.end()
|
||||||
|
if last < len(html):
|
||||||
|
parts.append(escape(html[last:]))
|
||||||
|
|
||||||
|
result = "".join(parts)
|
||||||
|
# 替换 \n 为 <br> 用于显示
|
||||||
|
return Markup(result).replace("\n", "<br>")
|
||||||
|
|
||||||
|
|
||||||
|
@app.errorhandler(403)
|
||||||
|
def forbidden(e):
|
||||||
|
return render_template("error.html", code=403, message="无权限访问此工单"), 403
|
||||||
|
|
||||||
|
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def not_found(e):
|
||||||
|
return render_template("error.html", code=404, message="页面不存在"), 404
|
||||||
|
|
||||||
|
|
||||||
|
# ── 全局模板上下文 ────────────────────────────────────
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_globals():
|
||||||
|
"""每个模板都能访问的用户信息 + 未读通知数"""
|
||||||
|
unread = 0
|
||||||
|
if "user_id" in session:
|
||||||
|
unread = Notification.query.filter_by(
|
||||||
|
user_id=session["user_id"], read=False,
|
||||||
|
).count()
|
||||||
|
return dict(current_user=current_user, unread_count=unread)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 启动 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def seed_admin():
|
||||||
|
"""确保存在 admin 用户(密码 admin)"""
|
||||||
|
if not User.query.filter_by(username="admin").first():
|
||||||
|
admin = User(username="admin", role="admin")
|
||||||
|
admin.set_password("admin")
|
||||||
|
db.session.add(admin)
|
||||||
|
db.session.commit()
|
||||||
|
print("✅ 已创建默认管理员: admin / admin")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
seed_admin()
|
||||||
|
app.run(host="0.0.0.0", port=5000, debug=True)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Flask==3.0.0
|
||||||
|
Flask-SQLAlchemy==3.1.1
|
||||||
|
gunicorn==21.2.0
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
/* ── 全局 ─────────────────────────────────────────── */
|
||||||
|
:root {
|
||||||
|
--bg: #f5f6fa;
|
||||||
|
--card: #ffffff;
|
||||||
|
--border: #e1e4ea;
|
||||||
|
--text: #1a1d23;
|
||||||
|
--text-muted: #6b7280;
|
||||||
|
--primary: #4f46e5;
|
||||||
|
--primary-light: #eef2ff;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
--success: #10b981;
|
||||||
|
--info: #3b82f6;
|
||||||
|
--radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans SC", sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--primary); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ── 导航 ─────────────────────────────────────────── */
|
||||||
|
.navbar {
|
||||||
|
background: var(--card);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0 24px;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.nav-brand a { font-size: 18px; font-weight: 700; color: var(--text); text-decoration: none; }
|
||||||
|
.nav-links { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.nav-link { padding: 6px 14px; border-radius: 6px; font-size: 14px; color: var(--text-muted); }
|
||||||
|
.nav-link:hover, .nav-link.active { background: var(--primary-light); color: var(--primary); text-decoration: none; }
|
||||||
|
.btn-new { background: var(--primary) !important; color: #fff !important; font-weight: 600; }
|
||||||
|
.btn-new:hover { background: #4338ca !important; text-decoration: none; }
|
||||||
|
|
||||||
|
/* ── 容器 ─────────────────────────────────────────── */
|
||||||
|
.container { max-width: 1100px; margin: 0 auto; padding: 24px; }
|
||||||
|
|
||||||
|
/* ── 卡片 ─────────────────────────────────────────── */
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 看板 ─────────────────────────────────────────── */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.stat-card .num { font-size: 32px; font-weight: 700; }
|
||||||
|
.stat-card .label { font-size: 13px; color: var(--text-muted); margin-top: 4px; }
|
||||||
|
.stat-card.total .num { color: var(--primary); }
|
||||||
|
.stat-card.open .num { color: var(--info); }
|
||||||
|
.stat-card.in_progress .num { color: var(--warning); }
|
||||||
|
.stat-card.pending .num { color: #8b5cf6; }
|
||||||
|
.stat-card.resolved .num { color: var(--success); }
|
||||||
|
.stat-card.closed .num { color: #6b7280; }
|
||||||
|
|
||||||
|
h2.section-title { font-size: 16px; margin-bottom: 14px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* ── 工单列表 ─────────────────────────────────────── */
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.filters label { font-size: 13px; color: var(--text-muted); }
|
||||||
|
.filters select, .filters input {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--card);
|
||||||
|
}
|
||||||
|
.filters input { width: 180px; }
|
||||||
|
.filters form { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
|
||||||
|
.ticket-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.ticket-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-bottom: 2px solid var(--border);
|
||||||
|
}
|
||||||
|
.ticket-table td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.ticket-table tr:hover { background: #fafbff; }
|
||||||
|
|
||||||
|
/* ── 徽章 ─────────────────────────────────────────── */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.badge-open { background: #dbeafe; color: #1e40af; }
|
||||||
|
.badge-in_progress { background: #fef3c7; color: #92400e; }
|
||||||
|
.badge-pending { background: #ede9fe; color: #5b21b6; }
|
||||||
|
.badge-resolved { background: #d1fae5; color: #065f46; }
|
||||||
|
.badge-closed { background: #e5e7eb; color: #374151; }
|
||||||
|
|
||||||
|
.badge-urgent { background: #fee2e2; color: #991b1b; }
|
||||||
|
.badge-high { background: #ffedd5; color: #9a3412; }
|
||||||
|
.badge-medium { background: #fef3c7; color: #92400e; }
|
||||||
|
.badge-low { background: #dbeafe; color: #1e40af; }
|
||||||
|
|
||||||
|
.badge-bug { background: #fee2e2; color: #991b1b; }
|
||||||
|
.badge-feature { background: #dbeafe; color: #1e40af; }
|
||||||
|
.badge-consult { background: #d1fae5; color: #065f46; }
|
||||||
|
.badge-incident { background: #fef3c7; color: #92400e; }
|
||||||
|
.badge-other { background: #e5e7eb; color: #374151; }
|
||||||
|
|
||||||
|
/* ── 标签 ─────────────────────────────────────────── */
|
||||||
|
.tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #555;
|
||||||
|
margin-right: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 表单 ─────────────────────────────────────────── */
|
||||||
|
.form-group { margin-bottom: 16px; }
|
||||||
|
.form-group label { display: block; font-weight: 600; margin-bottom: 4px; font-size: 14px; }
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--card);
|
||||||
|
}
|
||||||
|
.form-group textarea { min-height: 100px; resize: vertical; }
|
||||||
|
.form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 8px 18px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.btn-primary { background: var(--primary); color: #fff; }
|
||||||
|
.btn-primary:hover { background: #4338ca; text-decoration: none; }
|
||||||
|
.btn-danger { background: var(--danger); color: #fff; }
|
||||||
|
.btn-danger:hover { background: #dc2626; text-decoration: none; }
|
||||||
|
.btn-secondary { background: #e5e7eb; color: #374151; }
|
||||||
|
|
||||||
|
/* ── 详情页 ───────────────────────────────────────── */
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.detail-header h1 { font-size: 22px; }
|
||||||
|
.detail-meta { font-size: 13px; color: var(--text-muted); margin-top: 4px; }
|
||||||
|
.detail-grid { display: grid; grid-template-columns: 1fr 280px; gap: 20px; }
|
||||||
|
.detail-grid > div:first-child { min-width: 0; }
|
||||||
|
.comment-list { margin-top: 8px; }
|
||||||
|
.comment {
|
||||||
|
border-left: 3px solid var(--primary);
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
background: var(--primary-light);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.comment .author { font-weight: 600; font-size: 13px; }
|
||||||
|
.comment .time { font-size: 12px; color: var(--text-muted); margin-left: 8px; }
|
||||||
|
.comment .content { margin-top: 4px; white-space: pre-wrap; }
|
||||||
|
.sidebar .card { margin-bottom: 14px; }
|
||||||
|
|
||||||
|
/* ── 通知角标 ───────────────────────────────────────── */
|
||||||
|
.notif-link { position: relative; }
|
||||||
|
.notif-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
right: -4px;
|
||||||
|
background: var(--danger);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 10px;
|
||||||
|
min-width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.user-info {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 登录页 ─────────────────────────────────────────── */
|
||||||
|
.auth-page {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 80vh;
|
||||||
|
}
|
||||||
|
.auth-card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 32px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
.auth-card h1 { text-align: center; margin-bottom: 24px; font-size: 22px; }
|
||||||
|
.auth-card .error {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.auth-link { text-align: center; margin-top: 16px; font-size: 14px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* ── 通知列表 ───────────────────────────────────────── */
|
||||||
|
.notif-item {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-left: 3px solid var(--primary);
|
||||||
|
background: var(--primary-light);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.notif-item.unread { border-left-color: var(--danger); background: #fef2f2; }
|
||||||
|
.notif-item .notif-title { font-weight: 600; font-size: 14px; }
|
||||||
|
.notif-item .notif-content { font-size: 13px; color: var(--text-muted); margin-top: 2px; }
|
||||||
|
.notif-item .notif-time { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
|
||||||
|
|
||||||
|
/* ── 通用表单下拉优化 ─────────────────────────────── */
|
||||||
|
.form-row-select { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
|
||||||
|
|
||||||
|
/* ── 附件上传区 ─────────────────────────────────── */
|
||||||
|
.attachment-zone {
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
background: #fafbff;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.attachment-zone:hover,
|
||||||
|
.attachment-zone.drag-over {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--primary-light);
|
||||||
|
}
|
||||||
|
.attachment-zone .az-label { color: var(--text-muted); font-size: 14px; }
|
||||||
|
.attachment-zone .az-hint { color: var(--text-muted); font-size: 12px; margin-top: 4px; }
|
||||||
|
.attachment-zone input[type="file"] { display: none; }
|
||||||
|
|
||||||
|
/* 附件列表 */
|
||||||
|
.attachment-list { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; }
|
||||||
|
.attachment-item {
|
||||||
|
position: relative;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
max-width: 200px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.attachment-item .att-thumb {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 150px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.attachment-item .att-info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.attachment-item .att-delete {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.attachment-item:hover .att-delete { opacity: 1; }
|
||||||
|
.attachment-item .att-link { font-size: 13px; word-break: break-all; }
|
||||||
|
.attachment-item .att-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 图片预览模态框 */
|
||||||
|
.image-modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.85);
|
||||||
|
z-index: 1000;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.image-modal.show { display: flex; }
|
||||||
|
.image-modal img {
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 40px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 上传进度条 */
|
||||||
|
.upload-progress {
|
||||||
|
height: 3px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-top: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.upload-progress.active { display: block; }
|
||||||
|
.upload-progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--primary);
|
||||||
|
width: 0%;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 富文本编辑器 ─────────────────────────────────── */
|
||||||
|
.rich-editor {
|
||||||
|
min-height: 100px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--card);
|
||||||
|
outline: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 300px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.rich-editor:focus { border-color: var(--primary); }
|
||||||
|
.rich-editor:empty::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.rich-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.rich-toolbar button {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--card);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.rich-toolbar button:hover { background: var(--primary-light); border-color: var(--primary); }
|
||||||
|
.inline-image { max-width: 100%; max-height: 300px; border-radius: 4px; margin: 4px 0; }
|
||||||
|
|
||||||
|
/* ── 抄送人选择器 ─────────────────────────────────── */
|
||||||
|
.cc-select-wrap { position: relative; }
|
||||||
|
.cc-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--card);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-height: 38px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.cc-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: var(--primary-light);
|
||||||
|
color: var(--primary);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.cc-chip .chip-x {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.cc-dropdown {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 50;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.cc-dropdown.show { display: block; }
|
||||||
|
.cc-option {
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.cc-option:hover { background: var(--primary-light); }
|
||||||
|
.cc-option.selected { background: var(--primary-light); color: var(--primary); font-weight: 600; }
|
||||||
|
.cc-option .check { font-weight: 700; }
|
||||||
|
.cc-placeholder { color: var(--text-muted); font-size: 13px; padding: 8px 12px; }
|
||||||
|
.footer { text-align: center; padding: 20px; color: var(--text-muted); font-size: 12px; }
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<!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>轻量级工单系统 · Flask + SQLite</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
{% 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 %}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{% 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 %}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
{% 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') }} ·
|
||||||
|
更新 {{ 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 %}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
{% 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 %}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
{% 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 %}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""
|
||||||
|
Gunicorn WSGI entry point.
|
||||||
|
DB initialization + upload folder setup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.makedirs(os.path.join(os.path.dirname(__file__), "uploads"), exist_ok=True)
|
||||||
|
|
||||||
|
from app import app, db, seed_admin
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
seed_admin()
|
||||||
Reference in New Issue
Block a user