后台管理界面:软件/分类/状态可配,反馈可改挂归属 (#1)

## 新增 lib/admin_taxonomy.php
- 软件:增删改、排序、启停;新增时自动预置 Bug/功能建议/其他 三个分类
- 分类:在选定软件下增删改、排序、启停
- 状态:改名、改「给用户看的话」、排序、启停;标识不可改(历史数据靠它对应)
- 反馈改挂:th_move,可把反馈移到别的软件/分类,跨软件的非法分类会被拒

## 安全与边界
- 所有动作走 CSRF 校验(沿用现有机制)
- 不能删最后一个软件 / 没有分类的软件至少要留一个启用分类
- 有反馈挂着的软件或分类不能删,提示改为停用(停用后前台消失、数据保留)
- th_move 加入 fb_admin_is_taxonomy_act 白名单,否则会被「问题不存在」静默吞掉

## admin.php 接线
- 引入模块 + 动作分发 + tab 白名单加 taxonomy/statuses + 导航两项
- 前置检查放行不依赖 $t 的动作(否则新增软件报「问题不存在」)
- 全部问题列表:加软件筛选、分类跟着软件走;修掉三处仍在用旧
  fb_statuses()/fb_categories() 的遍历(那会渲染出错)
- 详情页加「归属」表单,软件徽章可显示

## 其他文件
- api.php:状态/分类改数据库;新增 ?sw= / ?software_id= / ?cid= 过滤,
  老的 ?category=<slug> 仍可用(在所属软件内解析)
- thread.php:详情页显示软件与分类徽章
- config.example.php:移除 categories / statuses,改由数据库管理

## 修掉两个真实 bug
1. 移除配置后出厂分类兜底只剩 4 个,漏了「同步问题」—— 建库后核对数量才发现
2. th_move 不在白名单,POST 后返回 200 却静默不生效

验证:后台 e2e 29/29;改挂与联动 13/13;PHP 8.5 全部入口 0 弃用;
19 个 PHP 文件语法全过。
This commit is contained in:
2026-11-01 12:03:11 +08:00
parent 59f7466ae4
commit 55d2dfa46b
8 changed files with 639 additions and 38 deletions
+89 -13
View File
@@ -12,6 +12,7 @@
* 入口:/feedback/admin.php * 入口:/feedback/admin.php
*/ */
require __DIR__ . '/lib/bootstrap.php'; require __DIR__ . '/lib/bootstrap.php';
require __DIR__ . '/lib/admin_taxonomy.php';
$pdo = fb_db(); $pdo = fb_db();
@@ -93,7 +94,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['act'])) {
$act = (string)$_POST['act']; $act = (string)$_POST['act'];
$t = fb_admin_thread($pdo, $id); $t = fb_admin_thread($pdo, $id);
if (!$t && $act !== 'settings' && $act !== 'password') { // 这些动作不针对某一条反馈(不依赖 $t),单独放行
$noThread = in_array($act, ['settings', 'password'], true)
|| fb_admin_is_taxonomy_act($act);
if (!$t && !$noThread) {
$msg = '问题不存在。'; $msg = '问题不存在。';
$msgType = 'error'; $msgType = 'error';
@@ -182,6 +187,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['act'])) {
fb_mod_log('delete', $id, '删除问题:' . fb_excerpt((string)$t['title'], 40)); fb_mod_log('delete', $id, '删除问题:' . fb_excerpt((string)$t['title'], 40));
$msg = '#' . $id . ' 已删除。'; $msg = '#' . $id . ' 已删除。';
} elseif (fb_admin_is_taxonomy_act($act)) {
list($msg, $msgType) = fb_admin_taxonomy_action($pdo, $act, $_POST);
} elseif ($act === 'settings') { } elseif ($act === 'settings') {
$msg = fb_admin_save_settings(); $msg = fb_admin_save_settings();
@@ -300,7 +308,7 @@ function fb_admin_save_settings() {
* 页面 * 页面
* ================================================================== */ * ================================================================== */
$tab = (string)(isset($_GET['tab']) ? $_GET['tab'] : 'pending'); $tab = (string)(isset($_GET['tab']) ? $_GET['tab'] : 'pending');
if (!in_array($tab, ['pending', 'all', 'thread', 'settings', 'log'], true)) { $tab = 'pending'; } if (!in_array($tab, ['pending', 'all', 'thread', 'taxonomy', 'statuses', 'settings', 'log'], true)) { $tab = 'pending'; }
fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']); fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
?> ?>
@@ -315,6 +323,8 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
<nav class="tabs"> <nav class="tabs">
<a class="tab<?php echo $tab === 'pending' ? ' on' : ''; ?>" href="admin.php?tab=pending">待审核</a> <a class="tab<?php echo $tab === 'pending' ? ' on' : ''; ?>" href="admin.php?tab=pending">待审核</a>
<a class="tab<?php echo $tab === 'all' ? ' on' : ''; ?>" href="admin.php?tab=all">全部问题</a> <a class="tab<?php echo $tab === 'all' ? ' on' : ''; ?>" href="admin.php?tab=all">全部问题</a>
<a class="tab<?php echo $tab === 'taxonomy' ? ' on' : ''; ?>" href="admin.php?tab=taxonomy">软件与分类</a>
<a class="tab<?php echo $tab === 'statuses' ? ' on' : ''; ?>" href="admin.php?tab=statuses">处理状态</a>
<a class="tab<?php echo $tab === 'settings' ? ' on' : ''; ?>" href="admin.php?tab=settings">站点设置</a> <a class="tab<?php echo $tab === 'settings' ? ' on' : ''; ?>" href="admin.php?tab=settings">站点设置</a>
<a class="tab<?php echo $tab === 'log' ? ' on' : ''; ?>" href="admin.php?tab=log">操作日志</a> <a class="tab<?php echo $tab === 'log' ? ' on' : ''; ?>" href="admin.php?tab=log">操作日志</a>
</nav> </nav>
@@ -343,9 +353,12 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
$visF = (string)(isset($_GET['vis']) ? $_GET['vis'] : ''); $visF = (string)(isset($_GET['vis']) ? $_GET['vis'] : '');
$catF = (string)(isset($_GET['cat']) ? $_GET['cat'] : ''); $catF = (string)(isset($_GET['cat']) ? $_GET['cat'] : '');
$qF = trim((string)(isset($_GET['q']) ? $_GET['q'] : '')); $qF = trim((string)(isset($_GET['q']) ? $_GET['q'] : ''));
$swF = isset($_GET['sw']) ? (int)$_GET['sw'] : 0;
$cidF = isset($_GET['cid']) ? (int)$_GET['cid'] : 0;
if ($stF !== '') { $where[] = 'status = ?'; $args[] = $stF; } if ($stF !== '') { $where[] = 'status = ?'; $args[] = $stF; }
if ($visF === '1' || $visF === '0') { $where[] = 'visible = ?'; $args[] = (int)$visF; } if ($visF === '1' || $visF === '0') { $where[] = 'visible = ?'; $args[] = (int)$visF; }
if ($catF !== '') { $where[] = 'category = ?'; $args[] = $catF; } if ($swF > 0) { $where[] = 'software_id = ?'; $args[] = $swF; }
if ($cidF > 0) { $where[] = 'category_id = ?'; $args[] = $cidF; }
if ($qF !== '') { $where[] = '(title LIKE ? OR body LIKE ?)'; $args[] = '%' . $qF . '%'; $args[] = '%' . $qF . '%'; } if ($qF !== '') { $where[] = '(title LIKE ? OR body LIKE ?)'; $args[] = '%' . $qF . '%'; $args[] = '%' . $qF . '%'; }
$wSql = $where ? ('WHERE ' . implode(' AND ', $where)) : ''; $wSql = $where ? ('WHERE ' . implode(' AND ', $where)) : '';
@@ -364,17 +377,29 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
<form class="filters" method="get" action="admin.php"> <form class="filters" method="get" action="admin.php">
<input type="hidden" name="tab" value="all"> <input type="hidden" name="tab" value="all">
<input type="text" name="q" value="<?php echo e($qF); ?>" placeholder="搜索标题或正文"> <input type="text" name="q" value="<?php echo e($qF); ?>" placeholder="搜索标题或正文">
<select name="sw" onchange="this.form.submit()">
<option value="0">全部软件</option>
<?php foreach (fb_softwares(false) as $sw1): ?>
<option value="<?php echo (int)$sw1['id']; ?>"<?php echo $swF === (int)$sw1['id'] ? ' selected' : ''; ?>><?php echo e((string)$sw1['name']); ?></option>
<?php endforeach; ?>
</select>
<select name="status"> <select name="status">
<option value="">全部状态</option> <option value="">全部状态</option>
<?php foreach (fb_statuses() as $k => $label): ?> <?php foreach (fb_status_keys() as $k => $label): ?>
<option value="<?php echo e($k); ?>"<?php echo $stF === $k ? ' selected' : ''; ?>><?php echo e($label); ?></option> <option value="<?php echo e($k); ?>"<?php echo $stF === $k ? ' selected' : ''; ?>><?php echo e($label); ?></option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<select name="cat"> <select name="cid">
<option value="">全部分类</option> <option value="0">全部分类</option>
<?php foreach (fb_categories() as $k => $label): ?> <?php
<option value="<?php echo e($k); ?>"<?php echo $catF === $k ? ' selected' : ''; ?>><?php echo e($label); ?></option> // 选了某个软件就只列它的分类;否则把所有软件的分类按软件分组列出
<?php endforeach; ?> $catSwList = $swF > 0 ? [fb_software($swF)] : fb_softwares(false);
foreach ($catSwList as $csw1):
if (!$csw1) { continue; }
foreach (fb_categories((int)$csw1['id'], false) as $c1):
?>
<option value="<?php echo (int)$c1['id']; ?>"<?php echo $cidF === (int)$c1['id'] ? ' selected' : ''; ?>><?php echo e(($swF > 0 ? '' : (string)$csw1['name'] . ' · ') . (string)$c1['name']); ?></option>
<?php endforeach; endforeach; ?>
</select> </select>
<select name="vis"> <select name="vis">
<option value="">公开与否不限</option> <option value="">公开与否不限</option>
@@ -437,7 +462,12 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
<div class="card-form" style="margin:0 0 16px"> <div class="card-form" style="margin:0 0 16px">
<div class="item-top"> <div class="item-top">
<span class="badge cat"><?php echo e(fb_category_label($t['category'])); ?></span> <?php
$dSw = !empty($t['software_id']) ? fb_software((int)$t['software_id']) : null;
$dCat = !empty($t['category_id']) ? fb_category((int)$t['category_id']) : null;
?>
<?php if ($dSw): ?><span class="badge sw"><?php echo e((string)$dSw['name']); ?></span><?php endif; ?>
<?php if ($dCat): ?><span class="badge cat"><?php echo e((string)$dCat['name']); ?></span><?php endif; ?>
<span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span> <span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span>
<span class="item-title"><?php echo e($t['title']); ?></span> <span class="item-title"><?php echo e($t['title']); ?></span>
<span class="item-id">#<?php echo (int)$t['id']; ?></span> <span class="item-id">#<?php echo (int)$t['id']; ?></span>
@@ -469,6 +499,40 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
<h2 style="margin:24px 0 10px">归属</h2>
<form method="post" action="admin.php?tab=thread&amp;id=<?php echo (int)$id; ?>" class="card-form">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
<p class="muted" style="margin:0 0 10px;font-size:13.5px">
挂错软件或分类时在这里改正。换软件后分类选项会跟着变,需重新选择。
</p>
<div class="row2">
<label class="field">
<span>所属软件</span>
<select name="software_id" onchange="location.href='admin.php?tab=thread&amp;id=<?php echo (int)$id; ?>&amp;sw='+this.value">
<?php foreach (fb_softwares(false) as $m1): ?>
<option value="<?php echo (int)$m1['id']; ?>"<?php echo (int)(isset($t['software_id']) ? $t['software_id'] : 0) === (int)$m1['id'] ? ' selected' : ''; ?>><?php echo e((string)$m1['name']); ?></option>
<?php endforeach; ?>
</select>
</label>
<label class="field">
<span>分类</span>
<select name="category_id">
<option value="0">(不指定)</option>
<?php
$mvSw = isset($_GET['sw']) ? (int)$_GET['sw']
: (int)(isset($t['software_id']) ? $t['software_id'] : 0);
if ($mvSw <= 0) { $d0 = fb_default_software(); $mvSw = $d0 ? (int)$d0['id'] : 0; }
foreach (fb_categories($mvSw, false) as $m2):
?>
<option value="<?php echo (int)$m2['id']; ?>"<?php echo (int)(isset($t['category_id']) ? $t['category_id'] : 0) === (int)$m2['id'] ? ' selected' : ''; ?>><?php echo e((string)$m2['name']); ?></option>
<?php endforeach; ?>
</select>
</label>
</div>
<button class="btn" name="act" value="th_move" type="submit">保存归属</button>
</form>
<h2 style="margin:24px 0 10px">以官方身份回复</h2> <h2 style="margin:24px 0 10px">以官方身份回复</h2>
<form method="post" action="admin.php?tab=thread&amp;id=<?php echo (int)$id; ?>" class="card-form"> <form method="post" action="admin.php?tab=thread&amp;id=<?php echo (int)$id; ?>" class="card-form">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>"> <input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
@@ -481,7 +545,7 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
<span>同时把状态改为</span> <span>同时把状态改为</span>
<select name="status"> <select name="status">
<option value="">(不改)</option> <option value="">(不改)</option>
<?php foreach (fb_statuses() as $k => $label): ?> <?php foreach (fb_status_keys() as $k => $label): ?>
<option value="<?php echo e($k); ?>"><?php echo e($label); ?></option> <option value="<?php echo e($k); ?>"><?php echo e($label); ?></option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
@@ -499,7 +563,7 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
<label class="field"> <label class="field">
<span>处理状态</span> <span>处理状态</span>
<select name="status"> <select name="status">
<?php foreach (fb_statuses() as $k => $label): ?> <?php foreach (fb_status_keys() as $k => $label): ?>
<option value="<?php echo e($k); ?>"<?php echo ((string)$t['status'] === $k) ? ' selected' : ''; ?>><?php echo e($label); ?></option> <option value="<?php echo e($k); ?>"<?php echo ((string)$t['status'] === $k) ? ' selected' : ''; ?>><?php echo e($label); ?></option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
@@ -542,6 +606,14 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
</form> </form>
<?php endif; ?> <?php endif; ?>
<?php elseif ($tab === 'taxonomy'): ?>
<?php fb_admin_taxonomy_page($pdo,
isset($_GET['edit_sw']) ? (int)$_GET['edit_sw'] : 0,
isset($_GET['edit_cat']) ? (int)$_GET['edit_cat'] : 0); ?>
<?php elseif ($tab === 'statuses'): ?>
<?php fb_admin_status_page($pdo, isset($_GET['edit_st']) ? (int)$_GET['edit_st'] : 0); ?>
<?php elseif ($tab === 'settings'): ?> <?php elseif ($tab === 'settings'): ?>
<?php <?php
$mode = fb_store_mode(); $mode = fb_store_mode();
@@ -761,7 +833,11 @@ function fb_admin_thread_card(array $t) {
ob_start(); ?> ob_start(); ?>
<div class="card-form" style="margin:0 0 16px"> <div class="card-form" style="margin:0 0 16px">
<div class="item-top"> <div class="item-top">
<span class="badge cat"><?php echo e(fb_category_label($t['category'])); ?></span> <?php $cSw = !empty($t['software_id']) ? fb_software((int)$t['software_id']) : null; ?>
<?php if ($cSw): ?><span class="badge sw"><?php echo e((string)$cSw['name']); ?></span><?php endif; ?>
<?php if (!empty($t['category_id'])): ?>
<span class="badge cat"><?php echo e(fb_category_label((int)$t['category_id'])); ?></span>
<?php endif; ?>
<span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span> <span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span>
<span class="item-title"><?php echo e($t['title']); ?></span> <span class="item-title"><?php echo e($t['title']); ?></span>
<span class="item-id" title="问题编号">#<?php echo (int)$t['id']; ?></span> <span class="item-id" title="问题编号">#<?php echo (int)$t['id']; ?></span>
+30 -5
View File
@@ -34,8 +34,7 @@ $action = isset($_GET['action']) ? (string)$_GET['action'] : 'list';
$contacts = !empty($_GET['contacts']); $contacts = !empty($_GET['contacts']);
$limit = isset($_GET['limit']) ? max(1, min(500, (int)$_GET['limit'])) : 100; $limit = isset($_GET['limit']) ? max(1, min(500, (int)$_GET['limit'])) : 100;
$cats = fb_categories(); $statuses = fb_status_keys();
$statuses = fb_statuses();
/** 过滤条件 */ /** 过滤条件 */
function fb_api_where(&$args, $cats, $statuses) { function fb_api_where(&$args, $cats, $statuses) {
@@ -43,8 +42,32 @@ function fb_api_where(&$args, $cats, $statuses) {
if (!empty($_GET['status']) && isset($statuses[(string)$_GET['status']])) { if (!empty($_GET['status']) && isset($statuses[(string)$_GET['status']])) {
$w[] = 'status = ?'; $args[] = (string)$_GET['status']; $w[] = 'status = ?'; $args[] = (string)$_GET['status'];
} }
if (!empty($_GET['category']) && isset($cats[(string)$_GET['category']])) { // 软件:?sw=<slug> 或 ?software_id=<id>
$w[] = 'category = ?'; $args[] = (string)$_GET['category']; $swArg = '';
if (!empty($_GET['sw'])) {
$sw = fb_software((string)$_GET['sw']);
if ($sw) { $w[] = 'software_id = ?'; $args[] = (int)$sw['id']; $swArg = 'sw'; }
} elseif (!empty($_GET['software_id'])) {
$sw = fb_software((int)$_GET['software_id']);
if ($sw) { $w[] = 'software_id = ?'; $args[] = (int)$sw['id']; $swArg = 'id'; }
}
// 分类:?cid=<id> 优先;兼容老的 ?category=<slug>,在其所属软件内解析
if (!empty($_GET['cid'])) {
$c = fb_category((int)$_GET['cid']);
if ($c) { $w[] = 'category_id = ?'; $args[] = (int)$c['id']; }
} elseif (!empty($_GET['category'])) {
$slug = (string)$_GET['category'];
$swId = 0;
if (!empty($_GET['sw'])) {
$sw0 = fb_software((string)$_GET['sw']);
if ($sw0) { $swId = (int)$sw0['id']; }
}
if ($swId <= 0) {
$d = fb_default_software();
if ($d) { $swId = (int)$d['id']; }
}
$c = fb_category_by_slug($swId, $slug);
if ($c) { $w[] = 'category_id = ?'; $args[] = (int)$c['id']; }
} }
if (!empty($_GET['since'])) { if (!empty($_GET['since'])) {
$w[] = 'updated_at >= ?'; $args[] = (int)$_GET['since']; $w[] = 'updated_at >= ?'; $args[] = (int)$_GET['since'];
@@ -58,7 +81,9 @@ function fb_api_thread(array $t, $contacts) {
'id' => (int)$t['id'], 'id' => (int)$t['id'],
'title' => $t['title'], 'title' => $t['title'],
'category' => $t['category'], 'category' => $t['category'],
'category_cn' => fb_category_label($t['category']), 'category_id' => isset($t['category_id']) ? (int)$t['category_id'] : null,
'category_cn' => fb_category_label(isset($t['category_id']) ? $t['category_id'] : 0),
'software_id' => isset($t['software_id']) ? (int)$t['software_id'] : null,
'status' => $t['status'], 'status' => $t['status'],
'status_cn' => fb_status_label($t['status']), 'status_cn' => fb_status_label($t['status']),
'author' => $t['author_name'], 'author' => $t['author_name'],
+40
View File
@@ -81,6 +81,43 @@ main.wrap{padding-top:24px; padding-bottom:40px; min-height:60vh}
.page-head h1{margin-bottom:4px} .page-head h1{margin-bottom:4px}
.page-head .muted{font-size:14px; margin:0} .page-head .muted{font-size:14px; margin:0}
/* ---------- 后台:软件 / 分类 / 状态 ---------- */
.card-list{display:flex; flex-direction:column; gap:8px; margin-bottom:16px}
.tax-row{
display:flex; align-items:flex-start; gap:12px; flex-wrap:wrap;
padding:12px 14px; background:var(--card); border:1px solid var(--line);
border-radius:11px;
}
.tax-row.on{border-color:var(--brand); box-shadow:0 0 0 1px var(--brand) inset}
.tax-row.off{opacity:.62}
.tax-main{flex:1 1 320px; min-width:0}
.tax-name{font-weight:600; font-size:15.5px; text-decoration:none; color:var(--fg)}
.tax-name:hover{color:var(--brand)}
.tax-name.plain{font-weight:600}
.tax-slug{
display:inline-block; margin-left:8px; font-size:12px; color:var(--muted);
font-family:ui-monospace,Consolas,monospace;
}
.tax-meta{margin-left:10px; font-size:12.5px; color:var(--muted)}
.tax-note{margin-top:5px; font-size:13px; color:var(--muted); line-height:1.5}
.tax-acts{display:flex; flex-wrap:wrap; gap:6px; align-items:center}
.inline-form{display:inline}
.btn.sm{padding:5px 11px; font-size:13px; border-radius:8px}
.btn.danger{border-color:#e0b4b4; color:#b4242c}
.btn.danger:hover{background:#fdf2f2; border-color:#b4242c}
.tax-form-box{
margin:18px 0 8px; border:1px solid var(--line); border-radius:11px;
background:var(--card); overflow:hidden;
}
.tax-form-box>summary{
cursor:pointer; padding:12px 14px; font-weight:600; font-size:14.5px;
background:var(--bg-soft, #f7f8fa); user-select:none;
}
.tax-form-box>summary:hover{color:var(--brand)}
.tax-form-box[open]>summary{border-bottom:1px solid var(--line)}
.sm-form{padding:14px; margin:0}
.sm-form .field{margin-bottom:10px}
/* ---------- 软件切换(一级) ---------- */ /* ---------- 软件切换(一级) ---------- */
.software-bar{ .software-bar{
display:flex; flex-wrap:wrap; align-items:center; gap:8px; display:flex; flex-wrap:wrap; align-items:center; gap:8px;
@@ -136,6 +173,9 @@ main.wrap{padding-top:24px; padding-bottom:40px; min-height:60vh}
.badge.st-resolved{background:var(--ok-soft); color:var(--ok)} .badge.st-resolved{background:var(--ok-soft); color:var(--ok)}
.badge.st-closed{background:#f1f3f5; color:#8a929c} .badge.st-closed{background:#f1f3f5; color:#8a929c}
.badge.official{background:var(--brand); color:#fff} .badge.official{background:var(--brand); color:#fff}
/* 软件徽章:比分类更"重",一眼看出属于哪个软件 */
.badge.sw{background:#eef2f8; color:#2b4a74; border:1px solid #cfdcee; font-weight:600}
.badge.st-wontfix{background:#f6eef0; color:#a13c53}
/* ---------- 分页 ---------- */ /* ---------- 分页 ---------- */
.pager{display:flex; align-items:center; gap:12px; margin:22px 0 0; justify-content:center} .pager{display:flex; align-items:center; gap:12px; margin:22px 0 0; justify-content:center}
+8 -16
View File
@@ -50,22 +50,14 @@ return [
// 对访客 IP 做加盐哈希(数据库只保存哈希,不保存明文 IP)。★ 请修改为随机值 ★ // 对访客 IP 做加盐哈希(数据库只保存哈希,不保存明文 IP)。★ 请修改为随机值 ★
'ip_salt' => 'CHANGE_ME_random_salt', 'ip_salt' => 'CHANGE_ME_random_salt',
/* ---------------- 问题分类 ---------------- */ /* ---------------- 问题分类与处理状态:已移入数据库 ----------------
'categories' => [ * 不再写在这里。安装时会把出厂值写进 data/ 的数据库,
'usage' => '使用问题', * 之后在「管理后台 → 软件与分类 / 处理状态」里增删改,改这里无效。
'bug' => 'Bug 反馈', *
'feature' => '功能建议', * 出厂分类(首个软件下):使用问题 / Bug 反馈 / 功能建议 / 同步问题 / 其他
'sync' => '同步问题', * 出厂状态(5 个):待处理 / 处理中 / 已解决 / 已关闭 / 不予考虑
'other' => '其他', * 每个状态都带一句「给用户看的话」,会在通知邮件里发给提交人。
], */
/* ---------------- 处理状态 ---------------- */
'statuses' => [
'open' => '待处理',
'doing' => '处理中',
'resolved' => '已解决',
'closed' => '已关闭',
],
/* ---------------- 邮件通知(SMTP) ---------------- */ /* ---------------- 邮件通知(SMTP) ---------------- */
'smtp' => [ 'smtp' => [
+456
View File
@@ -0,0 +1,456 @@
<?php
/**
* 后台:软件 / 分类 / 状态 管理
* 全部存数据库,管理员在这里增删改排序启停,不用碰配置文件。
*/
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
/* ==================================================================
* 统一的表单处理入口(由 admin.php 在 CSRF 校验通过后调用)
* 返回 [消息, 类型],类型 ok / error
* ================================================================== */
function fb_admin_taxonomy_action($pdo, $act, $post) {
$now = time();
switch ($act) {
/* ---------------- 软件 ---------------- */
case 'sw_add': {
$name = trim((string)(isset($post['name']) ? $post['name'] : ''));
$slug = fb_sw_slugify(isset($post['slug']) ? $post['slug'] : '');
$note = trim((string)(isset($post['note']) ? $post['note'] : ''));
if ($name === '') { return ['软件名称不能为空。', 'error']; }
if (fb_len($name) > 40) { return ['软件名称最多 40 个字符。', 'error']; }
if ($slug === '') { $slug = fb_sw_slugify($name); }
if ($slug === '') { return ['请填写英文标识(用于网址)。', 'error']; }
if (fb_software($slug)) { return ['英文标识「' . $slug . '」已被占用,换一个。', 'error']; }
$sort = (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM software')->fetchColumn();
$pdo->prepare('INSERT INTO software(slug,name,note,sort_order,enabled,created_at) VALUES(?,?,?,?,1,?)')
->execute([$slug, $name, $note !== '' ? $note : null, $sort, $now]);
$newId = (int)$pdo->lastInsertId();
// 新软件默认给一组常见分类,省得空着没法发帖
$i = 0;
foreach ([['bug', 'Bug'], ['feature', '功能建议'], ['other', '其他']] as $c) {
$pdo->prepare('INSERT INTO categories(software_id,slug,name,sort_order,enabled,created_at) VALUES(?,?,?,?,1,?)')
->execute([$newId, $c[0], $c[1], $i++, $now]);
}
return ['已添加软件「' . $name . '」,并预置 3 个分类。', 'ok'];
}
case 'sw_edit': {
$id = (int)(isset($post['id']) ? $post['id'] : 0);
$sw = fb_software($id);
if (!$sw) { return ['软件不存在。', 'error']; }
$name = trim((string)(isset($post['name']) ? $post['name'] : ''));
$slug = fb_sw_slugify(isset($post['slug']) ? $post['slug'] : '');
$note = trim((string)(isset($post['note']) ? $post['note'] : ''));
if ($name === '') { return ['软件名称不能为空。', 'error']; }
if ($slug === '') { $slug = fb_sw_slugify($name); }
if ($slug === '') { return ['请填写英文标识。', 'error']; }
$other = fb_software($slug);
if ($other && (int)$other['id'] !== $id) { return ['英文标识「' . $slug . '」已被其他软件占用。', 'error']; }
$pdo->prepare('UPDATE software SET name=?, slug=?, note=?, sort_order=? WHERE id=?')
->execute([$name, $slug, $note !== '' ? $note : null,
(int)(isset($post['sort_order']) ? $post['sort_order'] : $sw['sort_order']), $id]);
return ['软件「' . $name . '」已保存。', 'ok'];
}
case 'sw_del': {
$id = (int)(isset($post['id']) ? $post['id'] : 0);
$sw = fb_software($id);
if (!$sw) { return ['软件不存在。', 'error']; }
$cnt = (int)$pdo->query('SELECT COUNT(*) FROM software')->fetchColumn();
if ($cnt <= 1) { return ['这是最后一个软件,不能删除(至少要保留一个)。', 'error']; }
$n = (int)$pdo->query('SELECT COUNT(*) FROM threads WHERE software_id = ' . $id)->fetchColumn();
if ($n > 0) {
return ['该软件下还有 ' . $n . ' 条反馈,不能删除。' .
'请先把这些反馈移到其他软件,或先停用该软件(停用后前台不再显示)。', 'error'];
}
$pdo->prepare('DELETE FROM categories WHERE software_id = ?')->execute([$id]);
$pdo->prepare('DELETE FROM software WHERE id = ?')->execute([$id]);
return ['已删除软件「' . $sw['name'] . '」。', 'ok'];
}
case 'sw_toggle': {
$id = (int)(isset($post['id']) ? $post['id'] : 0);
$sw = fb_software($id);
if (!$sw) { return ['软件不存在。', 'error']; }
$on = (int)$sw['enabled'] === 1 ? 0 : 1;
if ($on === 0) {
$en = (int)$pdo->query('SELECT COUNT(*) FROM software WHERE enabled = 1')->fetchColumn();
if ($en <= 1) { return ['至少要保留一个启用的软件。', 'error']; }
}
$pdo->prepare('UPDATE software SET enabled = ? WHERE id = ?')->execute([$on, $id]);
return ['软件「' . $sw['name'] . '」已' . ($on ? '启用' : '停用') . '。', 'ok'];
}
/* ---------------- 分类 ---------------- */
case 'cat_add': {
$swId = (int)(isset($post['software_id']) ? $post['software_id'] : 0);
if (!fb_software($swId)) { return ['请先选择软件。', 'error']; }
$name = trim((string)(isset($post['name']) ? $post['name'] : ''));
$slug = fb_sw_slugify(isset($post['slug']) ? $post['slug'] : '');
if ($name === '') { return ['分类名称不能为空。', 'error']; }
if (fb_len($name) > 30) { return ['分类名称最多 30 个字符。', 'error']; }
if ($slug === '') { $slug = fb_sw_slugify($name); }
if ($slug === '') { return ['请填写英文标识。', 'error']; }
if (fb_category_by_slug($swId, $slug)) { return ['该软件下已有标识为「' . $slug . '」的分类。', 'error']; }
$sort = (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM categories WHERE software_id = ' . $swId)->fetchColumn();
$pdo->prepare('INSERT INTO categories(software_id,slug,name,sort_order,enabled,created_at) VALUES(?,?,?,?,1,?)')
->execute([$swId, $slug, $name, $sort, $now]);
return ['已添加分类「' . $name . '」。', 'ok'];
}
case 'cat_edit': {
$id = (int)(isset($post['id']) ? $post['id'] : 0);
$cat = fb_category($id);
if (!$cat) { return ['分类不存在。', 'error']; }
$name = trim((string)(isset($post['name']) ? $post['name'] : ''));
$slug = fb_sw_slugify(isset($post['slug']) ? $post['slug'] : '');
if ($name === '') { return ['分类名称不能为空。', 'error']; }
if ($slug === '') { $slug = fb_sw_slugify($name); }
if ($slug === '') { return ['请填写英文标识。', 'error']; }
$other = fb_category_by_slug((int)$cat['software_id'], $slug);
if ($other && (int)$other['id'] !== $id) { return ['该软件下已有标识为「' . $slug . '」的分类。', 'error']; }
$pdo->prepare('UPDATE categories SET name=?, slug=?, sort_order=? WHERE id=?')
->execute([$name, $slug,
(int)(isset($post['sort_order']) ? $post['sort_order'] : $cat['sort_order']), $id]);
return ['分类「' . $name . '」已保存。', 'ok'];
}
case 'cat_del': {
$id = (int)(isset($post['id']) ? $post['id'] : 0);
$cat = fb_category($id);
if (!$cat) { return ['分类不存在。', 'error']; }
$n = (int)$pdo->query('SELECT COUNT(*) FROM threads WHERE category_id = ' . $id)->fetchColumn();
if ($n > 0) {
return ['该分类下还有 ' . $n . ' 条反馈,不能删除。可以改为停用(停用后前台不再出现,但历史反馈保留)。', 'error'];
}
$pdo->prepare('DELETE FROM categories WHERE id = ?')->execute([$id]);
return ['已删除分类「' . $cat['name'] . '」。', 'ok'];
}
case 'cat_toggle': {
$id = (int)(isset($post['id']) ? $post['id'] : 0);
$cat = fb_category($id);
if (!$cat) { return ['分类不存在。', 'error']; }
$on = (int)$cat['enabled'] === 1 ? 0 : 1;
if ($on === 0) {
$en = (int)$pdo->query('SELECT COUNT(*) FROM categories WHERE software_id = ' . (int)$cat['software_id'] . ' AND enabled = 1')->fetchColumn();
if ($en <= 1) { return ['每个软件至少要保留一个启用的分类。', 'error']; }
}
$pdo->prepare('UPDATE categories SET enabled = ? WHERE id = ?')->execute([$on, $id]);
return ['分类「' . $cat['name'] . '」已' . ($on ? '启用' : '停用') . '。', 'ok'];
}
/* ---------------- 状态 ---------------- */
case 'st_add': {
$key = fb_sw_slugify(isset($post['key_name']) ? $post['key_name'] : '');
$name = trim((string)(isset($post['name']) ? $post['name'] : ''));
$note = trim((string)(isset($post['user_note']) ? $post['user_note'] : ''));
if ($key === '') { return ['状态标识不能为空(只能用字母、数字、连字符)。', 'error']; }
if ($name === '') { return ['状态名称不能为空。', 'error']; }
$st = $pdo->prepare('SELECT id FROM statuses WHERE key_name = ? LIMIT 1');
$st->execute([$key]);
if ($st->fetchColumn() !== false) { return ['状态标识「' . $key . '」已存在。', 'error']; }
$sort = (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM statuses')->fetchColumn();
$pdo->prepare('INSERT INTO statuses(key_name,name,user_note,sort_order,enabled) VALUES(?,?,?,?,1)')
->execute([$key, $name, $note !== '' ? $note : null, $sort]);
return ['已添加状态「' . $name . '」。', 'ok'];
}
case 'st_edit': {
$id = (int)(isset($post['id']) ? $post['id'] : 0);
$st = $pdo->prepare('SELECT * FROM statuses WHERE id = ? LIMIT 1');
$st->execute([$id]);
$row = $st->fetch();
if (!$row) { return ['状态不存在。', 'error']; }
$name = trim((string)(isset($post['name']) ? $post['name'] : ''));
$note = trim((string)(isset($post['user_note']) ? $post['user_note'] : ''));
if ($name === '') { return ['状态名称不能为空。', 'error']; }
// key_name 不允许改:threads.status 里存的都是它,改了历史数据就失联
$pdo->prepare('UPDATE statuses SET name=?, user_note=?, sort_order=? WHERE id=?')
->execute([$name, $note !== '' ? $note : null,
(int)(isset($post['sort_order']) ? $post['sort_order'] : $row['sort_order']), $id]);
return ['状态「' . $name . '」已保存。', 'ok'];
}
case 'st_toggle': {
$id = (int)(isset($post['id']) ? $post['id'] : 0);
$st = $pdo->prepare('SELECT * FROM statuses WHERE id = ? LIMIT 1');
$st->execute([$id]);
$row = $st->fetch();
if (!$row) { return ['状态不存在。', 'error']; }
$on = (int)$row['enabled'] === 1 ? 0 : 1;
if ($on === 0) {
$en = (int)$pdo->query('SELECT COUNT(*) FROM statuses WHERE enabled = 1')->fetchColumn();
if ($en <= 1) { return ['至少要保留一个启用的状态。', 'error']; }
}
$pdo->prepare('UPDATE statuses SET enabled = ? WHERE id = ?')->execute([$on, $id]);
return ['状态「' . $row['name'] . '」已' . ($on ? '启用' : '停用') . '。', 'ok'];
}
/* ---------------- 把反馈改挂到别的软件/分类 ---------------- */
case 'th_move': {
$id = (int)(isset($post['id']) ? $post['id'] : 0);
$swId = (int)(isset($post['software_id']) ? $post['software_id'] : 0);
$catId = (int)(isset($post['category_id']) ? $post['category_id'] : 0);
$sw = fb_software($swId);
if (!$sw) { return ['请选择目标软件。', 'error']; }
$cat = null;
if ($catId) {
$cat = fb_category($catId);
if (!$cat || (int)$cat['software_id'] !== $swId) {
return ['所选分类不属于目标软件,请重新选择。', 'error'];
}
}
$pdo->prepare('UPDATE threads SET software_id = ?, category_id = ?, category = ?, updated_at = ? WHERE id = ?')
->execute([$swId, $cat ? (int)$cat['id'] : null,
$cat ? (string)$cat['slug'] : 'other', time(), $id]);
return ['已把 #' . $id . ' 改挂到「' . $sw['name'] . '」' .
($cat ? ' / ' . $cat['name'] : '') . '。', 'ok'];
}
}
return ['未知操作。', 'error'];
}
/** 这个 act 是否属于本模块 */
function fb_admin_is_taxonomy_act($act) {
return in_array($act, [
'sw_add', 'sw_edit', 'sw_del', 'sw_toggle',
'cat_add', 'cat_edit', 'cat_del', 'cat_toggle',
'st_add', 'st_edit', 'st_toggle',
'th_move',
], true);
}
/* ==================================================================
* 页面:软件与分类
* ================================================================== */
function fb_admin_taxonomy_page($pdo, $editSw, $editCat) {
$swList = fb_softwares(false); // 含停用的,后台要看得到
// 当前选中的软件:URL 指定 > 第一个
$curSwId = isset($_GET['sw']) ? (int)$_GET['sw'] : 0;
if ($curSwId <= 0 && $swList) { $curSwId = (int)$swList[0]['id']; }
$curSw = $curSwId > 0 ? fb_software($curSwId) : null;
$curCats = $curSw ? fb_categories((int)$curSw['id'], false) : [];
echo '<h2 style="margin:22px 0 10px">软件</h2>';
echo '<p class="muted" style="margin-top:-4px;font-size:13.5px">'
. '前台按软件分栏显示。停用的软件前台不再出现,但它下面的反馈与分类都保留。'
. '</p>';
if (!$swList) {
echo '<div class="empty"><p>还没有软件,先添加一个。</p></div>';
} else {
echo '<div class="card-list">';
foreach ($swList as $sw) {
$sid = (int)$sw['id'];
$on = (int)$sw['enabled'] === 1;
$nTh = (int)$pdo->query('SELECT COUNT(*) FROM threads WHERE software_id = ' . $sid)->fetchColumn();
$nCat = (int)$pdo->query('SELECT COUNT(*) FROM categories WHERE software_id = ' . $sid)->fetchColumn();
$isCur = $sid === $curSwId;
echo '<div class="tax-row' . ($isCur ? ' on' : '') . ($on ? '' : ' off') . '">';
echo ' <div class="tax-main">';
echo ' <a class="tax-name" href="admin.php?tab=taxonomy&sw=' . $sid . '">' . e((string)$sw['name']) . '</a>';
echo ' <span class="tax-slug">' . e((string)$sw['slug']) . '</span>';
if (!$on) { echo ' <span class="badge st">已停用</span>'; }
echo ' <span class="tax-meta">' . $nCat . ' 个分类 · ' . $nTh . ' 条反馈</span>';
if (!empty($sw['note'])) { echo ' <div class="tax-note">' . e((string)$sw['note']) . '</div>'; }
echo ' </div>';
echo ' <div class="tax-acts">';
echo ' <a class="btn sm" href="admin.php?tab=taxonomy&sw=' . $sid . '">分类</a>';
echo ' <a class="btn sm" href="admin.php?tab=taxonomy&edit_sw=' . $sid . '">编辑</a>';
echo ' <form method="post" action="admin.php?tab=taxonomy&sw=' . $sid . '" class="inline-form">';
echo ' <input type="hidden" name="csrf" value="' . e(fb_csrf_token()) . '">';
echo ' <input type="hidden" name="id" value="' . $sid . '">';
echo ' <button class="btn sm" name="act" value="sw_toggle" type="submit">' . ($on ? '停用' : '启用') . '</button>';
echo ' </form>';
echo ' <form method="post" action="admin.php?tab=taxonomy&sw=' . $sid . '" class="inline-form"'
. ' onsubmit="return confirm(\'确定删除软件「' . e((string)$sw['name']) . '」?\n只有在该软件下没有反馈时才能删除。\');">';
echo ' <input type="hidden" name="csrf" value="' . e(fb_csrf_token()) . '">';
echo ' <input type="hidden" name="id" value="' . $sid . '">';
echo ' <button class="btn sm danger" name="act" value="sw_del" type="submit">删除</button>';
echo ' </form>';
echo ' </div>';
echo '</div>';
}
echo '</div>';
}
/* ---- 新增 / 编辑软件 ---- */
$esw = null;
if ($editSw > 0) { $esw = fb_software($editSw); }
echo '<details class="tax-form-box"' . ($esw ? ' open' : '') . '>';
echo '<summary>' . ($esw ? '编辑软件「' . e((string)$esw['name']) . '」' : '添加软件') . '</summary>';
echo '<form method="post" action="admin.php?tab=taxonomy" class="card-form sm-form">';
echo ' <input type="hidden" name="csrf" value="' . e(fb_csrf_token()) . '">';
if ($esw) { echo ' <input type="hidden" name="id" value="' . (int)$esw['id'] . '">'; }
echo ' <div class="row2">';
echo ' <label class="field"><span>名称 <b class="req">*</b></span>'
. '<input type="text" name="name" maxlength="40" required value="'
. e($esw ? (string)$esw['name'] : '') . '" placeholder="例如:同步日历"></label>';
echo ' <label class="field"><span>英文标识<i class="hint">网址里用,留空自动生成</i></span>'
. '<input type="text" name="slug" maxlength="40" value="'
. e($esw ? (string)$esw['slug'] : '') . '" placeholder="synccalendar"></label>';
echo ' </div>';
echo ' <label class="field"><span>一句话说明<i class="hint">显示在列表页标题下,可留空</i></span>'
. '<input type="text" name="note" maxlength="120" value="'
. e($esw ? (string)($esw['note'] === null ? '' : $esw['note']) : '') . '"></label>';
if ($esw) {
echo ' <label class="field"><span>排序<i class="hint">数字小的排前面</i></span>'
. '<input type="number" name="sort_order" value="' . (int)$esw['sort_order'] . '"></label>';
echo ' <button class="btn primary" name="act" value="sw_edit" type="submit">保存</button> '
. '<a class="btn" href="admin.php?tab=taxonomy">取消</a>';
} else {
echo ' <button class="btn primary" name="act" value="sw_add" type="submit">添加软件</button>';
}
echo '</form></details>';
/* ---- 该软件下的分类 ---- */
if (!$curSw) { return; }
echo '<h2 style="margin:30px 0 10px">「' . e((string)$curSw['name']) . '」的分类</h2>';
echo '<p class="muted" style="margin-top:-4px;font-size:13.5px">'
. '每个软件有自己的一套分类,名称可以和其他软件重复。'
. '停用的分类前台不再出现,历史反馈照旧保留。'
. '</p>';
if (!$curCats) {
echo '<div class="empty"><p>这个软件还没有分类,先加一个。</p></div>';
} else {
echo '<div class="card-list">';
foreach ($curCats as $c) {
$cid = (int)$c['id'];
$on = (int)$c['enabled'] === 1;
$n = (int)$pdo->query('SELECT COUNT(*) FROM threads WHERE category_id = ' . $cid)->fetchColumn();
echo '<div class="tax-row' . ($on ? '' : ' off') . '">';
echo ' <div class="tax-main">';
echo ' <span class="tax-name plain">' . e((string)$c['name']) . '</span>';
echo ' <span class="tax-slug">' . e((string)$c['slug']) . '</span>';
if (!$on) { echo ' <span class="badge st">已停用</span>'; }
echo ' <span class="tax-meta">' . $n . ' 条反馈</span>';
echo ' </div>';
echo ' <div class="tax-acts">';
echo ' <a class="btn sm" href="admin.php?tab=taxonomy&sw=' . (int)$curSw['id'] . '&edit_cat=' . $cid . '">编辑</a>';
echo ' <form method="post" action="admin.php?tab=taxonomy&sw=' . (int)$curSw['id'] . '" class="inline-form">';
echo ' <input type="hidden" name="csrf" value="' . e(fb_csrf_token()) . '">';
echo ' <input type="hidden" name="id" value="' . $cid . '">';
echo ' <button class="btn sm" name="act" value="cat_toggle" type="submit">' . ($on ? '停用' : '启用') . '</button>';
echo ' </form>';
echo ' <form method="post" action="admin.php?tab=taxonomy&sw=' . (int)$curSw['id'] . '" class="inline-form"'
. ' onsubmit="return confirm(\'确定删除分类「' . e((string)$c['name']) . '」?\n只有在该分类下没有反馈时才能删除。\');">';
echo ' <input type="hidden" name="csrf" value="' . e(fb_csrf_token()) . '">';
echo ' <input type="hidden" name="id" value="' . $cid . '">';
echo ' <button class="btn sm danger" name="act" value="cat_del" type="submit">删除</button>';
echo ' </form>';
echo ' </div>';
echo '</div>';
}
echo '</div>';
}
/* ---- 新增 / 编辑分类 ---- */
$ecat = $editCat > 0 ? fb_category($editCat) : null;
echo '<details class="tax-form-box"' . ($ecat ? ' open' : '') . '>';
echo '<summary>' . ($ecat ? '编辑分类「' . e((string)$ecat['name']) . '」' : '添加分类') . '</summary>';
echo '<form method="post" action="admin.php?tab=taxonomy&sw=' . (int)$curSw['id'] . '" class="card-form sm-form">';
echo ' <input type="hidden" name="csrf" value="' . e(fb_csrf_token()) . '">';
echo ' <input type="hidden" name="software_id" value="' . (int)$curSw['id'] . '">';
if ($ecat) { echo ' <input type="hidden" name="id" value="' . (int)$ecat['id'] . '">'; }
echo ' <div class="row2">';
echo ' <label class="field"><span>分类名称 <b class="req">*</b></span>'
. '<input type="text" name="name" maxlength="30" required value="'
. e($ecat ? (string)$ecat['name'] : '') . '" placeholder="例如:Bug"></label>';
echo ' <label class="field"><span>英文标识<i class="hint">网址里用,留空自动生成</i></span>'
. '<input type="text" name="slug" maxlength="40" value="'
. e($ecat ? (string)$ecat['slug'] : '') . '" placeholder="bug"></label>';
echo ' </div>';
if ($ecat) {
echo ' <label class="field"><span>排序<i class="hint">数字小的排前面</i></span>'
. '<input type="number" name="sort_order" value="' . (int)$ecat['sort_order'] . '"></label>';
echo ' <button class="btn primary" name="act" value="cat_edit" type="submit">保存</button> '
. '<a class="btn" href="admin.php?tab=taxonomy&sw=' . (int)$curSw['id'] . '">取消</a>';
} else {
echo ' <button class="btn primary" name="act" value="cat_add" type="submit">添加分类</button>';
}
echo '</form></details>';
}
/* ==================================================================
* 页面:状态
* ================================================================== */
function fb_admin_status_page($pdo, $editId) {
$rows = fb_statuses(false);
echo '<h2 style="margin:22px 0 10px">处理状态</h2>';
echo '<p class="muted" style="margin-top:-4px;font-size:13.5px">'
. '「给用户看的话」会写进发给提交人的邮件正文。改这里的文字,前台与邮件都会跟着变。'
. '</p>';
echo '<div class="card-list">';
foreach ($rows as $r) {
$rid = (int)$r['id'];
$on = (int)$r['enabled'] === 1;
$n = (int)$pdo->query('SELECT COUNT(*) FROM threads WHERE status = '
. $pdo->quote((string)$r['key_name']))->fetchColumn();
echo '<div class="tax-row' . ($on ? '' : ' off') . '">';
echo ' <div class="tax-main">';
echo ' <span class="badge st st-' . e((string)$r['key_name']) . '">' . e((string)$r['name']) . '</span>';
echo ' <span class="tax-slug">' . e((string)$r['key_name']) . '</span>';
if (!$on) { echo ' <span class="badge st">已停用</span>'; }
echo ' <span class="tax-meta">' . $n . ' 条反馈</span>';
$note = (string)($r['user_note'] === null ? '' : $r['user_note']);
echo ' <div class="tax-note">' . ($note !== '' ? e($note) : '<i>(没有给用户看的说明)</i>') . '</div>';
echo ' </div>';
echo ' <div class="tax-acts">';
echo ' <a class="btn sm" href="admin.php?tab=statuses&edit_st=' . $rid . '">编辑</a>';
echo ' <form method="post" action="admin.php?tab=statuses" class="inline-form">';
echo ' <input type="hidden" name="csrf" value="' . e(fb_csrf_token()) . '">';
echo ' <input type="hidden" name="id" value="' . $rid . '">';
echo ' <button class="btn sm" name="act" value="st_toggle" type="submit">' . ($on ? '停用' : '启用') . '</button>';
echo ' </form>';
echo ' </div>';
echo '</div>';
}
echo '</div>';
/* ---- 新增 / 编辑状态 ---- */
$est = null;
if ($editId > 0) {
$q = $pdo->prepare('SELECT * FROM statuses WHERE id = ? LIMIT 1');
$q->execute([$editId]);
$est = $q->fetch();
}
echo '<details class="tax-form-box"' . ($est ? ' open' : '') . '>';
echo '<summary>' . ($est ? '编辑状态「' . e((string)$est['name']) . '」' : '添加状态') . '</summary>';
echo '<form method="post" action="admin.php?tab=statuses" class="card-form sm-form">';
echo ' <input type="hidden" name="csrf" value="' . e(fb_csrf_token()) . '">';
if ($est) { echo ' <input type="hidden" name="id" value="' . (int)$est['id'] . '">'; }
echo ' <div class="row2">';
if ($est) {
echo ' <label class="field"><span>标识<i class="hint">不可修改(历史数据靠它对应)</i></span>'
. '<input type="text" value="' . e((string)$est['key_name']) . '" disabled></label>';
} else {
echo ' <label class="field"><span>标识 <b class="req">*</b><i class="hint">字母数字连字符</i></span>'
. '<input type="text" name="key_name" maxlength="30" required placeholder="wontfix"></label>';
}
echo ' <label class="field"><span>显示名称 <b class="req">*</b></span>'
. '<input type="text" name="name" maxlength="20" required value="'
. e($est ? (string)$est['name'] : '') . '" placeholder="不予考虑"></label>';
echo ' </div>';
echo ' <label class="field"><span>给用户看的话<i class="hint">会写进通知邮件</i></span>'
. '<textarea name="user_note" rows="2" maxlength="200">'
. e($est ? (string)($est['user_note'] === null ? '' : $est['user_note']) : '') . '</textarea></label>';
if ($est) {
echo ' <label class="field"><span>排序</span>'
. '<input type="number" name="sort_order" value="' . (int)$est['sort_order'] . '"></label>';
echo ' <button class="btn primary" name="act" value="st_edit" type="submit">保存</button> '
. '<a class="btn" href="admin.php?tab=statuses">取消</a>';
} else {
echo ' <button class="btn primary" name="act" value="st_add" type="submit">添加状态</button>';
}
echo '</form></details>';
}
+8 -1
View File
@@ -293,7 +293,14 @@ SQL
$old = []; $old = [];
try { $old = (array)fb_cfg('categories', []); } catch (\Throwable $e) { $old = []; } try { $old = (array)fb_cfg('categories', []); } catch (\Throwable $e) { $old = []; }
if (!$old) { if (!$old) {
$old = ['usage' => '使用问题', 'bug' => 'Bug', 'feature' => '功能建议', 'other' => '其他']; // 出厂分类:与改造前配置里的五个完全一致,一个都不能少
$old = [
'usage' => '使用问题',
'bug' => 'Bug 反馈',
'feature' => '功能建议',
'sync' => '同步问题',
'other' => '其他',
];
} }
$i = 0; $i = 0;
$st = $pdo->prepare('INSERT INTO categories(software_id, slug, name, sort_order, enabled, created_at) $st = $pdo->prepare('INSERT INTO categories(software_id, slug, name, sort_order, enabled, created_at)
+1 -1
View File
@@ -27,7 +27,7 @@ function fb_layout_head($title, array $opt = []) {
<link rel="icon" type="image/png" sizes="16x16" href="assets/favicon-16x16.png"> <link rel="icon" type="image/png" sizes="16x16" href="assets/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="assets/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="assets/apple-touch-icon.png">
<link rel="manifest" href="assets/site.webmanifest"> <link rel="manifest" href="assets/site.webmanifest">
<link rel="stylesheet" href="assets/style.css?v=8"> <link rel="stylesheet" href="assets/style.css?v=10">
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
+7 -2
View File
@@ -42,7 +42,7 @@ if (!fb_thread_public($thread)) {
exit; exit;
} }
$statuses = fb_statuses(); $statuses = fb_status_keys();
$err = ''; $err = '';
$atts = []; $atts = [];
$form = ['name' => '', 'email' => '', 'body' => '', 'captcha' => '']; $form = ['name' => '', 'email' => '', 'body' => '', 'captcha' => ''];
@@ -156,7 +156,12 @@ fb_layout_head(fb_excerpt($thread['title'], 50));
<article class="subject"> <article class="subject">
<div class="item-top"> <div class="item-top">
<span class="badge cat"><?php echo e(fb_category_label($thread['category'])); ?></span> <?php
$thSw = !empty($thread['software_id']) ? fb_software((int)$thread['software_id']) : null;
$thCat = !empty($thread['category_id']) ? fb_category((int)$thread['category_id']) : null;
?>
<?php if ($thSw): ?><span class="badge sw"><?php echo e((string)$thSw['name']); ?></span><?php endif; ?>
<?php if ($thCat): ?><span class="badge cat"><?php echo e((string)$thCat['name']); ?></span><?php endif; ?>
<span class="badge st st-<?php echo e($thread['status']); ?>"><?php echo e(fb_status_label($thread['status'])); ?></span> <span class="badge st st-<?php echo e($thread['status']); ?>"><?php echo e(fb_status_label($thread['status'])); ?></span>
<h1 class="subject-title"><?php echo e($thread['title']); ?></h1> <h1 class="subject-title"><?php echo e($thread['title']); ?></h1>
<span class="item-id" title="问题编号">#<?php echo (int)$id; ?></span> <span class="item-id" title="问题编号">#<?php echo (int)$id; ?></span>