Files
FeedLite/lib/admin_taxonomy.php
T

713 lines
40 KiB
PHP
Raw Normal View History

<?php
/**
* 后台:软件 / 分类 / 状态 管理
* 全部存数据库,管理员在这里增删改排序启停,不用碰配置文件。
*/
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
/* ==================================================================
* 软件图标图片:上传 / 删除
* ------------------------------------------------------------------
* 安全边界(三条,缺一不可):
* ① 只有管理员能进(这个函数只从后台登录后的动作分支调用);
* ② 落盘前用 finfo 读真实文件头,必须是图片白名单里的类型 ——
* 与附件上传同一个教训:把 PHP 改名成 .png 就能写进目录,
* .htaccess 是唯一防线,换 nginx 就失守;
* ③ 键空间独立为 icons/,且形状受限(无穿越、无自由路径)。
*
* 存储方式跟附件完全一致:本机 → uploads/;OSS → 服务器代理 PUT 到私有桶。
* 对外一律 media.php?k=... 代理,不暴露真实位置。
* ================================================================== */
/** 图标允许的扩展名 —— 定义在 lib/attach.php(fb_icon_allowed_ext),
* 因为 media.php 与 CLI 场景也会用到,不能依赖这个后台文件被加载。 */
/**
* 保存一个上传的图标文件,返回 [bool ok, string 值(键或错误信息), string mime]。
* 成功后值是【对象键】;失败时值是给用户看的错误原因。
*/
function fb_icon_store_upload(array $file) {
if (!isset($file['error']) || (int)$file['error'] !== UPLOAD_ERR_OK) {
$code = isset($file['error']) ? (int)$file['error'] : -1;
return [false, $code === UPLOAD_ERR_INI_SIZE || $code === UPLOAD_ERR_FORM_SIZE
? '图片太大,请换一张小一点的(建议 512×512 以内)。'
: '上传失败(错误码 ' . $code . ')。', ''];
}
$tmp = (string)(isset($file['tmp_name']) ? $file['tmp_name'] : '');
if ($tmp === '' || !is_uploaded_file($tmp)) {
return [false, '上传失败,请重试。', ''];
}
$size = (int)@filesize($tmp);
if ($size <= 0) { return [false, '图片是空文件。', '']; }
if ($size > 2 * 1024 * 1024) { return [false, '图标不能超过 2 MB。', '']; }
// 真实类型(不信扩展名、不信前端上报的 mime)
$mime = '';
if (function_exists('finfo_open')) {
$fi = @finfo_open(FILEINFO_MIME_TYPE);
if ($fi) { $mime = strtolower(trim((string)@finfo_file($fi, $tmp))); @finfo_close($fi); }
}
$mimeMap = [
'image/png' => 'png',
'image/jpeg' => 'jpg',
'image/webp' => 'webp',
];
if (!isset($mimeMap[$mime])) {
return [false, '只支持 PNG / JPG / WEBP 格式的图片(检测到 ' . ($mime === '' ? '未知' : $mime) . ')。', ''];
}
$ext = $mimeMap[$mime];
// 尺寸下限:太小的图放大会糊,直接拒绝比事后难看更好
if (function_exists('getimagesize')) {
$wh = @getimagesize($tmp);
if ($wh && isset($wh[0], $wh[1])) {
if ($wh[0] < 32 || $wh[1] < 32) {
return [false, '图片太小(' . (int)$wh[0] . '×' . (int)$wh[1] . '),至少 32×32。', ''];
}
}
}
$mode = fb_store_mode();
if ($mode === 'off') { return [false, '附件存储未启用,无法保存图标。请先在「站点设置」里启用存储。', '']; }
$key = fb_icon_make_key($ext);
/* 断言:键的前缀必须与当前存储方式一致。
* 这两者一旦脱钩,就会出现「文件写进 uploads/ 但 media.php 去 OSS 找」的 503 ——
* 早期把键硬编码成 icons/… 就是这么坏的。宁可当场报错,也不要静默存错位置。 */
if ($mode === 'local' && !fb_attach_is_local($key)) {
return [false, '内部错误:本地模式下生成了非本机路径的图标键。', ''];
}
if ($mode === 'oss' && fb_attach_is_local($key)) {
return [false, '内部错误:OSS 模式下生成了本机路径的图标键。', ''];
}
if ($mode === 'local') {
if (!fb_local_writable()) { return [false, '本机附件目录不可写(uploads/),无法保存图标。', '']; }
$path = fb_local_path_of($key);
$dir = dirname($path);
if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
if (!is_dir($dir) || !@move_uploaded_file($tmp, $path)) {
return [false, '保存图片失败,请稍后重试。', ''];
}
@chmod($path, 0644);
} else {
if (!fb_oss_enabled()) { return [false, '对象存储未配置完整,无法保存图标。', '']; }
list($ok, $err) = fb_oss_put_file($key, $tmp, $mime);
if (!$ok) { return [false, '写入对象存储失败:' . $err, '']; }
}
return [true, $key, $mime];
}
/**
* 删除一个图标实体(换成新图 / 清空图标时调用,避免孤儿文件堆积)。
*
* @return bool 是否已确认删除(文件不存在也算成功)
*
* 🔴 不要用 @ 把错误吞掉:删失败却返回「已保存」,
* 用户会以为旧图清掉了,实际 OSS 上还留着一个可访问的孤儿图。
* 这里失败就返回 false,调用方据此提示。
*/
function fb_icon_delete($key) {
$key = trim((string)$key);
if ($key === '' || !fb_icon_key_ok($key)) { return true; } // 没东西可删
if (fb_attach_is_local($key)) {
$p = fb_local_path_of($key);
if (!is_file($p)) { return true; }
return (bool)@unlink($p);
}
if (!fb_oss_enabled()) { return true; } // OSS 关掉了,对象已不可达,不必纠结
if (!function_exists('fb_oss_delete')) { return false; }
try {
return (bool)fb_oss_delete($key);
} catch (Throwable $e) {
return false;
}
}
/* ==================================================================
* 统一的表单处理入口(由 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']; }
// 留空就自动生成。中文名生成不出 ASCII 时退化成 sw<序号>,不打扰用户。
if ($slug === '') {
$next = (int)$pdo->query('SELECT COALESCE(MAX(id),0)+1 FROM software')->fetchColumn();
$slug = fb_sw_slugify($name, 'sw', $next);
}
if (fb_software($slug)) { return ['英文标识「' . $slug . '」已被占用,换一个。', 'error']; }
$sort = (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM software')->fetchColumn();
$color = fb_color_norm(isset($post['color']) ? $post['color'] : '');
$icon = fb_sw_icon_norm(isset($post['icon']) ? $post['icon'] : '');
$dl = fb_sw_url_norm(isset($post['download_url']) ? $post['download_url'] : '');
// 新建时也可以直接带上图标图片(先入库,再更新 icon_key)
$iconKeyNew = '';
if (isset($_FILES['icon_file']) && is_array($_FILES['icon_file'])
&& (int)(isset($_FILES['icon_file']['error']) ? $_FILES['icon_file']['error'] : UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
list($okUp, $valUp) = fb_icon_store_upload($_FILES['icon_file']);
if (!$okUp) { return ['图标上传失败:' . $valUp, 'error']; }
$iconKeyNew = $valUp;
}
$pdo->prepare('INSERT INTO software(slug,name,note,color,icon,icon_key,download_url,sort_order,enabled,created_at) VALUES(?,?,?,?,?,?,?,?,1,?)')
->execute([$slug, $name, $note !== '' ? $note : null, $color, $icon, $iconKeyNew, $dl, $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 = (string)$sw['slug']; }
$other = fb_software($slug);
if ($other && (int)$other['id'] !== $id) { return ['英文标识「' . $slug . '」已被其他软件占用。', 'error']; }
$color = fb_color_norm(isset($post['color']) ? $post['color'] : '');
$icon = fb_sw_icon_norm(isset($post['icon']) ? $post['icon'] : '');
$dl = fb_sw_url_norm(isset($post['download_url']) ? $post['download_url'] : '');
/* ---- 图标图片 ----
* 三种情况:
* ① 上传了新图 → 换掉旧图(旧实体删掉,不留孤儿文件)
* ② 勾了「移除图标」→ 删掉图片,退回字符图标
* ③ 都没动 → 保持原样(不能因为没传文件就当成清空,
* 这与「配置被跨页清空」是同一类错误) */
$oldIconKey = isset($sw['icon_key']) ? trim((string)$sw['icon_key']) : '';
$iconKey = $oldIconKey;
$delFailed = '';
$hasNewFile = isset($_FILES['icon_file'])
&& is_array($_FILES['icon_file'])
&& (int)(isset($_FILES['icon_file']['error']) ? $_FILES['icon_file']['error'] : UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE;
if ($hasNewFile) {
list($okUp, $valUp) = fb_icon_store_upload($_FILES['icon_file']);
if (!$okUp) { return ['图标上传失败:' . $valUp, 'error']; }
$iconKey = $valUp;
if ($oldIconKey !== '' && $oldIconKey !== $iconKey) {
if (!fb_icon_delete($oldIconKey)) { $delFailed = $oldIconKey; }
}
} elseif (!empty($post['icon_clear'])) {
if ($oldIconKey !== '') {
if (!fb_icon_delete($oldIconKey)) { $delFailed = $oldIconKey; }
}
$iconKey = '';
}
$pdo->prepare('UPDATE software SET name=?, slug=?, note=?, color=?, icon=?, icon_key=?, download_url=?, sort_order=? WHERE id=?')
->execute([$name, $slug, $note !== '' ? $note : null, $color, $icon, $iconKey, $dl,
(int)(isset($post['sort_order']) ? $post['sort_order'] : $sw['sort_order']), $id]);
$extraMsg = '';
if ($hasNewFile) { $extraMsg = ',图标已更新'; }
elseif (!empty($post['icon_clear']) && $oldIconKey !== '') { $extraMsg = ',图标已移除'; }
// 旧图删不掉要如实说 —— 悄悄留下一个还能访问的孤儿图是安全隐患
if ($delFailed !== '') {
return ['软件「' . $name . '」已保存' . $extraMsg . ',但旧图标文件删除失败('
. $delFailed . '),它可能仍可被访问,建议手动到存储里删掉。', 'error'];
}
return ['软件「' . $name . '」已保存' . $extraMsg . '。', '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]);
// 顺手删掉它的图标文件,避免 icons/ 下堆孤儿图
if (isset($sw['icon_key'])) { fb_icon_delete((string)$sw['icon_key']); }
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']; }
// 留空自动生成;中文名退化成 cat<序号>(在本软件内取号,保证不冲突)
if ($slug === '') {
$next = (int)$pdo->query('SELECT COALESCE(MAX(id),0)+1 FROM categories')->fetchColumn();
$slug = fb_sw_slugify($name, 'cat', $next);
}
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 = (string)$cat['slug']; }
$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">';
list($swC, $swCd, $swCs) = fb_software_colors($sw);
echo ' <span class="sw-dot" style="background:' . e($swC) . '" title="' . e($swC) . '"></span>';
echo ' <a class="tax-name" href="admin.php?tab=taxonomy&sw=' . $sid . '">'
. fb_software_badge($sw, 'sm') . '</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" enctype="multipart/form-data" data-validate>';
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>';
/* ---- 图标 ----
* 两种方式并存,图片优先:
* ① 上传一张图片(存 OSS / 本机,前端经 media.php 代理显示)—— 推荐
* ② 不传图片时用下面那个字符(emoji / 汉字 / 字母)兜底
* 有了图片,列表、页头、邮件里全都自动换成图片,不用分别设置。 */
$curIcon = $esw ? (string)(isset($esw['icon']) ? $esw['icon'] : '') : '';
$curIconKey = $esw ? trim((string)(isset($esw['icon_key']) ? $esw['icon_key'] : '')) : '';
$curIconUrl = $curIconKey !== '' ? fb_icon_url($curIconKey, 96) : '';
echo ' <div class="field"><span>图标图片<i class="hint">上传一张图(PNG/JPG/WEBP,≤2MB,方形最佳)。传了图片就优先用图片,不再用下面的字符</i></span>';
echo ' <div class="icon-up">';
echo ' <div class="icon-prev">';
if ($curIconUrl !== '') {
echo '<img src="' . e($curIconUrl) . '" alt="" width="48" height="48">';
} else {
list($pc) = $esw ? fb_software_colors($esw) : ['#0057B8'];
echo '<span class="icon-prev-ph" style="background:' . e($pc) . '">'
. e(fb_software_icon($esw ? $esw : ['name' => ''])) . '</span>';
}
echo ' </div>';
echo ' <div class="icon-up-main">';
echo ' <input type="file" name="icon_file" accept="image/png,image/jpeg,image/webp" id="iconFile' . ($esw ? (int)$esw['id'] : 'new') . '">';
echo ' <i class="hint" style="margin-top:4px">建议 256×256 或 512×512 的方形图;带透明背景的 PNG 效果最好。</i>';
if ($curIconUrl !== '') {
echo ' <label class="check" style="margin-top:6px"><input type="checkbox" name="icon_clear" value="1"> 移除当前图片,改用下面的字符图标</label>';
}
echo ' </div>';
echo ' </div>';
echo ' </div>';
// 字符兜底:没有图片时用它
$iconSug = ['📅', '🔐', '🔑', '📝', '🎵', '📷', '🌤', '🧭', '📦', '⚙️', '🗂', '💬'];
echo ' <label class="field"><span>字符图标<i class="hint">没上传图片时用它,一个字符:emoji 或汉字/字母</i></span>'
. '<input type="text" name="icon" maxlength="8" value="' . e($curIcon) . '" placeholder="例如 📅">'
. '<span class="icon-presets">';
foreach ($iconSug as $ic) {
echo '<button type="button" class="icon-preset" data-icon="' . e($ic) . '">' . e($ic) . '</button>';
}
echo '</span></label>';
// 下载地址:状态改成「已解决」时,邮件里会给用户一个去下新版的按钮
echo ' <label class="field"><span>下载地址<i class="hint">可留空。填了之后「已解决」的通知邮件里会多一个「下载最新版」按钮</i></span>'
. '<input type="url" name="download_url" maxlength="300" value="'
. e($esw ? (string)(isset($esw['download_url']) ? $esw['download_url'] : '') : '')
. '" placeholder="https://..."></label>';
// 主题色:前台按软件换色,用短链接进不同软件一眼就能区分
$curColor = $esw ? fb_color_norm((string)$esw['color']) : '';
$sugColor = $curColor !== '' ? $curColor : fb_color_default_for($esw ? (int)$esw['id'] : 0);
/* 🔴 必须用 fb_color_palette(),不要再在这里写死一份数组。
* 以前这里硬编码了另外 8 个色值,和 util.php 的调色板完全不同步 ——
* 结果后台只能选那 8 个,用户在后台挑不到新颜色,
* 而他从没配置过的软件却会被分到「调色板里另一批」颜色。 */
$presets = fb_color_palette();
echo ' <div class="field"><span>主题色<i class="hint">前台进这个软件时整体配色跟着变,用来区分不同软件</i></span>';
echo ' <div class="color-row">';
echo ' <input type="color" name="color" value="' . e($sugColor) . '" class="color-input">';
echo ' <div class="color-presets">';
foreach ($presets as $pc) {
$on = strcasecmp($pc, $sugColor) === 0 ? ' on' : '';
echo '<button type="button" class="color-preset' . $on . '" data-color="' . e($pc)
. '" style="background:' . e($pc) . '" title="' . e($pc) . '"></button>';
}
echo ' </div>';
echo ' <button type="button" class="btn sm color-reset">用默认</button>';
echo ' </div>';
echo ' </div>';
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" data-validate>';
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" data-validate>';
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>';
}