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,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 %}
|
||||
Reference in New Issue
Block a user