-
+
#
diff --git a/lib/db.php b/lib/db.php
index 0d4b1f3..a524f49 100644
--- a/lib/db.php
+++ b/lib/db.php
@@ -199,4 +199,150 @@ SQL
$pdo->exec('ALTER TABLE threads ADD COLUMN moderated_at INTEGER');
}
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_threads_visible ON threads(visible, created_at DESC)');
+
+ fb_db_migrate_taxonomy($pdo);
+}
+
+/**
+ * 软件 / 分类 / 状态 三张表(全部可在后台配置,不再写死在配置文件里)
+ *
+ * 结构:软件 → 分类(两级)。分类属于某个软件,不同软件可以有同名但互不相干的分类。
+ * 幂等:每次请求都会跑,靠 IF NOT EXISTS 与「空表才灌初始数据」保证重复执行结果一致。
+ */
+function fb_db_migrate_taxonomy(PDO $pdo, $cfg = null) {
+ /* ---- 软件 ---- */
+ $pdo->exec(<<<'SQL'
+CREATE TABLE IF NOT EXISTS software (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ slug TEXT NOT NULL,
+ name TEXT NOT NULL,
+ note TEXT,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ created_at INTEGER NOT NULL
+);
+SQL
+ );
+ $pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_sw_slug ON software(slug)');
+
+ /* ---- 分类(属于某个软件)---- */
+ $pdo->exec(<<<'SQL'
+CREATE TABLE IF NOT EXISTS categories (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ software_id INTEGER NOT NULL,
+ slug TEXT NOT NULL,
+ name TEXT NOT NULL,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ created_at INTEGER NOT NULL,
+ FOREIGN KEY(software_id) REFERENCES software(id) ON DELETE CASCADE
+);
+SQL
+ );
+ $pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_cat_sw_slug ON categories(software_id, slug)');
+
+ /* ---- 状态 ---- */
+ // key_name 是 threads.status 里存的值;user_note 是发给用户看的解释(进邮件正文)
+ $pdo->exec(<<<'SQL'
+CREATE TABLE IF NOT EXISTS statuses (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ key_name TEXT NOT NULL,
+ name TEXT NOT NULL,
+ user_note TEXT,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ enabled INTEGER NOT NULL DEFAULT 1
+);
+SQL
+ );
+ $pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_st_key ON statuses(key_name)');
+
+ /* ---- threads 增加两列(老 category 文本列保留,不删)---- */
+ $cols = $pdo->query('PRAGMA table_info(threads)')->fetchAll();
+ $names = [];
+ foreach ($cols as $c) { if (isset($c['name'])) { $names[(string)$c['name']] = true; } }
+ if (!isset($names['software_id'])) {
+ // 不用 REFERENCES:SQLite 加列时外键约束对既有行不友好,改由应用层保证
+ $pdo->exec('ALTER TABLE threads ADD COLUMN software_id INTEGER');
+ }
+ if (!isset($names['category_id'])) {
+ $pdo->exec('ALTER TABLE threads ADD COLUMN category_id INTEGER');
+ }
+ $pdo->exec('CREATE INDEX IF NOT EXISTS idx_threads_sw ON threads(software_id)');
+ $pdo->exec('CREATE INDEX IF NOT EXISTS idx_threads_cid ON threads(category_id)');
+
+ /* ================= 初始数据(空表才灌,幂等) ================= */
+
+ $now = time();
+ $swId = 0;
+
+ // 1) 软件:空表时建一个默认软件
+ if ((int)$pdo->query('SELECT COUNT(*) FROM software')->fetchColumn() === 0) {
+ $siteName = '';
+ try { $siteName = (string)fb_cfg('site_name', ''); } catch (\Throwable $e) { $siteName = ''; }
+ if (trim($siteName) === '') { $siteName = '我的软件'; }
+ $st = $pdo->prepare('INSERT INTO software(slug, name, note, sort_order, enabled, created_at)
+ VALUES(?,?,?,?,1,?)');
+ $st->execute(['default', $siteName, '默认软件,可在后台改名', 0, $now]);
+ $swId = (int)$pdo->lastInsertId();
+ } else {
+ $swId = (int)$pdo->query('SELECT id FROM software ORDER BY sort_order, id LIMIT 1')->fetchColumn();
+ }
+
+ // 2) 分类:空表时把配置文件里的老 categories 原样搬进来(保住既有五个分类)
+ if ((int)$pdo->query('SELECT COUNT(*) FROM categories')->fetchColumn() === 0 && $swId > 0) {
+ $old = [];
+ try { $old = (array)fb_cfg('categories', []); } catch (\Throwable $e) { $old = []; }
+ if (!$old) {
+ $old = ['usage' => '使用问题', 'bug' => 'Bug', 'feature' => '功能建议', 'other' => '其他'];
+ }
+ $i = 0;
+ $st = $pdo->prepare('INSERT INTO categories(software_id, slug, name, sort_order, enabled, created_at)
+ VALUES(?,?,?,?,1,?)');
+ foreach ($old as $k => $label) {
+ $slug = fb_sw_slugify($k);
+ if ($slug === '') { $slug = 'c' . $i; }
+ $st->execute([$swId, $slug, (string)$label, $i, $now]);
+ $i++;
+ }
+ }
+
+ // 3) 状态:空表时灌 5 个(4 个原有 + 新增「不予考虑」)
+ if ((int)$pdo->query('SELECT COUNT(*) FROM statuses')->fetchColumn() === 0) {
+ $rows = [
+ ['open', '待处理', '您提交的反馈已收到,我们正在处理中。', 0],
+ ['doing', '处理中', '我们正在跟进这个问题,有进展会在这里同步。', 1],
+ ['resolved', '已解决', '这个问题已经处理好了,欢迎回来验证一下。', 2],
+ ['closed', '已关闭', '这个反馈已关闭,如有新情况可以重新提交。', 3],
+ ['wontfix', '不予考虑', '经评估这个反馈暂不处理,感谢你的建议。', 4],
+ ];
+ $st = $pdo->prepare('INSERT INTO statuses(key_name, name, user_note, sort_order, enabled)
+ VALUES(?,?,?,?,1)');
+ foreach ($rows as $r) { $st->execute($r); }
+ }
+
+ // 4) 回填老帖子:software_id / category_id(只填 NULL 的,重复执行无副作用)
+ if ($swId > 0) {
+ $pdo->prepare('UPDATE threads SET software_id = ? WHERE software_id IS NULL')->execute([$swId]);
+ }
+ $need = $pdo->prepare('SELECT id, category FROM threads WHERE category_id IS NULL AND software_id = ?');
+ $need->execute([$swId]);
+ $rows = $need->fetchAll();
+ if ($rows) {
+ $find = $pdo->prepare('SELECT id FROM categories WHERE software_id = ? AND slug = ? LIMIT 1');
+ $set = $pdo->prepare('UPDATE threads SET category_id = ? WHERE id = ?');
+ foreach ($rows as $r) {
+ $slug = fb_sw_slugify((string)$r['category']);
+ $find->execute([$swId, $slug]);
+ $cid = $find->fetchColumn();
+ if ($cid !== false && $cid !== null) { $set->execute([(int)$cid, (int)$r['id']]); }
+ }
+ }
+}
+
+/** 生成 URL 友好的 slug(只留小写字母数字和连字符) */
+function fb_sw_slugify($s) {
+ $s = strtolower(trim((string)$s));
+ $s = preg_replace('/[^a-z0-9_-]+/', '-', $s);
+ $s = trim((string)$s, '-');
+ return $s === '' ? '' : substr($s, 0, 40);
}
diff --git a/lib/layout.php b/lib/layout.php
index b7542c4..dce8315 100644
--- a/lib/layout.php
+++ b/lib/layout.php
@@ -27,7 +27,7 @@ function fb_layout_head($title, array $opt = []) {
-
+
diff --git a/lib/util.php b/lib/util.php
index 4d09d50..13d6c5c 100644
--- a/lib/util.php
+++ b/lib/util.php
@@ -58,16 +58,121 @@ function fb_redirect($url) {
exit;
}
-/** 分类 / 状态 */
-function fb_categories() { return (array)fb_cfg('categories', []); }
-function fb_category_label($k) {
- $c = fb_categories();
- return isset($c[$k]) ? $c[$k] : '其他';
+/** ================= 软件 / 分类 / 状态 =================
+ * 三者全部存数据库、后台可配,不再读配置文件。
+ * 结构:软件 → 分类(两级)。分类属于某个软件,不同软件可有同名分类。 */
+
+/** 全部软件(按排序),$onlyEnabled=true 时只取启用的 */
+function fb_softwares($onlyEnabled = true) {
+ $sql = 'SELECT * FROM software';
+ if ($onlyEnabled) { $sql .= ' WHERE enabled = 1'; }
+ $sql .= ' ORDER BY sort_order, id';
+ $rows = fb_db()->query($sql)->fetchAll();
+ $out = [];
+ foreach ($rows as $r) { $out[] = $r; }
+ return $out;
}
-function fb_statuses() { return (array)fb_cfg('statuses', []); }
+
+/** 按 slug 或 id 取一个软件;取不到返回 null */
+function fb_software($key) {
+ if ($key === null || $key === '') { return null; }
+ $pdo = fb_db();
+ if (ctype_digit((string)$key)) {
+ $st = $pdo->prepare('SELECT * FROM software WHERE id = ? LIMIT 1');
+ $st->execute([(int)$key]);
+ } else {
+ $st = $pdo->prepare('SELECT * FROM software WHERE slug = ? LIMIT 1');
+ $st->execute([(string)$key]);
+ }
+ $r = $st->fetch();
+ return $r ? $r : null;
+}
+
+/** 默认软件:排序最前的启用软件;一个都没有时返回 null */
+function fb_default_software() {
+ $list = fb_softwares(true);
+ return $list ? $list[0] : null;
+}
+
+/** 某软件下的分类;不传软件则返回空数组 */
+function fb_categories($softwareId = null, $onlyEnabled = true) {
+ if ($softwareId === null) { return []; }
+ $sql = 'SELECT * FROM categories WHERE software_id = ?';
+ $args = [(int)$softwareId];
+ if ($onlyEnabled) { $sql .= ' AND enabled = 1'; }
+ $sql .= ' ORDER BY sort_order, id';
+ $st = fb_db()->prepare($sql);
+ $st->execute($args);
+ return $st->fetchAll();
+}
+
+/** 按 id 取分类(含禁用的,后台编辑用) */
+function fb_category($id) {
+ if (!$id) { return null; }
+ $st = fb_db()->prepare('SELECT * FROM categories WHERE id = ? LIMIT 1');
+ $st->execute([(int)$id]);
+ $r = $st->fetch();
+ return $r ? $r : null;
+}
+
+/** 在某软件内按 slug 取分类 */
+function fb_category_by_slug($softwareId, $slug) {
+ if (!$softwareId || $slug === '') { return null; }
+ $st = fb_db()->prepare('SELECT * FROM categories WHERE software_id = ? AND slug = ? LIMIT 1');
+ $st->execute([(int)$softwareId, (string)$slug]);
+ $r = $st->fetch();
+ return $r ? $r : null;
+}
+
+/** 分类显示名:只给 id 就能拿到名字(列表渲染用) */
+function fb_category_label($id) {
+ if (!$id) { return ''; }
+ static $cache = [];
+ $id = (int)$id;
+ if (!isset($cache[$id])) {
+ $c = fb_category($id);
+ $cache[$id] = $c ? (string)$c['name'] : '';
+ }
+ return $cache[$id];
+}
+
+/** 全部状态(按排序) */
+function fb_statuses($onlyEnabled = true) {
+ $sql = 'SELECT * FROM statuses';
+ if ($onlyEnabled) { $sql .= ' WHERE enabled = 1'; }
+ $sql .= ' ORDER BY sort_order, id';
+ return fb_db()->query($sql)->fetchAll();
+}
+
+/** 状态 key 列表(校验用) */
+function fb_status_keys() {
+ $out = [];
+ foreach (fb_statuses(true) as $s) { $out[(string)$s['key_name']] = (string)$s['name']; }
+ return $out;
+}
+
+/** 状态显示名 */
function fb_status_label($k) {
- $s = fb_statuses();
- return isset($s[$k]) ? $s[$k] : (string)$k;
+ static $cache = null;
+ if ($cache === null) {
+ $cache = [];
+ foreach (fb_statuses(false) as $s) { $cache[(string)$s['key_name']] = (string)$s['name']; }
+ }
+ $k = (string)$k;
+ return isset($cache[$k]) ? $cache[$k] : $k;
+}
+
+/** 状态的「给用户看的话」——进邮件正文,后台可改 */
+function fb_status_user_note($k) {
+ static $cache = null;
+ if ($cache === null) {
+ $cache = [];
+ foreach (fb_statuses(false) as $s) {
+ $cache[(string)$s['key_name']] = (string)($s['user_note'] === null ? '' : $s['user_note']);
+ }
+ }
+ $k = (string)$k;
+ return isset($cache[$k]) ? $cache[$k] : '';
}
/** CSRF */
diff --git a/new.php b/new.php
index 24e923b..14568f9 100644
--- a/new.php
+++ b/new.php
@@ -4,13 +4,27 @@
*/
require __DIR__ . '/lib/bootstrap.php';
-$pdo = fb_db();
-$cats = fb_categories();
-$err = '';
-$atts = []; // 校验失败时保留已上传的附件
-$catKeys = array_keys($cats);
-$form = [
- 'category' => $catKeys ? (string)$catKeys[0] : 'other',
+$pdo = fb_db();
+$err = '';
+$atts = []; // 校验失败时保留已上传的附件
+
+/* ---- 软件:URL 带 sw 就用它,否则落到默认软件 ---- */
+$swList = fb_softwares(true);
+$sw = null;
+$swKey = isset($_GET['sw']) ? (string)$_GET['sw'] : '';
+if ($swKey !== '') {
+ $sw = fb_software($swKey);
+ if (!$sw || (int)$sw['enabled'] !== 1) { $sw = null; }
+}
+if ($sw === null && $swList) { $sw = $swList[0]; }
+$swId = $sw ? (int)$sw['id'] : 0;
+
+$cats = fb_categories($swId);
+$catKeys = [];
+foreach ($cats as $c) { $catKeys[] = (int)$c['id']; }
+
+$form = [
+ 'category_id' => $catKeys ? (string)$catKeys[0] : '',
'title' => '',
'body' => '',
'name' => '',
@@ -19,16 +33,27 @@ $form = [
];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
- foreach (['category', 'title', 'body', 'name', 'email', 'phone'] as $k) {
+ foreach (['category_id', 'title', 'body', 'name', 'email', 'phone'] as $k) {
$form[$k] = trim(isset($_POST[$k]) ? (string)$_POST[$k] : '');
}
+ // POST 里带了 software_id 时以它为准(切换软件后表单会跟着变)
+ if (isset($_POST['software_id']) && ctype_digit((string)$_POST['software_id'])) {
+ $swPost = fb_software((int)$_POST['software_id']);
+ if ($swPost && (int)$swPost['enabled'] === 1) {
+ $sw = $swPost;
+ $swId = (int)$swPost['id'];
+ }
+ }
$capInput = isset($_POST['captcha']) ? (string)$_POST['captcha'] : '';
$hp = trim(isset($_POST['website']) ? (string)$_POST['website'] : '');
$t0 = isset($_POST['t0']) ? (int)$_POST['t0'] : 0;
list($attOk, $attList, $attErr) = fb_attach_parse(isset($_POST['attachments']) ? $_POST['attachments'] : '');
- if (!isset($cats[$form['category']])) { $form['category'] = 'other'; }
+ // 分类必须属于这个软件,否则视为未选
+ $curCatId = ctype_digit($form['category_id']) ? (int)$form['category_id'] : 0;
+ $curCat = $curCatId ? fb_category($curCatId) : null;
+ if (!$curCat || (int)$curCat['software_id'] !== $swId) { $curCat = null; $form['category_id'] = ''; }
if (!fb_csrf_ok(isset($_POST['csrf']) ? $_POST['csrf'] : '')) {
$err = '会话已过期,请重新提交。';
@@ -66,9 +91,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$now = time();
// 审核:开启后新提交默认不公开(visible=0),管理员通过后才会出现在列表
$visible = fb_moderation_on() ? 0 : 1;
- $ins = $pdo->prepare('INSERT INTO threads(title, body, category, author_name, author_email, author_phone, status, visible, reply_count, created_at, updated_at, ip_hash) VALUES(?,?,?,?,?,?,?,?,0,?,?,?)');
+ $ins = $pdo->prepare('INSERT INTO threads(title, body, category, category_id, software_id, author_name, author_email, author_phone, status, visible, reply_count, created_at, updated_at, ip_hash) VALUES(?,?,?,?,?,?,?,?,?,?,0,?,?,?)');
$ins->execute([
- $form['title'], $form['body'], $form['category'],
+ $form['title'], $form['body'],
+ $curCat ? (string)$curCat['slug'] : 'other', // 老列留个值,便于回退与检索
+ $curCat ? (int)$curCat['id'] : null,
+ $swId > 0 ? $swId : null,
$form['name'], $form['email'],
$form['phone'] !== '' ? $form['phone'] : null,
'open', $visible, $now, $now, fb_ip_hash(),
@@ -99,14 +127,31 @@ fb_layout_head('我要反馈', ['robots' => 'noindex,follow']);
+
+
+
+
+
+
+
+
+