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