Files
freeshop/admin/tickets.php
T
2026-08-14 10:55:06 +08:00

371 lines
19 KiB
PHP
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.
<?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(); ?>