371 lines
19 KiB
PHP
371 lines
19 KiB
PHP
<?php
|
||
// 后台工单管理:列表(按部门/状态筛选)+ 详情处理(多轮回复、状态流转、邮件通知用户)
|
||
require __DIR__ . '/../includes/init.php';
|
||
requireStaff();
|
||
require '_common.php';
|
||
$me = currentStaff();
|
||
$isAdmin = isAdminStaff();
|
||
|
||
$db = db();
|
||
$msg = '';
|
||
$err = '';
|
||
|
||
$depts = $db->query('SELECT * FROM ' . tn('ticket_departments') . ' ORDER BY sort, id')->fetchAll();
|
||
$deptMap = [];
|
||
foreach ($depts as $d) { $deptMap[$d['id']] = $d['name']; }
|
||
|
||
$validStatus = ['open', 'pending', 'resolved', 'closed'];
|
||
|
||
// 重新开启已关闭工单(快捷按钮)
|
||
if (isset($_POST['reopen'])) {
|
||
verifyCsrf();
|
||
$id = (int) ($_POST['id'] ?? 0);
|
||
$stmt = $db->prepare('SELECT id FROM ' . tn('tickets') . ' WHERE id = ?');
|
||
$stmt->execute([$id]);
|
||
if ($stmt->fetch()) {
|
||
$db->prepare('UPDATE ' . tn('tickets') . ' SET status=?, updated_at=NOW(), closed_at=NULL WHERE id=?')
|
||
->execute(['open', $id]);
|
||
$msg = '工单已重新开启。';
|
||
} else {
|
||
$err = '工单不存在';
|
||
}
|
||
}
|
||
|
||
// 分配负责人(仅管理员可操作;客服仅能看自己负责的工单)
|
||
if (isset($_POST['assign']) && $isAdmin) {
|
||
verifyCsrf();
|
||
$id = (int) ($_POST['id'] ?? 0);
|
||
$toUid = (int) ($_POST['assignee_id'] ?? 0);
|
||
if ($id <= 0) {
|
||
$err = '工单不存在';
|
||
} else {
|
||
$staffCheck = $db->prepare('SELECT id FROM ' . tn('users') . ' WHERE id = ? AND role IN (\'admin\',\'cs\') AND status = 1');
|
||
$staffCheck->execute([$toUid]);
|
||
$realUid = $toUid > 0 && $staffCheck->fetch() ? $toUid : null;
|
||
$db->prepare('UPDATE ' . tn('tickets') . ' SET assignee_id = ? WHERE id = ?')
|
||
->execute([$realUid, $id]);
|
||
$msg = $realUid ? '已分配负责人。' : '已取消分配(工单转为未分配,仅管理员可见)。';
|
||
}
|
||
}
|
||
|
||
// 处理提交(追加回复 / 状态变更)
|
||
if (isset($_POST['reply'])) {
|
||
verifyCsrf();
|
||
$id = (int) ($_POST['id'] ?? 0);
|
||
$status = trim($_POST['status'] ?? 'open');
|
||
$reply = trim($_POST['admin_reply'] ?? '');
|
||
$notify = !empty($_POST['notify']);
|
||
|
||
// 客服仅能处理分配给自己的工单
|
||
if (!$isAdmin) {
|
||
$chk = $db->prepare('SELECT assignee_id FROM ' . tn('tickets') . ' WHERE id = ?');
|
||
$chk->execute([$id]);
|
||
$ar = $chk->fetch();
|
||
if (!$ar || (int) $ar['assignee_id'] !== (int) $me['id']) {
|
||
$err = '该工单未分配给你,无权操作。';
|
||
$id = 0;
|
||
}
|
||
}
|
||
|
||
if (!in_array($status, $validStatus, true)) {
|
||
$err = '状态不合法';
|
||
} else {
|
||
$stmt = $db->prepare('SELECT t.*, u.email AS user_email, u.username AS user_name FROM ' . tn('tickets') . ' t LEFT JOIN ' . tn('users') . ' u ON u.id = t.user_id WHERE t.id = ?');
|
||
$stmt->execute([$id]);
|
||
$t = $stmt->fetch();
|
||
if (!$t) {
|
||
$err = '工单不存在';
|
||
} else {
|
||
$adminId = $me['id'];
|
||
$closedAt = $status === 'closed' ? date('Y-m-d H:i:s') : null;
|
||
|
||
if ($reply !== '') {
|
||
$db->prepare('INSERT INTO ' . tn('ticket_replies') . ' (ticket_id, user_id, is_admin, message, created_at) VALUES (?,?,?,?,NOW())')
|
||
->execute([$id, $adminId, 1, $reply]);
|
||
}
|
||
|
||
$db->prepare('UPDATE ' . tn('tickets') . ' SET status=?, admin_id=?, updated_at=NOW(), closed_at=? WHERE id=?')
|
||
->execute([$status, $adminId, $closedAt, $id]);
|
||
|
||
if ($notify && $t['user_email'] !== '') {
|
||
$base = siteBaseUrl();
|
||
$url = rtrim($base, '/') . '/mytickets.php?view=' . $id;
|
||
$subject = '[' . SITE_NAME . '] 您的工单 #' . $id . ' 有新回复';
|
||
$body = '<div style="font-family:sans-serif;max-width:560px;margin:auto">'
|
||
. '<h2 style="color:#2563eb">工单处理通知</h2>'
|
||
. '<p>您好 ' . h($t['user_name']) . ',您提交的工单已更新:</p>'
|
||
. '<p><strong>工单标题:</strong>' . h($t['subject']) . '</p>'
|
||
. '<p><strong>当前状态:</strong>' . ticketStatusLabel($status) . '</p>'
|
||
. ($reply !== '' ? '<div style="background:#f5f7fb;border-left:4px solid #2563eb;padding:12px 16px;margin:10px 0;white-space:pre-wrap">' . nl2br(h($reply)) . '</div>' : '')
|
||
. '<p><a href="' . $url . '" style="background:#2563eb;color:#fff;padding:10px 18px;border-radius:6px;text-decoration:none;display:inline-block">查看工单详情</a></p>'
|
||
. '</div>';
|
||
$r = fnwSendMail($t['user_email'], $subject, $body);
|
||
if (!$r['ok']) {
|
||
$msg = '工单已保存,但邮件通知发送失败:' . $r['error'];
|
||
} else {
|
||
$msg = '工单已保存,并已邮件通知用户。';
|
||
}
|
||
} else {
|
||
$msg = '工单已保存。';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 批准续期(续期工单专用)
|
||
if (isset($_POST['approve_renew'])) {
|
||
verifyCsrf();
|
||
if (!$isAdmin) {
|
||
$err = '仅管理员可批准续期。';
|
||
} else {
|
||
$id = (int) ($_POST['id'] ?? 0);
|
||
$reply = trim($_POST['admin_reply'] ?? '');
|
||
$stmt = $db->prepare('SELECT t.*, o.expires_at, o.period_days, u.email AS user_email, u.username AS user_name FROM ' . tn('tickets') . ' t LEFT JOIN ' . tn('orders') . ' o ON o.id = t.order_id LEFT JOIN ' . tn('users') . ' u ON u.id = t.user_id WHERE t.id = ?');
|
||
$stmt->execute([$id]);
|
||
$t = $stmt->fetch();
|
||
if (!$t || $t['type'] !== 'renewal' || !$t['order_id']) {
|
||
$err = '该工单不是有效的续期申请';
|
||
} elseif (empty($t['period_days']) || $t['period_days'] <= 0) {
|
||
$err = '关联订单无有效周期,无法续期';
|
||
} else {
|
||
// 从当前到期(未过期则顺延)或现在起,延长一个周期
|
||
$base = ($t['expires_at'] && !isExpired($t['expires_at'])) ? $t['expires_at'] : date('Y-m-d H:i:s');
|
||
$newExpiry = date('Y-m-d H:i:s', strtotime($base . ' +' . (int)$t['period_days'] . ' days'));
|
||
$db->prepare('UPDATE ' . tn('orders') . ' SET expires_at = ? WHERE id = ?')->execute([$newExpiry, $t['order_id']]);
|
||
|
||
$reply = $reply !== '' ? $reply : ('已为你续期,新到期时间:' . $newExpiry);
|
||
$adminId = $me['id'];
|
||
$db->prepare('INSERT INTO ' . tn('ticket_replies') . ' (ticket_id, user_id, is_admin, message, created_at) VALUES (?,?,?,?,NOW())')
|
||
->execute([$id, $adminId, 1, $reply]);
|
||
$db->prepare('UPDATE ' . tn('tickets') . ' SET admin_reply=?, status=?, admin_id=?, updated_at=NOW(), closed_at=NULL WHERE id=?')
|
||
->execute([$reply, 'resolved', $adminId, $id]);
|
||
|
||
$msg = '已批准续期,关联订单 #' . $t['order_id'] . ' 到期时间已延长至 ' . $newExpiry;
|
||
if (!empty($t['user_email'])) {
|
||
$baseUrl = siteBaseUrl();
|
||
$url = rtrim($baseUrl, '/') . '/mytickets.php?view=' . $id;
|
||
$subject = '[' . SITE_NAME . '] 你的续期申请已通过(订单 #' . $t['order_id'] . ')';
|
||
$body = '<div style="font-family:sans-serif;max-width:560px;margin:auto">'
|
||
. '<h2 style="color:#1a73e8">续期成功</h2>'
|
||
. '<p>你好 ' . h($t['user_name']) . ',你的续期申请已通过:</p>'
|
||
. '<p><strong>关联订单:</strong>#' . (int)$t['order_id'] . '</p>'
|
||
. '<p><strong>新到期时间:</strong>' . h($newExpiry) . '</p>'
|
||
. '<div style="background:#f5f7fb;border-left:4px solid #1a73e8;padding:12px 16px;margin:10px 0;white-space:pre-wrap">' . nl2br(h($reply)) . '</div>'
|
||
. '<p><a href="' . $url . '" style="background:#1a73e8;color:#fff;padding:10px 18px;border-radius:6px;text-decoration:none;display:inline-block">查看工单</a></p>'
|
||
. '<p class="muted">可在「我的订单 → 详情」中查看完整凭据。</p></div>';
|
||
fnwSendMail($t['user_email'], $subject, $body);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 列表筛选
|
||
$filterStatus = trim($_GET['status'] ?? '');
|
||
$filterDept = isset($_GET['dept']) ? (int) $_GET['dept'] : 0;
|
||
$where = [];
|
||
$params = [];
|
||
if ($filterStatus !== '') { $where[] = 't.status = ?'; $params[] = $filterStatus; }
|
||
if ($filterDept > 0) { $where[] = 't.dept_id = ?'; $params[] = $filterDept; }
|
||
// 客服仅能看到分配给自己的工单
|
||
if (!$isAdmin) {
|
||
$where[] = 't.assignee_id = ?';
|
||
$params[] = $me['id'];
|
||
}
|
||
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||
|
||
$totalStmt = $db->prepare('SELECT COUNT(*) FROM ' . tn('tickets') . ' t ' . $whereSql);
|
||
$totalStmt->execute($params);
|
||
$total = $totalStmt->fetchColumn();
|
||
|
||
$stmt = $db->prepare(
|
||
'SELECT t.*, u.username AS user_name, u.email AS user_email, d.name AS dept_name,
|
||
a.username AS assignee_name
|
||
FROM ' . tn('tickets') . ' t
|
||
LEFT JOIN ' . tn('users') . ' u ON u.id = t.user_id
|
||
LEFT JOIN ' . tn('ticket_departments') . ' d ON d.id = t.dept_id
|
||
LEFT JOIN ' . tn('users') . ' a ON a.id = t.assignee_id
|
||
' . $whereSql . '
|
||
ORDER BY t.updated_at DESC'
|
||
);
|
||
$stmt->execute($params);
|
||
$list = $stmt->fetchAll();
|
||
|
||
// 查看单条
|
||
$view = isset($_GET['view']) ? (int) $_GET['view'] : 0;
|
||
$ticket = null;
|
||
$replies = [];
|
||
if ($view > 0) {
|
||
$stmt = $db->prepare(
|
||
'SELECT t.*, u.username AS user_name, u.email AS user_email, d.name AS dept_name,
|
||
a.username AS assignee_name
|
||
FROM ' . tn('tickets') . ' t
|
||
LEFT JOIN ' . tn('users') . ' u ON u.id = t.user_id
|
||
LEFT JOIN ' . tn('ticket_departments') . ' d ON d.id = t.dept_id
|
||
LEFT JOIN ' . tn('users') . ' a ON a.id = t.assignee_id
|
||
WHERE t.id = ?'
|
||
);
|
||
$stmt->execute([$view]);
|
||
$ticket = $stmt->fetch();
|
||
|
||
// 客服仅能查看分配给自己的工单
|
||
if ($ticket && !$isAdmin && (int) $ticket['assignee_id'] !== (int) $me['id']) {
|
||
$ticket = null;
|
||
$err = '该工单未分配给你,无权查看。';
|
||
}
|
||
|
||
if ($ticket) {
|
||
$rstmt = $db->prepare(
|
||
'SELECT r.*, u.username FROM ' . tn('ticket_replies') . ' r
|
||
LEFT JOIN ' . tn('users') . ' u ON u.id = r.user_id
|
||
WHERE r.ticket_id = ? ORDER BY r.created_at ASC'
|
||
);
|
||
$rstmt->execute([$view]);
|
||
$replies = $rstmt->fetchAll();
|
||
}
|
||
}
|
||
|
||
// 负责人候选(管理员/客服),仅管理员可在表单中分配
|
||
$assignees = [];
|
||
if ($isAdmin) {
|
||
$assignees = $db->query("SELECT id, username, role FROM " . tn('users') . " WHERE role IN ('admin','cs') AND status = 1 ORDER BY role DESC, username")->fetchAll();
|
||
}
|
||
|
||
adminHeader('工单管理', 'tickets');
|
||
?>
|
||
<h1 class="page-title"><i class="fas fa-ticket-alt"></i> 工单管理(共 <?= (int) $total ?>)</h1>
|
||
|
||
<?php if ($msg): ?><p class="banner-ok"><i class="fas fa-check-circle"></i> <?= h($msg) ?></p><?php endif; ?>
|
||
<?php if ($err): ?><p class="form-err"><?= h($err) ?></p><?php endif; ?>
|
||
|
||
<?php if ($ticket): ?>
|
||
<div class="panel">
|
||
<div class="ticket-meta">
|
||
<span class="badge-status status-<?= h($ticket['status']) ?>"><?= ticketStatusLabel($ticket['status']) ?></span>
|
||
<span class="muted">类型:<?= ticketTypeLabel($ticket['type']) ?></span>
|
||
<span class="muted">提交人:<?= h($ticket['user_name'] ?? '游客') ?>(<?= h($ticket['user_email'] ?? '未填邮箱') ?>)</span>
|
||
<span class="muted">部门:<?= h($ticket['dept_name'] ?? '未指定') ?></span>
|
||
<span class="muted">优先级:<?= ticketPriorityLabel($ticket['priority']) ?></span>
|
||
<span class="muted">负责人:<?= !empty($ticket['assignee_name']) ? h($ticket['assignee_name']) : '<span style="color:var(--muted)">未分配</span>' ?></span>
|
||
<span class="muted">提交:<?= date('Y-m-d H:i', strtotime($ticket['created_at'])) ?></span>
|
||
</div>
|
||
<?php if ($ticket['type'] === 'renewal' && $ticket['order_id']): ?>
|
||
<?php $ord = $db->prepare('SELECT order_no, period_days, expires_at, status FROM ' . tn('orders') . ' WHERE id = ?'); $ord->execute([$ticket['order_id']]); $ord = $ord->fetch(); ?>
|
||
<div class="renew-box">
|
||
<i class="fas fa-rotate"></i> 续期申请 · 关联订单
|
||
#<?= (int)$ticket['order_id'] ?>(<?= $ord ? h($ord['order_no']) : '已删除' ?>)
|
||
<?php if ($ord): ?>
|
||
· 周期 <?= periodLabel($ord['period_days']) ?> · 当前到期 <?= expiryText($ord['expires_at']) ?>
|
||
<?php endif; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<div class="ticket-timeline">
|
||
<div class="ticket-bubble user-bubble">
|
||
<div class="bubble-head"><i class="fas fa-user"></i> <?= h($ticket['user_name'] ?? '用户') ?> · <?= date('Y-m-d H:i', strtotime($ticket['created_at'])) ?></div>
|
||
<h3 style="margin:8px 0 6px;font-size:1.05rem;"><?= h($ticket['subject']) ?></h3>
|
||
<div class="ticket-msg"><?= nl2br(h($ticket['message'])) ?></div>
|
||
</div>
|
||
|
||
<?php foreach ($replies as $r): ?>
|
||
<?php if ((int)$r['is_admin'] === 1): ?>
|
||
<div class="ticket-bubble admin-bubble">
|
||
<div class="bubble-head"><i class="fas fa-user-shield"></i> 处理人员(<?= h($r['username'] ?? '管理员') ?>) · <?= date('Y-m-d H:i', strtotime($r['created_at'])) ?></div>
|
||
<div class="ticket-msg"><?= nl2br(h($r['message'])) ?></div>
|
||
</div>
|
||
<?php else: ?>
|
||
<div class="ticket-bubble user-bubble">
|
||
<div class="bubble-head"><i class="fas fa-user"></i> <?= h($ticket['user_name'] ?? '用户') ?> · <?= date('Y-m-d H:i', strtotime($r['created_at'])) ?></div>
|
||
<div class="ticket-msg"><?= nl2br(h($r['message'])) ?></div>
|
||
</div>
|
||
<?php endif; ?>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
|
||
<form method="post" action="" class="grid-form" style="margin-top:18px;">
|
||
<?= csrfField() ?>
|
||
<input type="hidden" name="id" value="<?= (int) $ticket['id'] ?>">
|
||
<label class="span2">追加回复
|
||
<textarea name="admin_reply" rows="5" autocomplete="off" placeholder="填写新的处理说明,每次保存都会新增一条回复记录,不会覆盖历史回复(留空可直接修改状态)"></textarea>
|
||
</label>
|
||
<p class="muted span2" style="font-size:.85rem;margin:-6px 0 4px;"><i class="fas fa-info-circle"></i> 提示:历史回复以上方时间线为准,此处内容仅用于新增回复。</p>
|
||
<label>状态
|
||
<select name="status">
|
||
<option value="open" <?= $ticket['status']==='open'?'selected':'' ?>>待处理</option>
|
||
<option value="pending" <?= $ticket['status']==='pending'?'selected':'' ?>>处理中</option>
|
||
<option value="resolved" <?= $ticket['status']==='resolved'?'selected':'' ?>>已解决</option>
|
||
<option value="closed" <?= $ticket['status']==='closed'?'selected':'' ?>>已关闭</option>
|
||
</select>
|
||
</label>
|
||
<?php if ($isAdmin): ?>
|
||
<label>负责人(分配)
|
||
<select name="assignee_id">
|
||
<option value="0" <?= empty($ticket['assignee_id']) ? 'selected' : '' ?>>未分配(仅管理员可见)</option>
|
||
<?php foreach ($assignees as $a): ?>
|
||
<option value="<?= (int)$a['id'] ?>" <?= (int)$ticket['assignee_id']===(int)$a['id'] ? 'selected' : '' ?>><?= h($a['username']) ?>(<?= $a['role']==='admin'?'管理员':'客服' ?>)</option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</label>
|
||
<?php endif; ?>
|
||
<label class="checkbox">
|
||
<input type="checkbox" name="notify" <?= $ticket['user_email'] ? 'checked' : 'disabled' ?>> 邮件通知用户
|
||
</label>
|
||
<div class="form-actions span2">
|
||
<button type="submit" name="reply" class="btn btn-primary"><i class="fas fa-save"></i> 保存回复</button>
|
||
<?php if ($isAdmin): ?>
|
||
<button type="submit" name="assign" class="btn btn-ghost"><i class="fas fa-user-check"></i> 保存分配</button>
|
||
<?php endif; ?>
|
||
<?php if ($ticket['status'] === 'closed'): ?>
|
||
<button type="submit" name="reopen" class="btn btn-ok"><i class="fas fa-undo"></i> 重新开启</button>
|
||
<?php endif; ?>
|
||
<?php if ($ticket['type'] === 'renewal' && $ticket['order_id']): ?>
|
||
<button type="submit" name="approve_renew" class="btn btn-ok"><i class="fas fa-check"></i> 批准续期</button>
|
||
<?php endif; ?>
|
||
<a href="tickets.php" class="btn btn-ghost">返回列表</a>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
<?php else: ?>
|
||
<div class="panel">
|
||
<form method="get" action="" class="filter-bar">
|
||
<select name="status" onchange="this.form.submit()">
|
||
<option value="">全部状态</option>
|
||
<option value="open" <?= $filterStatus==='open'?'selected':'' ?>>待处理</option>
|
||
<option value="pending" <?= $filterStatus==='pending'?'selected':'' ?>>处理中</option>
|
||
<option value="resolved" <?= $filterStatus==='resolved'?'selected':'' ?>>已解决</option>
|
||
<option value="closed" <?= $filterStatus==='closed'?'selected':'' ?>>已关闭</option>
|
||
</select>
|
||
<select name="dept" onchange="this.form.submit()">
|
||
<option value="0">全部部门</option>
|
||
<?php foreach ($depts as $d): ?>
|
||
<option value="<?= (int) $d['id'] ?>" <?= $filterDept===$d['id']?'selected':'' ?>><?= h($d['name']) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
<a href="tickets.php" class="btn btn-ghost">重置</a>
|
||
</form>
|
||
<?php if (empty($list)): ?>
|
||
<p class="empty">暂无工单。</p>
|
||
<?php else: ?>
|
||
<table class="data-table">
|
||
<thead><tr><th>编号</th><th>标题</th><th>提交人</th><th>部门</th><th>类型</th><th>优先级</th><th>状态</th><th>负责人</th><th>更新</th><th></th></tr></thead>
|
||
<tbody>
|
||
<?php foreach ($list as $t): ?>
|
||
<tr>
|
||
<td>#<?= (int) $t['id'] ?></td>
|
||
<td><?= h($t['subject']) ?></td>
|
||
<td><?= h($t['user_name'] ?? '游客') ?></td>
|
||
<td><?= h($t['dept_name'] ?? '未指定') ?></td>
|
||
<td><?= ticketTypeLabel($t['type']) ?></td>
|
||
<td><?= ticketPriorityLabel($t['priority']) ?></td>
|
||
<td><span class="badge-status status-<?= h($t['status']) ?>"><?= ticketStatusLabel($t['status']) ?></span></td>
|
||
<td><?= !empty($t['assignee_name']) ? h($t['assignee_name']) : '<span class="muted">未分配</span>' ?></td>
|
||
<td><?= date('m-d H:i', strtotime($t['updated_at'])) ?></td>
|
||
<td><a href="tickets.php?view=<?= (int) $t['id'] ?>" class="mini-btn">处理</a></td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
<?php endif; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
<?php adminFooter(); ?>
|