26940c4574
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
368 lines
14 KiB
HTML
368 lines
14 KiB
HTML
{% 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 %}
|