如下图一轮对话鹈鹕骑车SVG动画,别说让它写了,改别人的都改不了

下面是免费版账户简单几轮就能完成的"回收站"插件,原本计划回来再开发或维护一下插件,现在不能了😭



我把这个免费版账户写的代码分享出来吧,谁有兴趣可以参考继续打磨打磨:
<?php declare(strict_types=1); if (!defined('APP_ROOT')) exit; function recycle_bin_table(): string { return 'plugin_recycle_bin_items'; } function recycle_bin_requests_table(): string { return 'plugin_recycle_bin_requests'; } function recycle_bin_config(): array { $raw = plugin_config('recycle_bin', []); return [ 'capture_topics' => (int)($raw['capture_topics'] ?? 1) === 1, 'capture_replies' => (int)($raw['capture_replies'] ?? 1) === 1, 'auto_purge' => (int)($raw['auto_purge'] ?? 0) === 1, 'retention_days' => min(3650, max(1, (int)($raw['retention_days'] ?? 30))), 'purge_batch_limit' => min(1000, max(1, (int)($raw['purge_batch_limit'] ?? 100))), 'admin_tab' => (int)($raw['admin_tab'] ?? 1) === 1, 'sidebar_link' => (int)($raw['sidebar_link'] ?? 0) === 1, 'user_entry' => (int)($raw['user_entry'] ?? 1) === 1, 'notify_cascade_authors' => (int)($raw['notify_cascade_authors'] ?? 1) === 1, ]; } function recycle_bin_install(array $plugin): void { $t = app_db_types(); app_db_create_table(recycle_bin_table(), "id {$t['id']},batch_key {$t['key']} NOT NULL, item_type {$t['key']} NOT NULL, item_id {$t['uint']} NOT NULL, parent_id {$t['uint']} NOT NULL DEFAULT 0, owner_id {$t['uint']} NOT NULL DEFAULT 0, delete_source {$t['key']} NOT NULL DEFAULT 'user', delete_reason {$t['text']} NULL, payload {$t['text']} NOT NULL, deleted_by {$t['uint']} NOT NULL DEFAULT 0, deleted_at {$t['uint']} NOT NULL, status {$t['key']} NOT NULL DEFAULT 'archived', restored_at {$t['uint']} NOT NULL DEFAULT 0, UNIQUE(batch_key,item_type,item_id)" ); app_db_ensure_columns(recycle_bin_table(), [ 'owner_id' => $t['uint'] . ' NOT NULL DEFAULT 0', 'delete_source' => $t['key'] . " NOT NULL DEFAULT 'user'", 'delete_reason' => $t['text'] . ' NULL', 'restored_at' => $t['uint'] . ' NOT NULL DEFAULT 0', ]); app_db_create_index('plugin_recycle_bin_items_owner_status', recycle_bin_table() . '(owner_id,status,deleted_at)'); app_db_create_index('plugin_recycle_bin_items_batch', recycle_bin_table() . '(batch_key,status)'); app_db_create_index('plugin_recycle_bin_items_source', recycle_bin_table() . '(delete_source,status,deleted_at)'); app_db_create_table(recycle_bin_requests_table(), "id {$t['id']},archive_id {$t['uint']} NOT NULL,user_id {$t['uint']} NOT NULL,item_type {$t['key']} NOT NULL,proposed_payload {$t['text']} NOT NULL,message {$t['text']} NULL,status {$t['key']} NOT NULL DEFAULT 'pending',created_at {$t['uint']} NOT NULL,reviewed_at {$t['uint']} NOT NULL DEFAULT 0,reviewer_id {$t['uint']} NOT NULL DEFAULT 0,review_note {$t['text']} NULL,UNIQUE(archive_id,status)" ); app_db_create_index('plugin_recycle_bin_requests_user_status', recycle_bin_requests_table() . '(user_id,status,created_at)'); app_db_create_index('plugin_recycle_bin_requests_status', recycle_bin_requests_table() . '(status,created_at)'); // 兼容 v1.0.0:补齐 owner/source 后,把旧快照归属到原作者。 $rows = q('SELECT id,payload,deleted_by FROM ' . recycle_bin_table() . ' WHERE owner_id=0 LIMIT 1000')->fetchAll(); foreach ($rows as $row) { $payload = json_decode((string)($row['payload'] ?? ''), true); if (!is_array($payload)) continue; $owner_id = max(0, (int)($payload['user_id'] ?? 0)); $deleted_by = max(0, (int)($row['deleted_by'] ?? 0)); $source = ($owner_id > 0 && $deleted_by > 0 && $owner_id === $deleted_by) ? 'user' : 'admin'; q('UPDATE ' . recycle_bin_table() . ' SET owner_id=?,delete_source=? WHERE id=?', [$owner_id, $source, (int)$row['id']]); } } function recycle_bin_uninstall(array $plugin, bool $keep_data = true): void { if ($keep_data) return; app_db_drop_table(recycle_bin_requests_table()); app_db_drop_table(recycle_bin_table()); } function recycle_bin_batch_key(): string { if (!isset($GLOBALS['__recycle_bin_batch_key'])) $GLOBALS['__recycle_bin_batch_key'] = bin2hex(random_bytes(16)); return (string)$GLOBALS['__recycle_bin_batch_key']; } function recycle_bin_archive_row(string $type, array $row, bool $cascade = false): void { $config = recycle_bin_config(); if ($type === 'topics' && !$config['capture_topics']) return; if ($type === 'replies' && !$config['capture_replies']) return; if (!in_array($type, ['topics', 'replies'], true)) return; $id = (int)($row['id'] ?? 0); $owner_id = (int)($row['user_id'] ?? 0); if ($id < 1 || $owner_id < 1) return; try { $payload = json_encode($row, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); } catch (Throwable) { throw new RuntimeException('回收站无法保存内容快照'); } $deleted_by = max(0, uid()); $source = ($deleted_by > 0 && $deleted_by === $owner_id) ? 'user' : 'admin'; $reason = cut(trim((string)($_POST['recycle_bin_reason'] ?? '')), DB_TEXT_MAX_LENGTH); if ($source === 'admin' && $reason === '') $reason = '管理员删除'; $batch = recycle_bin_batch_key(); q('INSERT INTO ' . recycle_bin_table() . '(batch_key,item_type,item_id,parent_id,owner_id,delete_source,delete_reason,payload,deleted_by,deleted_at,status,restored_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)', [ $batch, $type, $id, $type === 'replies' ? (int)($row['topic_id'] ?? 0) : 0, $owner_id, $source, $reason, $payload, $deleted_by, now(), 'archived', 0, ]); if ($source === 'admin') { if (!$cascade) recycle_bin_notify_deleted_author($owner_id, $type, $row, $reason); elseif (recycle_bin_config()['notify_cascade_authors']) recycle_bin_notify_cascade_author($owner_id, $type, $row); } } function recycle_bin_notify_cascade_author(int $owner_id, string $type, array $row): void { if ($owner_id <= 0 || $owner_id === uid()) return; if (!isset($GLOBALS['__recycle_bin_notified_cascade'])) $GLOBALS['__recycle_bin_notified_cascade'] = []; $key = (string)$owner_id . ':' . recycle_bin_batch_key(); if (isset($GLOBALS['__recycle_bin_notified_cascade'][$key])) return; $GLOBALS['__recycle_bin_notified_cascade'][$key] = true; create_notification($owner_id, uid(), 'recycle_bin', '你参与的内容所在主题已被管理员删除,其中你的回帖也已进入回收站。你可以在「我的回收站」查看,并在需要时提交恢复申请。'); } function recycle_bin_notify_deleted_author(int $owner_id, string $type, array $row, string $reason): void { if ($owner_id <= 0 || $owner_id === uid()) return; $title = trim((string)($row['title'] ?? '')); $label = $type === 'topics' ? '主题《' . ($title !== '' ? $title : '#' . (int)($row['id'] ?? 0)) . '》' : '一条回帖'; $reason_text = $reason !== '' ? '原因:' . content_excerpt($reason, 160) . '。' : ''; $content = '您的' . $label . '已被管理员移入回收站。' . $reason_text . '你可以在「我的回收站」查看原内容,并修改后重新提交恢复申请。'; create_notification($owner_id, uid(), 'recycle_bin', $content); } function recycle_bin_before_delete($value, array $ctx): mixed { $type = (string)($ctx['table'] ?? ''); $row = is_array($ctx['row'] ?? null) ? $ctx['row'] : []; recycle_bin_archive_row($type, $row, !empty($ctx['cascade'])); return $value; } function recycle_bin_topic_actions($value, array $ctx): mixed { if (!is_string($value) || !can_manage()) return $value; $topic = is_array($ctx['topic'] ?? null) ? $ctx['topic'] : []; if (!$topic) return $value; return $value . '<label class="grid recycle-bin-reason-field"><span>删除原因</span><textarea name="recycle_bin_reason" maxlength="' . (int)DB_TEXT_MAX_LENGTH . '" placeholder="可选;填写后作者会在通知中看到">' . h((string)($_POST['recycle_bin_reason'] ?? '')) . '</textarea></label>'; } function recycle_bin_reply_form_extra($value, array $ctx): mixed { if (!is_string($value) || !can_manage()) return $value; $reply = is_array($ctx['reply'] ?? null) ? $ctx['reply'] : []; if (!$reply || (int)($reply['id'] ?? 0) < 1) return $value; return $value . '<label class="grid recycle-bin-reason-field"><span>删除原因</span><textarea name="recycle_bin_reason" maxlength="' . (int)DB_TEXT_MAX_LENGTH . '" placeholder="可选;填写后作者会在通知中看到">' . h((string)($_POST['recycle_bin_reason'] ?? '')) . '</textarea></label>'; } function recycle_bin_admin_tabs($value, array $ctx): mixed { if (!recycle_bin_config()['admin_tab'] || !is_array($value)) return $value; $value['recycle_bin'] = ['label' => '回收站', 'href' => admin_url(['tab' => 'recycle_bin'])]; return $value; } function recycle_bin_user_menu_links($value, array $ctx): mixed { if (!recycle_bin_config()['user_entry'] || !is_array($value) || empty($ctx['self'])) return $value; $value['recycle_bin'] = ['text' => '我的回收站', 'url' => route_url('recycle_bin')]; return $value; } function recycle_bin_sidebar_feature_links($value, array $ctx): mixed { if (!recycle_bin_config()['sidebar_link'] || !uid() || !is_array($value)) return $value; $value['recycle_bin'] = ['text' => '我的回收站', 'url' => route_url('recycle_bin')]; return $value; } function recycle_bin_purge_interval(): int { return 3600; } function recycle_bin_purge_cron(array $plugin, array $task): string { $config = recycle_bin_config(); if (!$config['auto_purge']) return '自动清理未启用'; $cutoff = now() - $config['retention_days'] * 86400; $batches = q('SELECT batch_key,MAX(deleted_at) AS newest_deleted_at FROM ' . recycle_bin_table() . ' WHERE status=? GROUP BY batch_key HAVING MAX(deleted_at)<=? ORDER BY newest_deleted_at ASC LIMIT ?', ['archived', $cutoff, $config['purge_batch_limit']])->fetchAll(); $purged = 0; foreach ($batches as $batch) { $key = (string)($batch['batch_key'] ?? ''); if ($key === '') continue; $purged += (int)q('DELETE FROM ' . recycle_bin_table() . ' WHERE batch_key=? AND status=?', [$key, 'archived'])->rowCount(); q('DELETE FROM ' . recycle_bin_requests_table() . ' WHERE archive_id NOT IN (SELECT id FROM ' . recycle_bin_table() . ') AND status<>?', ['pending']); } return '自动清理归档 ' . $purged . ' 条'; } function recycle_bin_decode_payload(array $row): array { $payload = json_decode((string)($row['payload'] ?? ''), true); if (!is_array($payload) || (int)($payload['id'] ?? 0) < 1) err('回收站快照已损坏,无法恢复'); return $payload; } function recycle_bin_load_rows(array $ids, string $status = 'archived'): array { $ids = array_values(array_unique(array_filter(array_map('intval', $ids), static fn(int $id): bool => $id > 0))); if (!$ids) return []; $rows = q('SELECT * FROM ' . recycle_bin_table() . ' WHERE id IN (' . sql_marks(count($ids)) . ') AND status=? ORDER BY id', [...$ids, $status])->fetchAll(); $map = []; foreach ($rows as $row) $map[(int)$row['id']] = $row; return $map; } function recycle_bin_load_batches(array $batch_keys, string $status = 'archived'): array { $batch_keys = array_values(array_unique(array_filter(array_map('strval', $batch_keys), static fn(string $key): bool => $key !== ''))); if (!$batch_keys) return []; $rows = q('SELECT * FROM ' . recycle_bin_table() . ' WHERE batch_key IN (' . sql_marks(count($batch_keys)) . ') AND status=? ORDER BY id', [...$batch_keys, $status])->fetchAll(); $grouped = []; foreach ($rows as $row) $grouped[(string)$row['batch_key']][] = $row; return $grouped; } function recycle_bin_restore_pg_sequence(string $table): void { if (db_driver() !== 'pgsql') return; q("SELECT setval(pg_get_serial_sequence(?, 'id'), COALESCE((SELECT MAX(id) FROM " . app_db_identifier('pgsql', $table) . "), 1))", [$table]); } function recycle_bin_assert_reference(array $payload, string $type): void { $user_id = (int)($payload['user_id'] ?? 0); if ($user_id < 1 || !row('app_users', 'id', $user_id)) err(($type === 'topics' ? '主题' : '回帖') . '的作者账号已不存在,暂时无法恢复'); if ($type === 'topics') { $forum_id = (int)($payload['forum_id'] ?? 0); if ($forum_id < 1 || !row('app_forums', 'id', $forum_id)) err('主题所属版块已不存在,暂时无法恢复'); } else { $topic_id = (int)($payload['topic_id'] ?? 0); if ($topic_id < 1 || !row('app_topics', 'id', $topic_id)) err('回帖所属主题不存在,请先恢复主题'); } } function recycle_bin_restore_topic(array $topic_row, array $batch_rows, ?array $override = null): void { $topic = $override ?: recycle_bin_decode_payload($topic_row); $topic_id = (int)$topic['id']; if (row('app_topics', 'id', $topic_id)) err('主题ID #' . $topic_id . ' 已被占用,无法按原ID恢复'); recycle_bin_assert_reference($topic, 'topics'); $reply_payloads = []; foreach ($batch_rows as $row) { if ((string)$row['item_type'] !== 'replies' || (string)$row['status'] !== 'archived') continue; $reply = recycle_bin_decode_payload($row); if ((int)($reply['topic_id'] ?? 0) !== $topic_id) continue; if (row('app_replies', 'id', (int)($reply['id'] ?? 0))) err('回帖ID #' . (int)$reply['id'] . ' 已被占用,无法恢复整个主题'); recycle_bin_assert_reference($reply, 'replies'); $reply_payloads[] = ['archive' => $row, 'payload' => $reply]; } $has_floor_index = app_db_table_exists(db(), db_driver(), 'app_topics_del'); q('INSERT INTO app_topics(id,forum_id,user_id,title,body,highlight_style,reply_order,reply_count,view_count,last_reply_at,last_reply_user_id,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)', [ $topic_id, (int)$topic['forum_id'], (int)$topic['user_id'], (string)$topic['title'], (string)$topic['body'], (string)($topic['highlight_style'] ?? ''), (int)($topic['reply_order'] ?? 0), 0, (int)($topic['view_count'] ?? 0), 0, 0, (int)$topic['created_at'] ]); topic_fts_sync($topic_id, (string)$topic['title'], (string)$topic['body']); $restored_archive_ids = [(int)$topic_row['id']]; foreach ($reply_payloads as $item) { $reply = $item['payload']; q('INSERT INTO app_replies(id,topic_id,user_id,body,created_at,updated_at) VALUES(?,?,?,?,?,?)', [(int)$reply['id'], $topic_id, (int)$reply['user_id'], (string)$reply['body'], (int)$reply['created_at'], (int)($reply['updated_at'] ?? $reply['created_at'])]); reply_fts_sync((int)$reply['id'], (string)$reply['body']); if ($has_floor_index) q('DELETE FROM app_topics_del WHERE topic_id=? AND reply_id=?', [$topic_id, (int)$reply['id']]); $restored_archive_ids[] = (int)$item['archive']['id']; } if (function_exists('floor_index_clear')) floor_index_clear(); refresh_topic_stats($topic_id); q('UPDATE ' . recycle_bin_table() . ' SET status=?,restored_at=? WHERE id IN (' . sql_marks(count($restored_archive_ids)) . ')', ['restored', now(), ...$restored_archive_ids]); recycle_bin_restore_pg_sequence('app_topics'); recycle_bin_restore_pg_sequence('app_replies'); recycle_bin_notify_restore_authors($topic_row, $batch_rows, $topic_id, 0); } function recycle_bin_restore_reply(array $row, ?array $override = null): void { $reply = $override ?: recycle_bin_decode_payload($row); $reply_id = (int)$reply['id']; $topic_id = (int)($reply['topic_id'] ?? 0); if (row('app_replies', 'id', $reply_id)) err('回帖ID #' . $reply_id . ' 已被占用,无法恢复'); if (!$topic_id || !row('app_topics', 'id', $topic_id)) err('所属主题不存在,请先恢复主题'); recycle_bin_assert_reference($reply, 'replies'); q('INSERT INTO app_replies(id,topic_id,user_id,body,created_at,updated_at) VALUES(?,?,?,?,?,?)', [$reply_id, $topic_id, (int)$reply['user_id'], (string)$reply['body'], (int)$reply['created_at'], (int)($reply['updated_at'] ?? $reply['created_at'])]); reply_fts_sync($reply_id, (string)$reply['body']); if (app_db_table_exists(db(), db_driver(), 'app_topics_del')) q('DELETE FROM app_topics_del WHERE topic_id=? AND reply_id=?', [$topic_id, $reply_id]); if (function_exists('floor_index_clear')) floor_index_clear(); refresh_topic_stats($topic_id); q('UPDATE ' . recycle_bin_table() . ' SET status=?,restored_at=? WHERE id=?', ['restored', now(), (int)$row['id']]); recycle_bin_restore_pg_sequence('app_replies'); recycle_bin_notify_restore_authors($row, [$row], $topic_id, $reply_id); } function recycle_bin_notify_restore_authors(array $primary, array $batch_rows, int $topic_id, int $reply_id): void { if ((string)($primary['delete_source'] ?? '') !== 'admin') return; $owners = []; foreach ($batch_rows as $item) { $owner = (int)($item['owner_id'] ?? 0); if ($owner > 0) $owners[$owner] = true; } $owner = (int)($primary['owner_id'] ?? 0); if ($owner > 0) $owners[$owner] = true; foreach (array_keys($owners) as $owner_id) { if ((int)$owner_id === uid()) continue; $url = route_url('recycle_bin'); $content = '管理员已恢复你被删除的' . ((string)($primary['item_type'] ?? '') === 'topics' ? '主题' : '回帖') . '。你可以查看恢复结果;如需继续修改,可直接编辑后重新发布。\n\n[打开我的回收站](' . $url . ')'; create_notification((int)$owner_id, uid(), 'recycle_bin', $content, $topic_id, $reply_id); } } function recycle_bin_restore_selected(array $ids): int { $rows = recycle_bin_load_rows($ids, 'archived'); if (!$rows) return 0; $topic_batch_keys = []; foreach ($rows as $row) { if ((string)$row['item_type'] === 'topics') $topic_batch_keys[(string)$row['batch_key']] = true; } $batches = recycle_bin_load_batches(array_keys($topic_batch_keys), 'archived'); $count = 0; foreach ($rows as $row) { $type = (string)$row['item_type']; if ($type === 'replies' && isset($topic_batch_keys[(string)$row['batch_key']])) continue; tx(function () use ($row, $batches, &$count): void { $type = (string)$row['item_type']; if ($type === 'topics') { recycle_bin_restore_topic($row, $batches[(string)$row['batch_key']] ?? []); } elseif ($type === 'replies') { recycle_bin_restore_reply($row); } else { err('未知回收类型'); } $count++; }); } return $count; } function recycle_bin_purge_selected(array $ids): int { $rows = recycle_bin_load_rows($ids, 'archived'); if (!$rows) return 0; $count = 0; foreach ($rows as $row) { $count += (int)q('DELETE FROM ' . recycle_bin_table() . ' WHERE id=? AND status=?', [(int)$row['id'], 'archived'])->rowCount(); } return $count; } function recycle_bin_admin_handle_action(): never { require_post(); if ((string)($_POST['recycle_bin_action'] ?? '') === 'save_config') { $config = recycle_bin_config(); foreach (['capture_topics','capture_replies','auto_purge','admin_tab','sidebar_link','user_entry','notify_cascade_authors'] as $key) $config[$key] = isset($_POST[$key]) ? 1 : 0; $config['retention_days'] = min(3650, max(1, (int)($_POST['retention_days'] ?? $config['retention_days']))); $config['purge_batch_limit'] = min(1000, max(1, (int)($_POST['purge_batch_limit'] ?? $config['purge_batch_limit']))); plugin_save_config('recycle_bin', $config); set_flash('回收站设置已保存'); go(admin_url(['tab' => 'recycle_bin'])); } $action = (string)($_POST['recycle_bin_action'] ?? ''); $ids = array_values(array_unique(array_filter(array_map('intval', (array)($_POST['ids'] ?? [])), static fn(int $id): bool => $id > 0))); if (count($ids) > 200) err('一次最多处理200条归档'); if ($action === 'restore') { set_flash('已恢复 ' . recycle_bin_restore_selected($ids) . ' 条内容'); go(admin_url(['tab' => 'recycle_bin'])); } if ($action === 'purge') { set_flash('已永久清除 ' . recycle_bin_purge_selected($ids) . ' 条回收站快照(不会删除当前论坛数据)'); go(admin_url(['tab' => 'recycle_bin'])); } if ($action === 'approve_request' || $action === 'reject_request') { $request_id = max(0, (int)($_POST['request_id'] ?? 0)); recycle_bin_review_request($request_id, $action === 'approve_request'); go(admin_url(['tab' => 'recycle_bin', 'view' => 'requests'])); } if ($action === 'purge_all') { if ((string)($_POST['confirm_phrase'] ?? '') !== '永久清空') err('请输入“永久清空”确认'); $count = (int)q('DELETE FROM ' . recycle_bin_table() . ' WHERE status=?', ['archived'])->rowCount(); set_flash('已永久清空 ' . $count . ' 条回收站快照(不会删除当前论坛数据)'); go(admin_url(['tab' => 'recycle_bin'])); } err('参数错误'); } function recycle_bin_submit_request(int $archive_id): void { need_login(); require_post(); $row = one('SELECT * FROM ' . recycle_bin_table() . ' WHERE id=? AND status=?', [$archive_id, 'archived']); if (!$row || (int)$row['owner_id'] !== uid()) err('回收站内容不存在或无权操作'); if ((string)$row['delete_source'] !== 'admin') err('你可以直接恢复自己删除的内容'); $payload = recycle_bin_decode_payload($row); $type = (string)$row['item_type']; if ($type === 'topics') { $title = cut(trim((string)($_POST['title'] ?? '')), length_limit('title', 'max')); $body = cut(trim((string)($_POST['body'] ?? '')), length_limit('topic_body', 'max')); if ($title === '' || $body === '') err('标题和内容不能为空'); require_length($title, length_limit('title', 'min'), '标题'); require_length($body, length_limit('topic_body', 'min'), '主题内容'); $payload['title'] = $title; $payload['body'] = $body; } elseif ($type === 'replies') { $body = cut(trim((string)($_POST['body'] ?? '')), length_limit('reply_body', 'max')); if ($body === '') err('回帖内容不能为空'); require_length($body, length_limit('reply_body', 'min'), '回帖内容'); $payload['body'] = $body; } else err('未知内容类型'); $message = cut(trim((string)($_POST['message'] ?? '')), DB_TEXT_MAX_LENGTH); $existing = one('SELECT id FROM ' . recycle_bin_requests_table() . ' WHERE archive_id=? AND status=?', [$archive_id, 'pending']); if ($existing) err('该内容已有待处理的恢复申请,请等待管理员处理'); app_db_insert_ignore(recycle_bin_requests_table(), [ 'archive_id' => $archive_id, 'user_id' => uid(), 'item_type' => $type, 'proposed_payload' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), 'message' => $message, 'status' => 'pending', 'created_at' => now(), 'reviewed_at' => 0, 'reviewer_id' => 0, 'review_note' => '', ], ['archive_id','status']); set_flash('恢复申请已提交,等待管理员处理'); go(route_url('recycle_bin')); } function recycle_bin_review_request(int $request_id, bool $approve): void { need_admin(); $request = one('SELECT * FROM ' . recycle_bin_requests_table() . ' WHERE id=? AND status=?', [$request_id, 'pending']); if (!$request) err('恢复申请不存在或已处理'); $archive = one('SELECT * FROM ' . recycle_bin_table() . ' WHERE id=? AND status=?', [(int)$request['archive_id'], 'archived']); if (!$archive) err('对应回收站内容已不存在'); $review_note = cut(trim((string)($_POST['review_note'] ?? '')), DB_TEXT_MAX_LENGTH); tx(function () use ($request, $archive, $approve, $review_note): void { if ($approve) { $payload = json_decode((string)$request['proposed_payload'], true); if (!is_array($payload)) err('申请内容已损坏'); if ((string)$archive['item_type'] === 'topics') { $batch = recycle_bin_load_batches([(string)$archive['batch_key']], 'archived'); recycle_bin_restore_topic($archive, $batch[(string)$archive['batch_key']] ?? [], $payload); } else { recycle_bin_restore_reply($archive, $payload); } } q('UPDATE ' . recycle_bin_requests_table() . ' SET status=?,reviewed_at=?,reviewer_id=?,review_note=? WHERE id=? AND status=?', [$approve ? 'approved' : 'rejected', now(), uid(), $review_note, (int)$request['id'], 'pending']); $recipient = (int)$request['user_id']; if ($recipient > 0 && $recipient !== uid()) { $message = $approve ? '管理员已通过你的恢复申请,修改后的内容已经恢复。' : '管理员暂未通过你的恢复申请。' . ($review_note !== '' ? '原因:' . content_excerpt($review_note, 160) : ''); create_notification($recipient, uid(), 'recycle_bin', $message, (string)$archive['item_type'] === 'topics' ? (int)($payload['id'] ?? 0) : (int)($payload['topic_id'] ?? 0), (string)$archive['item_type'] === 'replies' ? (int)($payload['id'] ?? 0) : 0); } }); set_flash($approve ? '恢复申请已通过' : '恢复申请已驳回'); } function recycle_bin_user_handle_action(): never { need_login(); require_post(); $action = (string)($_POST['recycle_bin_action'] ?? ''); $id = max(0, (int)($_POST['archive_id'] ?? 0)); if ($action === 'submit_request') recycle_bin_submit_request($id); if ($action === 'restore_self') { $row = one('SELECT * FROM ' . recycle_bin_table() . ' WHERE id=? AND status=? AND owner_id=?', [$id, 'archived', uid()]); if (!$row) err('回收站内容不存在'); if ((string)$row['delete_source'] !== 'user') err('管理员删除的内容需要修改后提交恢复申请'); recycle_bin_restore_selected([$id]); set_flash('内容已恢复'); go(route_url('recycle_bin')); } err('参数错误'); } function recycle_bin_user_page(array $plugin): void { need_login(); if (is_post_request()) recycle_bin_user_handle_action(); $view = (string)($_GET['view'] ?? 'all'); if (!in_array($view, ['all','archived','restored','requests'], true)) $view = 'all'; $archive_id = max(0, (int)($_GET['id'] ?? 0)); if ($archive_id > 0 && $view === 'edit') $view = 'edit'; if ($archive_id > 0 && (string)($_GET['view'] ?? '') === 'edit') { $row = one('SELECT * FROM ' . recycle_bin_table() . ' WHERE id=? AND status=? AND owner_id=?', [$archive_id, 'archived', uid()]); if (!$row) err('回收站内容不存在'); if ((string)$row['delete_source'] !== 'admin') err('自己删除的内容可以直接恢复,无需申请'); $payload = recycle_bin_decode_payload($row); $body = '<div class="recycle-bin-page"><section class="form-panel"><h2>修改后重新提交</h2><p class="admin-muted">这是管理员删除前的原始内容。修改后会提交给管理员审核,不会直接重新公开。</p><form method="post">' . form_token() . '<input type="hidden" name="recycle_bin_action" value="submit_request"><input type="hidden" name="archive_id" value="' . (int)$row['id'] . '">'; if ((string)$row['item_type'] === 'topics') $body .= input('标题', 'title', (string)($payload['title'] ?? ''), 'text', true) . textarea('内容', 'body', (string)($payload['body'] ?? ''), true); else $body .= textarea('内容', 'body', (string)($payload['body'] ?? ''), true); $body .= textarea('给管理员的说明', 'message', '', false, '例如:已删除违规内容中的链接,其他内容保持不变。') . '<button type="submit">提交恢复申请</button></form></section></div>'; page('修改后重新提交', form_shell($body)); return; } $where = ['owner_id=?']; $params = [uid()]; if ($view === 'archived') { $where[] = 'status=?'; $params[] = 'archived'; } if ($view === 'restored') { $where[] = 'status=?'; $params[] = 'restored'; } $rows = q('SELECT * FROM ' . recycle_bin_table() . ' WHERE ' . implode(' AND ', $where) . ' ORDER BY deleted_at DESC,id DESC LIMIT 100', $params)->fetchAll(); $requests = q('SELECT r.*,i.item_type,i.item_id,i.delete_reason FROM ' . recycle_bin_requests_table() . ' r LEFT JOIN ' . recycle_bin_table() . ' i ON i.id=r.archive_id WHERE r.user_id=? ORDER BY r.created_at DESC LIMIT 100', [uid()])->fetchAll(); $pending_archive_ids = []; if ($rows) { $archive_ids = array_values(array_unique(array_map(static fn(array $r): int => (int)$r['id'], $rows))); $pending_rows = q('SELECT archive_id FROM ' . recycle_bin_requests_table() . ' WHERE archive_id IN (' . sql_marks(count($archive_ids)) . ') AND status=?', [...$archive_ids, 'pending'])->fetchAll(); foreach ($pending_rows as $pending_row) $pending_archive_ids[(int)$pending_row['archive_id']] = true; } $nav = '<div class="recycle-bin-user-tabs"><a href="' . h(route_url('recycle_bin')) . '">全部</a><a href="' . h(route_url('recycle_bin', ['view'=>'archived'])) . '">待处理</a><a href="' . h(route_url('recycle_bin', ['view'=>'restored'])) . '">已恢复</a><a href="' . h(route_url('recycle_bin', ['view'=>'requests'])) . '">恢复申请</a></div>'; $html = '<div class="recycle-bin-page"><section class="form-panel"><h2>我的回收站</h2><p class="admin-muted">这里只显示你自己的内容。管理员删除的内容不会直接恢复,需要修改后提交申请;你自己删除的内容可以直接恢复。</p>' . $nav; if ($view === 'requests') { $html .= '<ul class="recycle-bin-list">'; foreach ($requests as $request) { $status = match ((string)$request['status']) { 'pending'=>'待审核','approved'=>'已通过','rejected'=>'已驳回', default=>'未知' }; $html .= '<li><strong>' . h((string)$request['item_type'] === 'topics' ? '主题' : '回帖') . ' #' . (int)$request['item_id'] . '</strong><span>' . h($status) . ' · ' . human_time((int)$request['created_at']) . '</span>' . ($request['review_note'] !== '' ? '<small>' . h((string)$request['review_note']) . '</small>' : '') . '</li>'; } if (!$requests) $html .= '<li class="empty-state">还没有恢复申请。</li>'; $html .= '</ul></section></div>'; page('我的回收站', form_shell($html)); return; } $html .= '</section><section class="admin-list-panel"><ul class="recycle-bin-list">'; foreach ($rows as $row) { $payload = json_decode((string)$row['payload'], true) ?: []; $type = (string)$row['item_type']; $title = $type === 'topics' ? trim((string)($payload['title'] ?? '')) : content_excerpt((string)($payload['body'] ?? ''), 120); if ($title === '') $title = ($type === 'topics' ? '主题' : '回帖') . ' #' . (int)$row['item_id']; $source = (string)$row['delete_source'] === 'admin' ? '管理员删除' : '自己删除'; $status = (string)$row['status']; $html .= '<li><div><strong>' . h($title) . '</strong><small>' . h($type === 'topics' ? '主题' : '回帖') . ' #' . (int)$row['item_id'] . ' · ' . h($source) . ' · ' . h($status === 'archived' ? '待处理' : '已恢复') . ' · ' . human_time((int)$row['deleted_at']) . '</small>' . ((string)$row['delete_reason'] !== '' ? '<small>删除原因:' . h((string)$row['delete_reason']) . '</small>' : '') . '</div><div class="recycle-bin-actions">'; if ($status === 'archived') { if ((string)$row['delete_source'] === 'user') { $html .= '<form method="post">' . form_token() . '<input type="hidden" name="recycle_bin_action" value="restore_self"><input type="hidden" name="archive_id" value="' . (int)$row['id'] . '"><button type="submit">恢复</button></form>'; } else { $pending = isset($pending_archive_ids[(int)$row['id']]); $html .= $pending ? '<span class="admin-flag on">申请审核中</span>' : '<a class="btn" href="' . h(route_url('recycle_bin', ['view'=>'edit','id'=>(int)$row['id']])) . '">修改后重新提交</a>'; } } else $html .= '<span class="admin-flag on">已恢复</span>'; $html .= '</div></li>'; } if (!$rows) $html .= '<li class="empty-state">你的回收站为空。</li>'; $html .= '</ul></section></div>'; page('我的回收站', form_shell($html)); } function recycle_bin_admin_title(array $row): string { $payload = json_decode((string)($row['payload'] ?? ''), true); if (!is_array($payload)) return '损坏的快照 #' . (int)$row['id']; if ((string)$row['item_type'] === 'topics') { $title = trim((string)($payload['title'] ?? '')); return $title !== '' ? $title : '主题 #' . (int)$row['item_id']; } return content_excerpt((string)($payload['body'] ?? ''), 100) ?: '回帖 #' . (int)$row['item_id']; } function recycle_bin_admin_page(array $plugin): string { need_admin(); if (is_post_request()) recycle_bin_admin_handle_action(); $config = recycle_bin_config(); $view = (string)($_GET['view'] ?? 'all'); if (!in_array($view, ['all','requests'], true)) $view = 'all'; $stats = []; foreach (['archived','restored'] as $status) $stats[$status] = (int)val('SELECT COUNT(*) FROM ' . recycle_bin_table() . ' WHERE status=?', [$status]); $pending_requests = (int)val('SELECT COUNT(*) FROM ' . recycle_bin_requests_table() . ' WHERE status=?', ['pending']); $settings = '<section class="form-panel"><h2>回收规则</h2><p class="admin-muted">这是管理员专用的全站回收站。这里可以看到所有用户的回收状态;普通用户只能看到自己的内容。</p><form method="post">' . form_token() . '<input type="hidden" name="recycle_bin_action" value="save_config">' . checkbox('回收主题','capture_topics',$config['capture_topics']) . checkbox('回收回帖','capture_replies',$config['capture_replies']) . checkbox('启用自动永久清理','auto_purge',$config['auto_purge']) . number_input('保留天数','retention_days',$config['retention_days'],1,3650) . number_input('每次自动清理最多处理批次','purge_batch_limit',$config['purge_batch_limit'],1,1000) . checkbox('在后台主导航显示“回收站”','admin_tab',$config['admin_tab']) . checkbox('在侧栏快捷功能显示“我的回收站”','sidebar_link',$config['sidebar_link']) . checkbox('在用户菜单显示“我的回收站”','user_entry',$config['user_entry']) . checkbox('删除主题时通知受影响的回帖作者','notify_cascade_authors',$config['notify_cascade_authors']) . '<p class="admin-muted">自动永久清理默认关闭。管理员删除会通知作者;作者可以修改后提交恢复申请,管理员审核后才恢复。</p><button type="submit">保存设置</button></form></section>'; $tabs = '<div class="recycle-bin-admin-tabs"><a href="' . h(admin_url(['tab'=>'recycle_bin'])) . '">全部内容</a><a href="' . h(admin_url(['tab'=>'recycle_bin','view'=>'requests'])) . '">恢复申请' . ($pending_requests ? ' (' . $pending_requests . ')' : '') . '</a></div>'; if ($view === 'requests') { $rows = q('SELECT r.*,i.item_type,i.item_id,i.owner_id,i.delete_reason,i.payload FROM ' . recycle_bin_requests_table() . ' r LEFT JOIN ' . recycle_bin_table() . ' i ON i.id=r.archive_id ORDER BY CASE r.status WHEN \'pending\' THEN 0 ELSE 1 END,r.created_at DESC LIMIT 200')->fetchAll(); $body = '<section class="admin-list-panel"><div class="admin-list-head"><strong>恢复申请</strong><span class="admin-muted">待处理 ' . $pending_requests . '</span></div><ul class="admin-manage-list">'; $request_users = rows_by_ids('app_users', array_column($rows, 'user_id'), 'id,username,avatar_style,avatar_seed,group_id'); foreach ($rows as $row) { $user = $request_users[(int)$row['user_id']] ?? null; $payload = json_decode((string)$row['proposed_payload'], true) ?: []; $title = (string)$row['item_type'] === 'topics' ? (string)($payload['title'] ?? '') : content_excerpt((string)($payload['body'] ?? ''), 100); $body .= '<li class="admin-list-item"><div><strong>' . h($title ?: ('#' . (int)$row['item_id'])) . '</strong><small>用户:' . h((string)($user['username'] ?? ('#' . (int)$row['user_id']))) . ' · ' . h((string)$row['item_type'] === 'topics' ? '主题' : '回帖') . ' #' . (int)$row['item_id'] . ' · ' . h((string)$row['status']) . ' · ' . human_time((int)$row['created_at']) . '</small>' . ((string)$row['message'] !== '' ? '<small>用户说明:' . h((string)$row['message']) . '</small>' : '') . '</div>'; if ((string)$row['status'] === 'pending') { $body .= '<div class="admin-inline-ops"><form method="post">' . form_token() . '<input type="hidden" name="recycle_bin_action" value="approve_request"><input type="hidden" name="request_id" value="' . (int)$row['id'] . '"><button type="submit">通过并恢复</button></form><form method="post">' . form_token() . '<input type="hidden" name="recycle_bin_action" value="reject_request"><input type="hidden" name="request_id" value="' . (int)$row['id'] . '"><input name="review_note" placeholder="驳回原因"><button type="submit" class="alt">驳回</button></form></div>'; } $body .= '</li>'; } if (!$rows) $body .= '<li class="empty-state">暂无恢复申请。</li>'; $body .= '</ul></section>'; return '<div class="recycle-bin-page">' . $settings . $tabs . $body . '</div>'; } $status = (string)($_GET['status'] ?? 'archived'); if (!in_array($status, ['archived','restored','all'], true)) $status = 'archived'; $type = (string)($_GET['type'] ?? 'all'); if (!in_array($type, ['topics','replies','all'], true)) $type = 'all'; $source = (string)($_GET['source'] ?? 'all'); if (!in_array($source, ['admin','user','all'], true)) $source = 'all'; $search = cut(trim((string)($_GET['q'] ?? '')), 100); $page_no = min(max_pagination_pages(), max(1, (int)($_GET['p'] ?? 1))); $size = 50; $where=[]; $params=[]; if ($status !== 'all') { $where[]='status=?'; $params[]=$status; } if ($type !== 'all') { $where[]='item_type=?'; $params[]=$type; } if ($source !== 'all') { $where[]='delete_source=?'; $params[]=$source; } if ($search !== '') { $where[]='payload LIKE ?'; $params[]='%' . $search . '%'; } $where_sql = $where ? ' WHERE ' . implode(' AND ', $where) : ''; $total=(int)val('SELECT COUNT(*) FROM ' . recycle_bin_table() . $where_sql,$params); $offset=($page_no-1)*$size; $rows=q('SELECT * FROM ' . recycle_bin_table() . $where_sql . ' ORDER BY deleted_at DESC,id DESC LIMIT ? OFFSET ?', [...$params,$size+1,$offset])->fetchAll(); $has_next=count($rows)>$size; if($has_next)array_pop($rows); $base=['tab'=>'recycle_bin','status'=>$status,'type'=>$type,'source'=>$source]; if($search!=='')$base['q']=$search; $toolbar='<div class="admin-list-head"><div class="admin-head-inline"><div><strong>全站回收状态</strong> <span class="admin-muted">待处理 ' . $stats['archived'] . ' · 已恢复 ' . $stats['restored'] . ' · 待审核 ' . $pending_requests . '</span></div></div></div>'; $filters='<form method="get" class="admin-filter-form"><input type="hidden" name="a" value="admin"><input type="hidden" name="tab" value="recycle_bin"><select name="status"><option value="archived"' . ($status==='archived'?' selected':'') . '>待处理</option><option value="restored"' . ($status==='restored'?' selected':'') . '>已恢复</option><option value="all"' . ($status==='all'?' selected':'') . '>全部</option></select><select name="type"><option value="all">全部类型</option><option value="topics"' . ($type==='topics'?' selected':'') . '>主题</option><option value="replies"' . ($type==='replies'?' selected':'') . '>回帖</option></select><select name="source"><option value="all">全部来源</option><option value="admin"' . ($source==='admin'?' selected':'') . '>管理员删除</option><option value="user"' . ($source==='user'?' selected':'') . '>用户自己删除</option></select><input type="search" name="q" value="' . h($search) . '" placeholder="搜索标题/内容"><button type="submit">筛选</button></form>'; $list='<form method="post">' . form_token() . '<input type="hidden" name="recycle_bin_action" value="restore"><div class="admin-list-panel">' . $toolbar . $filters . '<ul class="admin-manage-list">'; $owner_users = rows_by_ids('app_users', array_column($rows, 'owner_id'), 'id,username,avatar_style,avatar_seed,group_id'); foreach($rows as $row){ $arch=(string)$row['status']==='archived'; $title=recycle_bin_admin_title($row); $user=$owner_users[(int)$row['owner_id']] ?? null; $meta=((string)$row['item_type']==='topics'?'主题':'回帖') . ' #' . (int)$row['item_id'] . ' · 作者 ' . (string)($user['username'] ?? ('#'.(int)$row['owner_id'])) . ' · ' . ((string)$row['delete_source']==='admin'?'管理员删除':'用户删除') . ' · ' . human_time((int)$row['deleted_at']); $list.='<li class="admin-list-item"><label class="check"><input type="checkbox" name="ids[]" value="' . (int)$row['id'] . '"' . ($arch?'':' disabled') . '><span><strong>' . h($title) . '</strong><small>' . h($meta) . '</small>' . ((string)$row['delete_reason']!==''?'<small>原因:'.h((string)$row['delete_reason']).'</small>':'') . '</span></label>' . ($arch?'<div class="admin-inline-ops"><button type="submit" name="recycle_bin_action" value="restore">恢复</button><button type="submit" name="recycle_bin_action" value="purge" class="alt" onclick="return confirm(\'永久清除所选快照?\')">永久清除</button></div>':'<span class="admin-flag on">已恢复</span>') . '</li>'; } if(!$rows)$list.='<li class="empty-state">没有符合条件的回收站记录。</li>'; $list.='</ul>'; if($has_next||$page_no>1)$list.='<div class="pagination-bar">'.simple_paginate($page_no>1,$has_next,$page_no,admin_url($base)).'</div>'; $list.='</div></form>'; $danger='<section class="form-panel"><h2>危险操作</h2><p class="admin-muted">永久清除只删除回收站快照,不会再次删除当前论坛数据。</p><form method="post" onsubmit="return confirm(\'确定永久清空全部已归档数据?\')">'.form_token().'<input type="hidden" name="recycle_bin_action" value="purge_all">'.input('确认文字','confirm_phrase','','text',true).'<button type="submit">永久清空已归档</button></form></section>'; return '<div class="recycle-bin-page">' . $settings . $tabs . $list . $danger . '</div>'; } function recycle_bin_plugin_css(): string { return '.recycle-bin-page{display:grid;gap:16px}.recycle-bin-page .form-panel{display:grid;gap:12px}.recycle-bin-page .admin-muted{color:var(--text-muted);font-size:var(--font-size-sm)}.recycle-bin-user-tabs,.recycle-bin-admin-tabs{display:flex;gap:8px;flex-wrap:wrap}.recycle-bin-user-tabs a,.recycle-bin-admin-tabs a{padding:7px 10px;border:1px solid var(--line);border-radius:8px}.recycle-bin-list{list-style:none;margin:0;padding:0}.recycle-bin-list>li{display:flex;gap:12px;align-items:center;justify-content:space-between;padding:14px 0;border-bottom:1px solid var(--line-soft)}.recycle-bin-list small{display:block;color:var(--text-muted);font-size:var(--font-size-sm);margin-top:4px}.recycle-bin-actions{display:flex;gap:8px;flex-wrap:wrap}.recycle-bin-reason-field{margin-top:8px}.recycle-bin-page .admin-list-item{display:flex;gap:12px;align-items:center;justify-content:space-between}.recycle-bin-page .admin-list-item .check{flex:1}.recycle-bin-page .admin-list-item small{display:block;color:var(--text-muted);font-size:var(--font-size-sm)}.recycle-bin-page .admin-inline-ops{display:flex;gap:8px;flex-wrap:wrap}.recycle-bin-page .admin-filter-form{display:flex;gap:8px;flex-wrap:wrap;padding:12px;border-bottom:1px solid var(--line-soft)}.recycle-bin-page .admin-filter-form input,.recycle-bin-page .admin-filter-form select{min-width:120px}.recycle-bin-page .admin-filter-form input[type=search]{flex:1;min-width:180px}@media(max-width:700px){.recycle-bin-list>li,.recycle-bin-page .admin-list-item{align-items:flex-start;flex-direction:column}.recycle-bin-actions,.recycle-bin-page .admin-inline-ops{width:100%}}'; } return [ 'id' => 'recycle_bin', 'name' => '回收站', 'version' => '1.1.0', 'description' => '管理员可管理全站回收内容,用户可查看自己的删除记录;管理员删除后,作者可以修改内容并提交恢复申请。', 'author' => 'bbs1org plugin', 'hooks' => [ 'content.before_delete' => 'recycle_bin_before_delete', 'topic.actions' => 'recycle_bin_topic_actions', 'reply.form_extra' => 'recycle_bin_reply_form_extra', 'admin.tabs' => 'recycle_bin_admin_tabs', 'user.menu_links' => 'recycle_bin_user_menu_links', 'sidebar.feature_links' => 'recycle_bin_sidebar_feature_links', ], 'routes' => [ 'recycle_bin' => 'recycle_bin_user_page', ], 'admin_tabs' => [ 'recycle_bin' => 'recycle_bin_admin_page', ], 'assets' => [ 'css' => 'recycle_bin_plugin_css', ], 'cron' => [ 'purge' => [ 'callback' => 'recycle_bin_purge_cron', 'interval' => 'recycle_bin_purge_interval', ], ], 'entries' => [ 'admin_tabs' => true, 'feature_links' => false, ], 'install' => 'recycle_bin_install', 'uninstall' => 'recycle_bin_uninstall', ];主楼- 暂无回复