文件
2026-08-16 17:03:10 +08:00

331 行
18 KiB
PHP

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
<?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 . '] 您的工单
$body = '<div style="font-family:sans-serif;max-width:560px;margin:auto">'
. '<h2 style="color:
. '<p>您好 ' . h($t['user_name']) . ',您提交的工单已更新:</p>'
. '<p><strong>工单标题:</strong>' . h($t['subject']) . '</p>'
. '<p><strong>当前状态:</strong>' . ticketStatusLabel($status) . '</p>'
. ($reply !== '' ? '<div style="background:
. '<p><a href="' . $url . '" style="background:
. '</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 = '已批准续期,关联订单
if (!empty($t['user_email'])) {
$baseUrl = siteBaseUrl();
$url = rtrim($baseUrl, '/') . '/mytickets.php?view=' . $id;
$subject = '[' . SITE_NAME . '] 你的续期申请已通过(订单
$body = '<div style="font-family:sans-serif;max-width:560px;margin:auto">'
. '<h2 style="color:
. '<p>你好 ' . h($t['user_name']) . ',你的续期申请已通过:</p>'
. '<p><strong>关联订单:</strong>
. '<p><strong>新到期时间:</strong>' . h($newExpiry) . '</p>'
. '<div style="background:
. '<p><a href="' . $url . '" style="background:
. '<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> 续期申请 · 关联订单
<?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>
<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(); ?>