Files
palladium-monitor/ticket-system/templates/ticket_form.html
T
AI Agent 26940c4574 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
2026-07-02 15:31:27 +08:00

368 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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 %}