2026-11-01 12:03:11 +08:00
|
|
|
|
<?php
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 后台:软件 / 分类 / 状态 管理
|
|
|
|
|
|
* 全部存数据库,管理员在这里增删改排序启停,不用碰配置文件。
|
|
|
|
|
|
*/
|
|
|
|
|
|
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
|
|
|
|
|
|
|
2026-11-03 08:13:21 +08:00
|
|
|
|
/* ==================================================================
|
|
|
|
|
|
* 软件图标图片:上传 / 删除
|
|
|
|
|
|
* ------------------------------------------------------------------
|
|
|
|
|
|
* 安全边界(三条,缺一不可):
|
|
|
|
|
|
* ① 只有管理员能进(这个函数只从后台登录后的动作分支调用);
|
|
|
|
|
|
* ② 落盘前用 finfo 读真实文件头,必须是图片白名单里的类型 ——
|
|
|
|
|
|
* 与附件上传同一个教训:把 PHP 改名成 .png 就能写进目录,
|
|
|
|
|
|
* .htaccess 是唯一防线,换 nginx 就失守;
|
|
|
|
|
|
* ③ 键空间独立为 icons/,且形状受限(无穿越、无自由路径)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 存储方式跟附件完全一致:本机 → uploads/;OSS → 服务器代理 PUT 到私有桶。
|
|
|
|
|
|
* 对外一律 media.php?k=... 代理,不暴露真实位置。
|
|
|
|
|
|
* ================================================================== */
|
|
|
|
|
|
|
2026-11-03 08:19:17 +08:00
|
|
|
|
/** 图标允许的扩展名 —— 定义在 lib/attach.php(fb_icon_allowed_ext),
|
|
|
|
|
|
* 因为 media.php 与 CLI 场景也会用到,不能依赖这个后台文件被加载。 */
|
2026-11-03 08:13:21 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 保存一个上传的图标文件,返回 [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);
|
|
|
|
|
|
|
2026-11-03 08:19:17 +08:00
|
|
|
|
/* 断言:键的前缀必须与当前存储方式一致。
|
|
|
|
|
|
* 这两者一旦脱钩,就会出现「文件写进 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 模式下生成了本机路径的图标键。', ''];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-03 08:13:21 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-01 12:03:11 +08:00
|
|
|
|
/* ==================================================================
|
|
|
|
|
|
* 统一的表单处理入口(由 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']; }
|
2026-11-01 15:21:27 +08:00
|
|
|
|
// 留空就自动生成。中文名生成不出 ASCII 时退化成 sw<序号>,不打扰用户。
|
|
|
|
|
|
if ($slug === '') {
|
|
|
|
|
|
$next = (int)$pdo->query('SELECT COALESCE(MAX(id),0)+1 FROM software')->fetchColumn();
|
|
|
|
|
|
$slug = fb_sw_slugify($name, 'sw', $next);
|
|
|
|
|
|
}
|
2026-11-01 12:03:11 +08:00
|
|
|
|
if (fb_software($slug)) { return ['英文标识「' . $slug . '」已被占用,换一个。', 'error']; }
|
|
|
|
|
|
$sort = (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM software')->fetchColumn();
|
2026-11-01 16:39:09 +08:00
|
|
|
|
$color = fb_color_norm(isset($post['color']) ? $post['color'] : '');
|
2026-11-03 08:00:50 +08:00
|
|
|
|
$icon = fb_sw_icon_norm(isset($post['icon']) ? $post['icon'] : '');
|
|
|
|
|
|
$dl = fb_sw_url_norm(isset($post['download_url']) ? $post['download_url'] : '');
|
2026-11-03 08:13:21 +08:00
|
|
|
|
|
|
|
|
|
|
// 新建时也可以直接带上图标图片(先入库,再更新 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]);
|
2026-11-01 12:03:11 +08:00
|
|
|
|
$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']; }
|
2026-11-01 15:21:27 +08:00
|
|
|
|
// 编辑时留空 = 保持原标识,避免改名把已有网址打散
|
|
|
|
|
|
if ($slug === '') { $slug = (string)$sw['slug']; }
|
2026-11-01 12:03:11 +08:00
|
|
|
|
$other = fb_software($slug);
|
|
|
|
|
|
if ($other && (int)$other['id'] !== $id) { return ['英文标识「' . $slug . '」已被其他软件占用。', 'error']; }
|
2026-11-01 16:39:09 +08:00
|
|
|
|
$color = fb_color_norm(isset($post['color']) ? $post['color'] : '');
|
2026-11-03 08:00:50 +08:00
|
|
|
|
$icon = fb_sw_icon_norm(isset($post['icon']) ? $post['icon'] : '');
|
|
|
|
|
|
$dl = fb_sw_url_norm(isset($post['download_url']) ? $post['download_url'] : '');
|
2026-11-03 08:13:21 +08:00
|
|
|
|
|
|
|
|
|
|
/* ---- 图标图片 ----
|
|
|
|
|
|
* 三种情况:
|
|
|
|
|
|
* ① 上传了新图 → 换掉旧图(旧实体删掉,不留孤儿文件)
|
|
|
|
|
|
* ② 勾了「移除图标」→ 删掉图片,退回字符图标
|
|
|
|
|
|
* ③ 都没动 → 保持原样(不能因为没传文件就当成清空,
|
|
|
|
|
|
* 这与「配置被跨页清空」是同一类错误) */
|
|
|
|
|
|
$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,
|
2026-11-01 12:03:11 +08:00
|
|
|
|
(int)(isset($post['sort_order']) ? $post['sort_order'] : $sw['sort_order']), $id]);
|
2026-11-03 08:13:21 +08:00
|
|
|
|
|
|
|
|
|
|
$extraMsg = '';
|
|
|
|
|
|
if ($hasNewFile) { $extraMsg = ',图标已更新'; }
|
|
|
|
|
|
elseif (!empty($post['icon_clear']) && $oldIconKey !== '') { $extraMsg = ',图标已移除'; }
|
|
|
|
|
|
|
|
|
|
|
|
// 旧图删不掉要如实说 —— 悄悄留下一个还能访问的孤儿图是安全隐患
|
|
|
|
|
|
if ($delFailed !== '') {
|
|
|
|
|
|
return ['软件「' . $name . '」已保存' . $extraMsg . ',但旧图标文件删除失败('
|
|
|
|
|
|
. $delFailed . '),它可能仍可被访问,建议手动到存储里删掉。', 'error'];
|
|
|
|
|
|
}
|
|
|
|
|
|
return ['软件「' . $name . '」已保存' . $extraMsg . '。', 'ok'];
|
2026-11-01 12:03:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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]);
|
2026-11-03 08:13:21 +08:00
|
|
|
|
// 顺手删掉它的图标文件,避免 icons/ 下堆孤儿图
|
|
|
|
|
|
if (isset($sw['icon_key'])) { fb_icon_delete((string)$sw['icon_key']); }
|
2026-11-01 12:03:11 +08:00
|
|
|
|
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']; }
|
2026-11-01 15:21:27 +08:00
|
|
|
|
// 留空自动生成;中文名退化成 cat<序号>(在本软件内取号,保证不冲突)
|
|
|
|
|
|
if ($slug === '') {
|
|
|
|
|
|
$next = (int)$pdo->query('SELECT COALESCE(MAX(id),0)+1 FROM categories')->fetchColumn();
|
|
|
|
|
|
$slug = fb_sw_slugify($name, 'cat', $next);
|
|
|
|
|
|
}
|
2026-11-01 12:03:11 +08:00
|
|
|
|
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']; }
|
2026-11-01 15:21:27 +08:00
|
|
|
|
// 编辑时留空 = 保持原标识,避免改名把已有链接打散
|
|
|
|
|
|
if ($slug === '') { $slug = (string)$cat['slug']; }
|
2026-11-01 12:03:11 +08:00
|
|
|
|
$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">';
|
2026-11-01 16:39:09 +08:00
|
|
|
|
list($swC, $swCd, $swCs) = fb_software_colors($sw);
|
|
|
|
|
|
echo ' <span class="sw-dot" style="background:' . e($swC) . '" title="' . e($swC) . '"></span>';
|
2026-11-03 08:00:50 +08:00
|
|
|
|
echo ' <a class="tax-name" href="admin.php?tab=taxonomy&sw=' . $sid . '">'
|
|
|
|
|
|
. fb_software_badge($sw, 'sm') . '</a>';
|
2026-11-01 12:03:11 +08:00
|
|
|
|
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>';
|
2026-11-03 08:13:21 +08:00
|
|
|
|
echo '<form method="post" action="admin.php?tab=taxonomy" class="card-form sm-form" enctype="multipart/form-data" data-validate>';
|
2026-11-01 12:03:11 +08:00
|
|
|
|
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>';
|
2026-11-01 16:39:09 +08:00
|
|
|
|
|
2026-11-03 08:13:21 +08:00
|
|
|
|
/* ---- 图标 ----
|
|
|
|
|
|
* 两种方式并存,图片优先:
|
|
|
|
|
|
* ① 上传一张图片(存 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">';
|
2026-11-04 19:17:20 +08:00
|
|
|
|
/* 文件选择按钮重做成真按钮(用户:原生那个太丑)。
|
|
|
|
|
|
* <label class="file-btn"> 包住 input[type=file],点 label 就等于点 input;
|
|
|
|
|
|
* input 用 clip 视觉隐藏但仍可聚焦(不能用 display:none,那样点不到)。 */
|
|
|
|
|
|
$ficonFileId = 'iconFile' . ($esw ? (int)$esw['id'] : 'new');
|
|
|
|
|
|
echo ' <label class="file-btn" for="' . $ficonFileId . '">'
|
|
|
|
|
|
. '<span class="file-btn-ic" aria-hidden="true"></span>'
|
|
|
|
|
|
. '<span class="file-btn-txt">选择图片</span>'
|
|
|
|
|
|
. '<span class="file-btn-name" data-file-name></span>'
|
|
|
|
|
|
. '<input type="file" name="icon_file" accept="image/png,image/jpeg,image/webp" id="' . $ficonFileId . '">'
|
|
|
|
|
|
. '</label>';
|
|
|
|
|
|
echo ' <i class="hint" style="margin-top:6px">建议 256×256 或 512×512 的方形图;带透明背景的 PNG 效果最好。</i>';
|
2026-11-03 08:13:21 +08:00
|
|
|
|
if ($curIconUrl !== '') {
|
2026-11-04 19:17:20 +08:00
|
|
|
|
echo ' <label class="check" style="margin-top:8px"><input type="checkbox" name="icon_clear" value="1"> 移除当前图片,改用下面的字符图标</label>';
|
2026-11-03 08:13:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
echo ' </div>';
|
|
|
|
|
|
echo ' </div>';
|
|
|
|
|
|
echo ' </div>';
|
|
|
|
|
|
|
|
|
|
|
|
// 字符兜底:没有图片时用它
|
2026-11-03 08:00:50 +08:00
|
|
|
|
$iconSug = ['📅', '🔐', '🔑', '📝', '🎵', '📷', '🌤', '🧭', '📦', '⚙️', '🗂', '💬'];
|
2026-11-03 08:13:21 +08:00
|
|
|
|
echo ' <label class="field"><span>字符图标<i class="hint">没上传图片时用它,一个字符:emoji 或汉字/字母</i></span>'
|
2026-11-03 08:00:50 +08:00
|
|
|
|
. '<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>';
|
|
|
|
|
|
|
2026-11-04 19:17:20 +08:00
|
|
|
|
/* ── 「下载地址」字段已删除 ──
|
|
|
|
|
|
* 用户 2026-11-04:「有个什么下载链接,这个东西不要,要它干什么?」
|
|
|
|
|
|
*
|
|
|
|
|
|
* 它原本的唯一用途:当反馈状态改成「已解决」时,把通知邮件里的按钮
|
|
|
|
|
|
* 从「查看详情」换成「下载最新版本」。属于可有可无的小功能,
|
|
|
|
|
|
* 却要求管理员为每个软件再维护一条 URL —— 收益远小于维护成本。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 数据库的 download_url 列**保留不删**:
|
|
|
|
|
|
* ① 删列会让历史数据不可逆丢失;
|
|
|
|
|
|
* ② 万一以后想恢复,不用再走一次迁移。
|
|
|
|
|
|
* 只是前台表单不再暴露,mailer 里也已改为始终用「查看详情」。 */
|
2026-11-03 08:00:50 +08:00
|
|
|
|
|
2026-11-01 16:39:09 +08:00
|
|
|
|
// 主题色:前台按软件换色,用短链接进不同软件一眼就能区分
|
|
|
|
|
|
$curColor = $esw ? fb_color_norm((string)$esw['color']) : '';
|
|
|
|
|
|
$sugColor = $curColor !== '' ? $curColor : fb_color_default_for($esw ? (int)$esw['id'] : 0);
|
2026-11-04 18:57:18 +08:00
|
|
|
|
/* 颜色选择:**点色块弹出一整块色板**。
|
|
|
|
|
|
* 用户 2026-11-04:「还是不对呀,需要一个完整的色板,右侧那些默认颜色就不要了。」
|
|
|
|
|
|
* ⇒ ① 右侧那排预设圆点删除,界面更干净,能力靠色板本身(任意取色)覆盖;
|
|
|
|
|
|
* ② 色板要"完整":足够大的色相/饱和度画布 + 色相条 + 明度条 +
|
|
|
|
|
|
* 十六进制输入。太小会让人觉得是"玩具",拖着不好定位颜色。
|
|
|
|
|
|
* ⚠️ 不再需要 $presets:色板本身就能取任意颜色,
|
|
|
|
|
|
* 留着那排小圆点反而和色板重复、还占横向空间(用户明确要求去掉)。 */
|
2026-11-01 16:39:09 +08:00
|
|
|
|
echo ' <div class="field"><span>主题色<i class="hint">前台进这个软件时整体配色跟着变,用来区分不同软件</i></span>';
|
2026-11-04 18:52:45 +08:00
|
|
|
|
echo ' <div class="color-row" data-colorpicker>';
|
|
|
|
|
|
echo ' <button type="button" class="color-trigger" data-color-trigger'
|
|
|
|
|
|
. ' style="--cur:' . e($sugColor) . '" title="点击选择颜色">'
|
|
|
|
|
|
. ' <span class="color-trigger-chip"></span>'
|
|
|
|
|
|
. ' <span class="color-trigger-hex">' . e(strtoupper($sugColor)) . '</span>'
|
2026-11-04 18:57:18 +08:00
|
|
|
|
. ' <span class="color-trigger-caret">▾</span>'
|
2026-11-04 18:52:45 +08:00
|
|
|
|
. ' </button>';
|
|
|
|
|
|
echo ' <input type="hidden" name="color" value="' . e($sugColor) . '" data-color-value>';
|
2026-11-01 16:39:09 +08:00
|
|
|
|
echo ' <button type="button" class="btn sm color-reset">用默认</button>';
|
2026-11-04 18:52:45 +08:00
|
|
|
|
|
2026-11-04 18:57:18 +08:00
|
|
|
|
/* 色板:默认隐藏,点色块才出现。 */
|
2026-11-04 18:52:45 +08:00
|
|
|
|
echo ' <div class="cp" data-color-panel hidden>';
|
2026-11-04 18:57:18 +08:00
|
|
|
|
echo ' <div class="cp-hd">选择颜色</div>';
|
|
|
|
|
|
// 画布做大:340×200,拖起来才好定位
|
|
|
|
|
|
echo ' <div class="cp-sv-wrap">';
|
|
|
|
|
|
echo ' <canvas class="cp-sv" width="340" height="200" data-cp-sv></canvas>';
|
|
|
|
|
|
echo ' <div class="cp-cursor" data-cp-cursor></div>';
|
|
|
|
|
|
echo ' </div>';
|
|
|
|
|
|
// 色相条
|
|
|
|
|
|
echo ' <div class="cp-slider cp-hue"><input type="range" min="0" max="360" value="0" data-cp-hue aria-label="色相"></div>';
|
|
|
|
|
|
// 明度条:背景由 JS 按当前色相刷新
|
|
|
|
|
|
echo ' <div class="cp-slider cp-val"><input type="range" min="0" max="100" value="100" data-cp-val aria-label="明度"></div>';
|
2026-11-04 18:52:45 +08:00
|
|
|
|
echo ' <div class="cp-foot">';
|
|
|
|
|
|
echo ' <span class="cp-preview" data-cp-prev></span>';
|
|
|
|
|
|
echo ' <input type="text" class="cp-hex" maxlength="7" value="' . e(strtoupper($sugColor)) . '" data-cp-hex aria-label="十六进制颜色值">';
|
2026-11-04 18:57:18 +08:00
|
|
|
|
echo ' <button type="button" class="btn sm cp-ok" data-cp-ok>完成</button>';
|
2026-11-04 18:52:45 +08:00
|
|
|
|
echo ' </div>';
|
|
|
|
|
|
echo ' </div>';
|
2026-11-01 16:39:09 +08:00
|
|
|
|
echo ' </div>';
|
|
|
|
|
|
echo ' </div>';
|
2026-11-01 12:03:11 +08:00
|
|
|
|
if ($esw) {
|
2026-11-04 19:17:20 +08:00
|
|
|
|
/* 排序是 1-2 位的小整数,占满整行很难看 —— 收窄成定宽。
|
|
|
|
|
|
* 用 .field-narrow 限制宽度,而不是让它跟着 .field 铺满。 */
|
|
|
|
|
|
echo ' <label class="field field-narrow"><span>排序<i class="hint">数字小的排前面,同级按名称排</i></span>'
|
|
|
|
|
|
. '<input type="number" name="sort_order" min="0" max="999" value="' . (int)$esw['sort_order'] . '"></label>';
|
2026-11-01 12:03:11 +08:00
|
|
|
|
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>';
|
2026-11-01 15:21:27 +08:00
|
|
|
|
echo '<form method="post" action="admin.php?tab=taxonomy&sw=' . (int)$curSw['id'] . '" class="card-form sm-form" data-validate>';
|
2026-11-01 12:03:11 +08:00
|
|
|
|
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>';
|
2026-11-01 15:21:27 +08:00
|
|
|
|
echo '<form method="post" action="admin.php?tab=statuses" class="card-form sm-form" data-validate>';
|
2026-11-01 12:03:11 +08:00
|
|
|
|
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>';
|
|
|
|
|
|
}
|