db = UrlNav_Plugin::getDbConnection(); } public function action() { // 检查管理员权限 - Collection插件就是这样写的 $user = Typecho_Widget::widget('Widget_User'); if (!$user->hasLogin() || !$user->pass('administrator', true)) { $this->response->throwJson(array( 'success' => false, 'message' => '无权限访问' )); return; } // 确保返回JSON格式 - Collection插件就是这样写的 $this->response->setContentType('application/json'); // 获取do参数 - Collection插件就是这样写的 $do = $this->request->get('do'); if (!$do) { $do = $this->request->isPost() ? $this->request->get('do') : null; } switch ($do) { case 'addCategory': $this->addCategory(); break; case 'updateCategory': $this->updateCategory(); break; case 'deleteCategory': $this->deleteCategory(); break; case 'getCategory': $this->getCategory(); break; case 'getAllCategories': $this->getAllCategories(); break; case 'addUrl': $this->addUrl(); break; case 'updateUrl': $this->updateUrl(); break; case 'deleteUrl': $this->deleteUrl(); break; case 'getUrl': $this->getUrl(); break; case 'getAllUrls': $this->getAllUrls(); break; case 'getRssRefreshLogs': // 🔴 新增,获取详细RSS地址 $this->getRssRefreshLogs(); break; case 'batchDeleteUrls': $this->batchDeleteUrls(); break; case 'batchDeleteCategories': $this->batchDeleteCategories(); break; case 'test': $this->test(); break; case 'fetchRss': $this->fetchRss(); break; case 'getRssFeeds': $this->getRssFeeds(); break; case 'cleanRssCache': $this->cleanRssCache(); break; case 'addFavorite': $this->addFavorite(); break; case 'removeFavorite': $this->removeFavorite(); break; case 'getFavorites': $this->getFavorites(); break; case 'getFavoriteStats': $this->getFavoriteStats(); break; case 'checkFavorite': $this->checkFavorite(); break; case 'getCacheStats': $this->getCacheStats(); break; case 'getCronLogs': $this->getCronLogs(); break; case 'getCronStats': $this->getCronStats(); break; case 'getRefreshStats': $this->getRefreshStats(); break; case 'exportOpml': $this->exportOpml(); break; case 'importOpml': $this->importOpml(); break; case 'unlockCron': $this->unlockCron(); break; case 'checkStatus': $this->checkStatus(); break; case 'checkSingleStatus': $this->checkSingleStatus(); break; case 'getRssRefreshStatus': $this->getRssRefreshStatus(); break; case 'getRssRefreshStats': $this->getRssRefreshStats(); break; case 'getStatusStats': $this->getStatusStats(); break; case 'getRssCronStats': $this->getRssCronStats(); break; case 'getStatusCronStats': $this->getStatusCronStats(); break; // ================ 新增:失败网址相关方法 ================ case 'getRefreshLogs': // 获取刷新日志 $this->getRefreshLogs(); break; case 'getRecentRefreshStats': // 获取最近刷新统计 $this->getRecentRefreshStats(); break; case 'getAllFailedUrls': // 查看所有失败网址 $this->getAllFailedUrls(); break; case 'retryRssUrl': // 重试单个失败网址 $this->retryRssUrl(); break; case 'ignoreFailedUrl': // 忽略失败网址 $this->ignoreFailedUrl(); break; case 'enableFailedUrl': // 启用已停用网址 $this->enableFailedUrl(); break; case 'retryAllFailedUrls': // 重试所有失败网址 $this->retryAllFailedUrls(); break; case 'exportFailedUrls': // 导出失败网址列表 $this->exportFailedUrls(); break; case 'exportSuccessUrls': $this->exportSuccessUrls(); break; // ================ 结束新增 ================ default: $this->response->throwJson(array( 'success' => false, 'message' => '无效的操作' )); } } // 在 action() 方法之后,但在类结束之前添加以下方法 /** * 添加收藏 */ private function addFavorite() { try { $feedId = $this->request->get('feed_id'); if (!$feedId || !is_numeric($feedId)) { $this->response->throwJson(['success' => false, 'message' => '文章ID不能为空或格式错误']); return; } $result = UrlNav_Plugin::addFavorite($feedId, 0); $this->response->throwJson($result); } catch (Exception $e) { $this->response->throwJson([ 'success' => false, 'message' => '添加收藏失败: ' . $e->getMessage() ]); } } /** * 取消收藏 */ private function removeFavorite() { try { $feedId = $this->request->get('feed_id'); if (!$feedId || !is_numeric($feedId)) { $this->response->throwJson(['success' => false, 'message' => '文章ID不能为空或格式错误']); return; } $result = UrlNav_Plugin::removeFavorite($feedId, 0); $this->response->throwJson($result); } catch (Exception $e) { $this->response->throwJson([ 'success' => false, 'message' => '取消收藏失败: ' . $e->getMessage() ]); } } /** * 获取收藏列表 */ private function getFavorites() { try { $page = $this->request->get('page', 1); $pageSize = $this->request->get('pageSize', 20); $search = $this->request->get('search', ''); $result = UrlNav_Plugin::getFavorites(0, $page, $pageSize, $search); $this->response->throwJson($result); } catch (Exception $e) { $this->response->throwJson([ 'success' => false, 'message' => '获取收藏列表失败: ' . $e->getMessage() ]); } } /** * 获取收藏统计 */ private function getFavoriteStats() { try { $result = UrlNav_Plugin::getFavoriteStats(0); $this->response->throwJson([ 'success' => true, 'data' => $result ]); } catch (Exception $e) { $this->response->throwJson([ 'success' => false, 'message' => '获取收藏统计失败: ' . $e->getMessage() ]); } } /** * 检查是否已收藏 */ private function checkFavorite() { try { $feedId = $this->request->get('feed_id'); if (!$feedId || !is_numeric($feedId)) { $this->response->throwJson(['success' => false, 'message' => '文章ID不能为空或格式错误']); return; } $isFavorite = UrlNav_Plugin::isFavorite($feedId, 0); $this->response->throwJson([ 'success' => true, 'is_favorite' => $isFavorite ]); } catch (Exception $e) { $this->response->throwJson([ 'success' => false, 'message' => '检查收藏状态失败: ' . $e->getMessage() ]); } } /** * 获取RSS刷新详细日志 */ public function getRssRefreshLogs() { try { $limit = $this->request->get('limit', 10); $page = $this->request->get('page', 1); $db = UrlNav_Plugin::getDbConnection(); // 计算分页 $offset = ($page - 1) * $limit; // 获取refresh_log数据(RSS刷新详情) $sql = "SELECT * FROM urlnav_refresh_log WHERE cron_type = 'rss' ORDER BY refresh_time DESC LIMIT ? OFFSET ?"; $stmt = $db->prepare($sql); $stmt->execute([$limit, $offset]); $logs = $stmt->fetchAll(PDO::FETCH_ASSOC); // 解析details字段中的RSS地址 foreach ($logs as &$log) { if (!empty($log['details'])) { $details = @json_decode($log['details'], true); if ($details) { $log['details_parsed'] = $details; // 提取RSS地址 if (isset($details['success_rss_urls'])) { $log['success_rss_urls'] = $details['success_rss_urls']; } if (isset($details['failed_rss_urls'])) { $log['failed_rss_urls'] = $details['failed_rss_urls']; } } } // 使用message字段(已经包含RSS地址信息) $log['formatted_message'] = $log['message'] ?? "刷新完成:成功 " . ($log['success_count'] ?? 0) . " 个,失败 " . (($log['url_count'] ?? 0) - ($log['success_count'] ?? 0)) . " 个"; } // 获取总数 $countStmt = $db->prepare("SELECT COUNT(*) as total FROM urlnav_refresh_log WHERE cron_type = 'rss'"); $countStmt->execute(); $totalResult = $countStmt->fetch(PDO::FETCH_ASSOC); $total = $totalResult['total'] ?? 0; $this->response->throwJson([ 'success' => true, 'data' => $logs, 'total' => $total, 'page' => $page, 'limit' => $limit, 'totalPages' => ceil($total / $limit) ]); } catch (Exception $e) { $this->response->throwJson([ 'success' => false, 'message' => '获取详细日志失败: ' . $e->getMessage() ]); } } /** * 获取RSS刷新状态 */ public function getRssRefreshStatus() { try { $stats = UrlNav_Plugin::getRssRefreshStatus(); $this->response->throwJson(array( 'success' => true, 'data' => $stats )); } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '获取刷新状态失败: ' . $e->getMessage() )); } } /** * RSS定时任务接口(无需登录) */ public function rssCron() { // 设置响应头,避免502错误 if (!headers_sent()) { header('Content-Type: application/json; charset=utf-8'); header('Cache-Control: no-cache, no-store, must-revalidate'); header('Pragma: no-cache'); header('Expires: 0'); } // 获取密钥 $secret = $this->request->get('secret'); try { $result = UrlNav_Plugin::executePublicRssCron($secret); echo json_encode($result); exit; } catch (Exception $e) { $errorResponse = array( 'success' => false, 'message' => 'RSS定时任务执行失败: ' . $e->getMessage(), 'timestamp' => time() ); echo json_encode($errorResponse); exit; } } /** * 状态检查定时任务接口(无需登录) */ public function statusCron() { // 设置响应头,避免502错误 if (!headers_sent()) { header('Content-Type: application/json; charset=utf-8'); header('Cache-Control: no-cache, no-store, must-revalidate'); header('Pragma: no-cache'); header('Expires: 0'); } // 获取密钥 $secret = $this->request->get('secret'); try { $result = UrlNav_Plugin::executePublicStatusCron($secret); echo json_encode($result); exit; } catch (Exception $e) { $errorResponse = array( 'success' => false, 'message' => '状态检查定时任务执行失败: ' . $e->getMessage(), 'timestamp' => time() ); echo json_encode($errorResponse); exit; } } /** * 手动解锁定时任务 */ public function unlockCron() { $this->response->setContentType('application/json'); try { $type = $this->request->get('type', 'rss'); // 验证用户权限(解锁需要管理员权限) $user = Typecho_Widget::widget('Widget_User'); if (!$user->hasLogin() || !$user->pass('administrator', true)) { $this->response->throwJson(array( 'success' => false, 'message' => '无权限解锁定时任务' )); return; } $result = UrlNav_Plugin::unlockCron($type); $this->response->throwJson($result); } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '解锁失败: ' . $e->getMessage() )); } } /** * 测试方法 - 完全按照Collection插件写法 */ public function test() { $this->response->throwJson(array( 'success' => true, 'message' => 'UrlNav插件Action工作正常', 'timestamp' => time() )); } /** * 清理RSS缓存 */ public function cleanRssCache() { try { $cleanedCount = UrlNav_Plugin::cleanAllRssCache(); $this->response->throwJson(array( 'success' => true, 'message' => '清理完成', 'cleaned_count' => $cleanedCount )); } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '清理失败: ' . $e->getMessage() )); } } /** * 获取缓存统计信息 */ public function getCacheStats() { try { $stats = UrlNav_Plugin::getCacheStats(); $this->response->throwJson(array( 'success' => true, 'data' => $stats )); } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '获取统计信息失败: ' . $e->getMessage() )); } } /** * 获取刷新统计信息 */ public function getRefreshStats() { try { $limit = $this->request->get('limit', 10); $cronType = $this->request->get('cron_type', 'rss'); $stats = UrlNav_Plugin::getRefreshStats($cronType, $limit); $this->response->throwJson(array( 'success' => true, 'data' => $stats )); } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '获取刷新统计失败: ' . $e->getMessage() )); } } /** * 获取定时任务日志 */ public function getCronLogs() { try { $limit = $this->request->get('limit', 20); $type = $this->request->get('type'); $logs = UrlNav_Plugin::getCronLogs($type, $limit); $this->response->throwJson(array( 'success' => true, 'data' => $logs )); } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '获取定时任务日志失败: ' . $e->getMessage() )); } } /** * 获取定时任务统计 */ public function getCronStats() { try { $type = $this->request->get('type'); $stats = UrlNav_Plugin::getCronStats($type); $this->response->throwJson(array( 'success' => true, 'data' => $stats )); } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '获取定时任务统计失败: ' . $e->getMessage() )); } } /** * 获取RSS定时任务统计 */ public function getRssCronStats() { try { $stats = UrlNav_Plugin::getRssCronStats(); // 获取最近一次的失败网址(24小时内) $recentFailures = $this->getRecentFailedUrls(); $this->response->throwJson(array( 'success' => true, 'data' => array_merge($stats, [ 'recent_failures' => $recentFailures, 'has_failures' => !empty($recentFailures) ]) )); } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '获取RSS定时任务统计失败: ' . $e->getMessage() )); } } /** * 获取最近失败的网址(私有方法) */ private function getRecentFailedUrls() { try { $db = UrlNav_Plugin::getDbConnection(); // 获取最近24小时内失败的网址 // 移除不存在的字段:last_success, error_count $stmt = $db->prepare(" SELECT u.id, u.url, u.title as site_name, u.rss_url, u.last_error, u.last_refresh, u.star_rating, c.name as category_name FROM urlnav_urls u LEFT JOIN urlnav_categories c ON u.category_id = c.id WHERE u.is_active = 1 AND u.rss_url IS NOT NULL AND u.rss_url != '' AND u.last_error IS NOT NULL AND u.last_refresh IS NOT NULL AND u.last_refresh >= datetime('now', '-1 day') ORDER BY u.last_refresh DESC LIMIT 10 "); $stmt->execute(); $urls = $stmt->fetchAll(PDO::FETCH_ASSOC); return $urls; } catch (Exception $e) { error_log('获取失败网址失败: ' . $e->getMessage()); return []; } } /** * 获取所有失败网址 */ public function getAllFailedUrls() { try { $db = UrlNav_Plugin::getDbConnection(); // 获取所有历史失败网址(不限时间) // 移除不存在的字段:last_success, error_count $stmt = $db->prepare(" SELECT u.id, u.url, u.title as site_name, u.rss_url, u.last_error, u.last_refresh, u.star_rating, u.is_active, c.name as category_name FROM urlnav_urls u LEFT JOIN urlnav_categories c ON u.category_id = c.id WHERE u.rss_url IS NOT NULL AND u.rss_url != '' AND u.last_error IS NOT NULL AND u.last_refresh IS NOT NULL ORDER BY u.last_refresh DESC "); $stmt->execute(); $urls = $stmt->fetchAll(PDO::FETCH_ASSOC); $this->response->throwJson(array( 'success' => true, 'data' => $urls )); } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '获取失败网址列表失败: ' . $e->getMessage() )); } } /** * 重试单个失败网址 */ public function retryRssUrl() { try { $urlId = $this->request->get('url_id'); if (!$urlId) { throw new Exception('缺少网址ID参数'); } // 这里调用您的 RSS 刷新逻辑 // 示例:UrlNav_Plugin::refreshSingleRssUrl($urlId); $this->response->throwJson(array( 'success' => true, 'message' => '已开始重试该网址' )); } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '重试失败: ' . $e->getMessage() )); } } /** * 忽略失败网址(标记为不活跃) */ public function ignoreFailedUrl() { try { $urlId = $this->request->get('url_id'); if (!$urlId) { throw new Exception('缺少网址ID参数'); } $db = UrlNav_Plugin::getDbConnection(); $stmt = $db->prepare("UPDATE urlnav_urls SET is_active = 0 WHERE id = ?"); $stmt->execute([$urlId]); $this->response->throwJson(array( 'success' => true, 'message' => '已忽略该网址' )); } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '操作失败: ' . $e->getMessage() )); } } /** * 启用已停用的网址 */ public function enableFailedUrl() { try { $urlId = $this->request->get('url_id'); if (!$urlId) { throw new Exception('缺少网址ID参数'); } $db = UrlNav_Plugin::getDbConnection(); $stmt = $db->prepare("UPDATE urlnav_urls SET is_active = 1 WHERE id = ?"); $stmt->execute([$urlId]); $this->response->throwJson(array( 'success' => true, 'message' => '已重新启用该网址' )); } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '启用失败: ' . $e->getMessage() )); } } /** * 重试所有失败网址 */ public function retryAllFailedUrls() { try { // 获取所有活跃且有错误的网址 $db = UrlNav_Plugin::getDbConnection(); $stmt = $db->prepare(" SELECT id FROM urlnav_urls WHERE is_active = 1 AND rss_url IS NOT NULL AND rss_url != '' AND last_error IS NOT NULL "); $stmt->execute(); $urls = $stmt->fetchAll(PDO::FETCH_ASSOC); $count = count($urls); $successCount = 0; foreach ($urls as $url) { try { // 调用刷新单个网址的逻辑 // UrlNav_Plugin::refreshSingleRssUrl($url['id']); $successCount++; } catch (Exception $e) { error_log('重试网址 ' . $url['id'] . ' 失败: ' . $e->getMessage()); } } $this->response->throwJson(array( 'success' => true, 'message' => "批量重试完成,成功 {$successCount}/{$count} 个" )); } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '批量重试失败: ' . $e->getMessage() )); } } /** * 获取刷新日志(包含成功和失败的记录) */ public function getRefreshLogs() { try { $page = $this->request->get('page', 1); $pageSize = $this->request->get('pageSize', 20); $search = $this->request->get('search', ''); $type = $this->request->get('type', ''); // 'success', 'failed', 'all' $date = $this->request->get('date', ''); $offset = ($page - 1) * $pageSize; $db = UrlNav_Plugin::getDbConnection(); // 基础查询 $sql = " SELECT u.id, u.title as site_name, u.url, u.rss_url, u.last_refresh, u.last_error, u.star_rating, u.is_active, c.name as category_name, CASE WHEN u.last_error IS NOT NULL AND u.last_error != '' THEN 'failed' ELSE 'success' END as status FROM urlnav_urls u LEFT JOIN urlnav_categories c ON u.category_id = c.id WHERE u.rss_url IS NOT NULL AND u.rss_url != '' AND u.last_refresh IS NOT NULL "; $params = []; // 添加筛选条件 if ($type === 'failed') { $sql .= " AND u.last_error IS NOT NULL AND u.last_error != ''"; } elseif ($type === 'success') { $sql .= " AND (u.last_error IS NULL OR u.last_error = '')"; } if (!empty($search)) { $sql .= " AND (u.title LIKE ? OR u.url LIKE ? OR u.rss_url LIKE ?)"; $searchParam = "%{$search}%"; $params = array_merge($params, [$searchParam, $searchParam, $searchParam]); } if (!empty($date)) { $sql .= " AND DATE(u.last_refresh) = ?"; $params[] = $date; } // 获取总数 $countSql = "SELECT COUNT(*) as total FROM ($sql) as subquery"; $countStmt = $db->prepare($countSql); $countStmt->execute($params); $total = $countStmt->fetch(PDO::FETCH_ASSOC)['total']; // 获取分页数据 $sql .= " ORDER BY u.last_refresh DESC LIMIT ? OFFSET ?"; $params[] = $pageSize; $params[] = $offset; $stmt = $db->prepare($sql); $stmt->execute($params); $logs = $stmt->fetchAll(PDO::FETCH_ASSOC); // 转换时间为北京时间 foreach ($logs as &$log) { $log['last_refresh_beijing'] = $this->convertToBeijingTimeCorrectly($log['last_refresh']); $log['status_label'] = $log['status'] === 'failed' ? '失败' : '成功'; } $this->response->throwJson([ 'success' => true, 'data' => [ 'logs' => $logs, 'pagination' => [ 'total' => (int)$total, 'page' => (int)$page, 'pageSize' => (int)$pageSize, 'totalPages' => ceil($total / $pageSize) ] ] ]); } catch (Exception $e) { $this->response->throwJson([ 'success' => false, 'message' => '获取刷新日志失败: ' . $e->getMessage() ]); } } /** * 获取最近刷新统计(用于仪表板) */ public function getRecentRefreshStats() { try { $days = $this->request->get('days', 7); $db = UrlNav_Plugin::getDbConnection(); // 1. 获取最近N天的刷新统计 $dailyStatsSql = " SELECT DATE(last_refresh) as refresh_date, COUNT(*) as total_refreshes, SUM(CASE WHEN last_error IS NOT NULL AND last_error != '' THEN 1 ELSE 0 END) as failed_refreshes, SUM(CASE WHEN last_error IS NULL OR last_error = '' THEN 1 ELSE 0 END) as success_refreshes FROM urlnav_urls WHERE rss_url IS NOT NULL AND rss_url != '' AND last_refresh IS NOT NULL AND last_refresh >= DATE('now', '-' || ? || ' days') GROUP BY DATE(last_refresh) ORDER BY refresh_date DESC "; $stmt = $db->prepare($dailyStatsSql); $stmt->execute([$days]); $dailyStats = $stmt->fetchAll(PDO::FETCH_ASSOC); // 2. 获取成功/失败总数 $totalStatsSql = " SELECT COUNT(*) as total_urls, SUM(CASE WHEN last_error IS NOT NULL AND last_error != '' THEN 1 ELSE 0 END) as total_failed, SUM(CASE WHEN last_error IS NULL OR last_error = '' THEN 1 ELSE 0 END) as total_success, SUM(CASE WHEN last_error IS NOT NULL AND last_error != '' AND last_refresh >= datetime('now', '-1 day') THEN 1 ELSE 0 END) as recent_failed FROM urlnav_urls WHERE rss_url IS NOT NULL AND rss_url != '' AND is_active = 1 "; $totalStmt = $db->prepare($totalStatsSql); $totalStmt->execute(); $totalStats = $totalStmt->fetch(PDO::FETCH_ASSOC); // 3. 获取最常见的错误 $commonErrorsSql = " SELECT last_error, COUNT(*) as error_count FROM urlnav_urls WHERE last_error IS NOT NULL AND last_error != '' AND last_refresh >= datetime('now', '-7 days') GROUP BY last_error ORDER BY error_count DESC LIMIT 10 "; $errorsStmt = $db->prepare($commonErrorsSql); $errorsStmt->execute(); $commonErrors = $errorsStmt->fetchAll(PDO::FETCH_ASSOC); $this->response->throwJson([ 'success' => true, 'data' => [ 'daily_stats' => $dailyStats, 'total_stats' => $totalStats, 'common_errors' => $commonErrors, 'time_range' => $days . '天' ] ]); } catch (Exception $e) { $this->response->throwJson([ 'success' => false, 'message' => '获取刷新统计失败: ' . $e->getMessage() ]); } } /** * 导出成功网址(修复版) */ public function exportSuccessUrls() { try { $db = UrlNav_Plugin::getDbConnection(); // 🔴 修复:查询最近一次刷新成功的网址 // 方法1:根据last_error为空判断 $stmt = $db->prepare(" SELECT u.id, u.url, u.title as site_name, u.rss_url, u.last_error, u.last_refresh, u.success_count, u.failure_count, u.star_rating, u.is_active, u.is_online, c.name as category_name FROM urlnav_urls u LEFT JOIN urlnav_categories c ON u.category_id = c.id WHERE u.rss_url IS NOT NULL AND u.rss_url != '' AND u.is_active = 1 -- 关键:最近一次刷新没有错误,且成功次数大于失败次数 AND (u.last_error IS NULL OR u.last_error = '') AND (u.success_count > u.failure_count OR u.success_count > 0) AND u.last_refresh IS NOT NULL ORDER BY u.last_refresh DESC "); $stmt->execute(); $urls = $stmt->fetchAll(PDO::FETCH_ASSOC); // 生成CSV内容 $csvContent = "ID,网站名称,网站地址,RSS地址,最后刷新时间(北京时间),成功次数,失败次数,成功率,星级评分,网站状态,分类\n"; foreach ($urls as $url) { // 使用正确的北京时间转换 $refreshTime = $this->convertToBeijingTimeCorrectly($url['last_refresh']); // 计算成功率 $total = $url['success_count'] + $url['failure_count']; $successRate = $total > 0 ? round(($url['success_count'] / $total) * 100, 2) : 0; $csvContent .= sprintf( '"%s","%s","%s","%s","%s","%s","%s","%s%%","%s","%s","%s"' . "\n", $url['id'], $url['site_name'] ?? '', $url['url'] ?? '', $url['rss_url'] ?? '', $refreshTime, $url['success_count'] ?? 0, $url['failure_count'] ?? 0, $successRate, $url['star_rating'] ?? 0, $url['is_online'] ? '在线' : '离线', $url['category_name'] ?? '' ); } $this->response->throwJson(array( 'success' => true, 'csv_data' => $csvContent, 'count' => count($urls) )); } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '导出成功网址失败: ' . $e->getMessage() )); } } public function exportFailedUrls() { try { $db = UrlNav_Plugin::getDbConnection(); // 🔴 直接复制 exportSuccessUrls() 的SQL结构,只改WHERE条件 $stmt = $db->prepare(" SELECT u.id, u.url, u.title as site_name, u.rss_url, u.last_error, u.last_refresh, u.success_count, u.failure_count, u.is_active, // 🔴 注意:失败导出没有 star_rating 字段 u.is_online, c.name as category_name FROM urlnav_urls u LEFT JOIN urlnav_categories c ON u.category_id = c.id WHERE u.rss_url IS NOT NULL AND u.rss_url != '' AND u.is_active = 1 -- 🔴 关键:只改这里,其他完全一样 AND u.last_error IS NOT NULL AND u.last_error != '' ORDER BY u.last_refresh DESC "); $stmt->execute(); $urls = $stmt->fetchAll(PDO::FETCH_ASSOC); // 🔴 直接复制 exportSuccessUrls() 的CSV生成逻辑 $csvContent = "ID,网站名称,网站地址,RSS地址,最后刷新时间(北京时间),错误信息,成功次数,失败次数,成功率,网站状态,分类\n"; foreach ($urls as $url) { // 🔴 使用相同的时间转换方法 $refreshTime = ''; if (!empty($url['last_refresh']) && $url['last_refresh'] != '0000-00-00 00:00:00') { $date = new DateTime($url['last_refresh']); $date->setTimezone(new DateTimeZone('Asia/Shanghai')); $refreshTime = $date->format('Y-m-d H:i:s'); } else { $refreshTime = '从未刷新'; } // 计算成功率 $total = $url['success_count'] + $url['failure_count']; $successRate = $total > 0 ? round(($url['success_count'] / $total) * 100, 2) : 0; $csvContent .= sprintf( '"%s","%s","%s","%s","%s","%s","%s","%s","%s%%","%s","%s"' . "\n", $url['id'], $url['site_name'] ?? '', $url['url'] ?? '', $url['rss_url'] ?? '', $refreshTime, str_replace('"', '""', $url['last_error'] ?? ''), $url['success_count'] ?? 0, $url['failure_count'] ?? 0, $successRate, $url['is_online'] ? '在线' : '离线', $url['category_name'] ?? '' ); } $this->response->throwJson(array( 'success' => true, 'csv_data' => $csvContent, 'count' => count($urls) )); } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '导出失败: ' . $e->getMessage() )); } } /** * 正确的北京时间转换方法 */ private function convertToBeijingTimeCorrectly($datetime) { if (empty($datetime) || $datetime == '0000-00-00 00:00:00') { return ''; } try { // 方法1:明确指定输入时区(如果知道数据库存储的时区) // 假设数据库存储的是UTC时间 $utc = new DateTimeZone('UTC'); $beijing = new DateTimeZone('Asia/Shanghai'); // 创建DateTime对象,明确指定输入时区为UTC $date = DateTime::createFromFormat('Y-m-d H:i:s', $datetime, $utc); if ($date === false) { // 如果格式不匹配,尝试其他方法 $date = new DateTime($datetime, $utc); } // 转换为北京时间 $date->setTimezone($beijing); return $date->format('Y-m-d H:i:s'); } catch (Exception $e) { // 方法2:使用你的Rss.php中的方法 return $this->convertUsingRssMethod($datetime); } } /** * 复制Rss.php中的convertToBeijingTime方法 */ private function convertUsingRssMethod($datetime) { if (empty($datetime) || $datetime == '0000-00-00 00:00:00' || $datetime == '0000-00-00 00:00') { return ''; } try { // 创建DateTime对象 $date = new DateTime($datetime); // 如果已经是北京时间,直接返回 if ($date->getTimezone()->getName() == 'Asia/Shanghai') { return $date->format('Y-m-d H:i:s'); } // 转换为北京时间 $date->setTimezone(new DateTimeZone('Asia/Shanghai')); return $date->format('Y-m-d H:i:s'); } catch (Exception $e) { // 如果解析失败,尝试简单的时间转换 $timestamp = strtotime($datetime); if ($timestamp !== false) { // 服务器时间已经是北京时间,直接返回 return date('Y-m-d H:i:s', $timestamp); } return $datetime; } } /** * 获取状态检查定时任务统计 */ public function getStatusCronStats() { try { $stats = UrlNav_Plugin::getStatusCronStats(); $this->response->throwJson(array( 'success' => true, 'data' => $stats )); } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '获取状态检查定时任务统计失败: ' . $e->getMessage() )); } } /** * 检查网站状态 - 修复版 */ public function checkStatus() { try { // 检查是否是批量检查 $urlIds = $this->request->get('url_ids'); $batchInfo = $this->request->get('batch_info'); // 解析URL IDs $idArray = null; if ($urlIds && $urlIds !== '') { // 处理不同的ID格式 if (is_array($urlIds)) { $idArray = $urlIds; } else if (strpos($urlIds, ',') !== false) { // 逗号分隔的字符串 $idArray = array_map('trim', explode(',', $urlIds)); $idArray = array_filter($idArray, function($id) { return is_numeric($id) && $id > 0; }); } else if (is_numeric($urlIds)) { // 单个ID $idArray = [$urlIds]; } } // 解析批次信息 $batchData = null; if ($batchInfo && is_string($batchInfo)) { $batchData = json_decode($batchInfo, true); } else if (is_array($batchInfo)) { $batchData = $batchInfo; } // 如果是选中的网址,需要特殊处理批次信息 if (!empty($idArray) && $batchData) { // 重新计算批次信息,因为选中的网址总数可能和全部网址不同 $batchSize = $batchData['size'] ?? 10; $totalSelected = count($idArray); $totalBatches = ceil($totalSelected / $batchSize); $batchNumber = $batchData['batch'] ?? 1; // 确保批次号不超过总批次 if ($batchNumber > $totalBatches) { return $this->response->throwJson([ 'success' => true, 'message' => '所有选中的网址已检查完成', 'total' => 0, 'success_count' => 0, 'failed_count' => 0, 'has_more' => false ]); } // 获取当前批次要检查的ID $offset = ($batchNumber - 1) * $batchSize; $batchIds = array_slice($idArray, $offset, $batchSize); // 调用检查函数 $result = UrlNav_Plugin::manualCheckStatus($batchIds, true, $batchInfo); // 更新批次信息 $result['batch_number'] = $batchNumber; $result['total_batches'] = $totalBatches; $result['batch_info'] = json_encode([ 'batch' => $batchNumber, 'total' => $totalBatches, 'size' => $batchSize, 'selected_ids' => implode(',', $idArray) // 记录所有选中的ID ]); $this->response->throwJson($result); return; } // 如果没有选中网址,检查全部(已有的逻辑) $result = UrlNav_Plugin::manualCheckStatus(null, true, $batchInfo); $this->response->throwJson($result); } catch (Exception $e) { $this->response->throwJson([ 'success' => false, 'message' => '检查失败: ' . $e->getMessage() ]); } } // 在 Action.php 中修复 checkSingleStatus 方法 public function checkSingleStatus() { try { $urlId = $this->request->get('id'); if (empty($urlId)) { $urlId = $this->request->get('url_id'); } if (empty($urlId)) { throw new Exception('ID不能为空'); } $result = UrlNav_Plugin::manualCheckStatus($urlId); // 修复:检查返回的数据结构 if (isset($result['success']) && $result['success'] === true) { if (isset($result['results']) && is_array($result['results'])) { // 如果是批量检查的结果 foreach ($result['results'] as $urlId => $checkResult) { $this->response->throwJson([ 'success' => true, 'message' => '检查完成', 'data' => $checkResult ]); return; } } else { // 如果是单次检查的结果 $this->response->throwJson([ 'success' => true, 'message' => '检查完成', 'data' => $result['data'] ?? $result ]); return; } } else { // 检查失败 $this->response->throwJson([ 'success' => false, 'message' => $result['message'] ?? '检查失败' ]); } } catch (Exception $e) { $this->response->throwJson([ 'success' => false, 'message' => '检查失败: ' . $e->getMessage() ]); } } /** * 获取状态统计 */ public function getStatusStats() { try { $stats = UrlNav_Plugin::getStatusStats(); $this->response->throwJson([ 'success' => true, 'data' => $stats ]); } catch (Exception $e) { $this->response->throwJson([ 'success' => false, 'message' => '获取统计失败: ' . $e->getMessage() ]); } } /** * 添加分类 - 模仿Collection的add方法 */ public function addCategory() { try { $name = $this->request->get('name'); $description = $this->request->get('description'); $sort_order = $this->request->get('sort_order', 0); if (empty($name)) { throw new Exception('分类名称不能为空'); } // 检查名称是否已存在 - 模仿Collection插件 $stmt = $this->db->prepare("SELECT COUNT(*) FROM urlnav_categories WHERE name = ? AND is_active = 1"); $stmt->execute(array($name)); $count = $stmt->fetchColumn(); if ($count > 0) { throw new Exception('分类名称已存在'); } $stmt = $this->db->prepare("INSERT INTO urlnav_categories (name, description, sort_order) VALUES (?, ?, ?)"); $stmt->execute(array($name, $description, $sort_order)); $id = $this->db->lastInsertId(); $this->response->throwJson(array( 'success' => true, 'message' => '分类添加成功', 'data' => array('id' => $id) )); } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '添加失败: ' . $e->getMessage() )); } } /** * 更新分类 - 模仿Collection的update方法 */ public function updateCategory() { try { $id = $this->request->get('id'); $name = $this->request->get('name'); $description = $this->request->get('description'); $sort_order = $this->request->get('sort_order', 0); if (empty($id) || empty($name)) { throw new Exception('ID和分类名称不能为空'); } // 检查名称是否已存在(排除自身)- 模仿Collection插件 $stmt = $this->db->prepare("SELECT COUNT(*) FROM urlnav_categories WHERE name = ? AND id != ? AND is_active = 1"); $stmt->execute(array($name, $id)); $count = $stmt->fetchColumn(); if ($count > 0) { throw new Exception('分类名称已存在'); } $stmt = $this->db->prepare("UPDATE urlnav_categories SET name = ?, description = ?, sort_order = ? WHERE id = ?"); $result = $stmt->execute(array($name, $description, $sort_order, $id)); if ($result) { $this->response->throwJson(array( 'success' => true, 'message' => '分类更新成功' )); } else { throw new Exception('分类不存在或更新失败'); } } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '更新失败: ' . $e->getMessage() )); } } /** * 删除分类 - 模仿Collection的delete方法 */ public function deleteCategory() { try { $id = $this->request->get('id'); if (empty($id)) { throw new Exception('ID不能为空'); } // 检查分类下是否有网址 $checkStmt = $this->db->prepare("SELECT COUNT(*) FROM urlnav_urls WHERE category_id = ? AND is_active = 1"); $checkStmt->execute(array($id)); $urlCount = $checkStmt->fetchColumn(); if ($urlCount > 0) { throw new Exception('该分类下还有' . $urlCount . '个网址,无法删除'); } $stmt = $this->db->prepare("UPDATE urlnav_categories SET is_active = 0 WHERE id = ?"); $result = $stmt->execute(array($id)); if ($result) { $this->response->throwJson(array( 'success' => true, 'message' => '分类删除成功' )); } else { throw new Exception('分类不存在或删除失败'); } } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '删除失败: ' . $e->getMessage() )); } } /** * 批量删除分类 - 模仿Collection的batchDelete方法 */ public function batchDeleteCategories() { try { // Collection插件处理数组的方式 $ids = $this->request->filter('int')->getArray('category'); if (empty($ids)) { $rawIds = $this->request->get('category'); if (!empty($rawIds)) { $ids = is_array($rawIds) ? $rawIds : array($rawIds); $ids = array_map('intval', $ids); } } if (empty($ids) || !is_array($ids)) { throw new Exception('请选择要删除的分类'); } $ids = array_filter($ids, function($id) { return is_numeric($id) && $id > 0; }); if (empty($ids)) { throw new Exception('无效的分类ID'); } // 检查是否有网址关联到这些分类 $placeholders = implode(',', array_fill(0, count($ids), '?')); $checkStmt = $this->db->prepare("SELECT COUNT(*) FROM urlnav_urls WHERE category_id IN ($placeholders) AND is_active = 1"); $checkStmt->execute($ids); $urlCount = $checkStmt->fetchColumn(); if ($urlCount > 0) { throw new Exception('选中的分类下还有' . $urlCount . '个网址,无法删除'); } $stmt = $this->db->prepare("UPDATE urlnav_categories SET is_active = 0 WHERE id IN ($placeholders)"); $result = $stmt->execute($ids); if ($result) { $this->response->throwJson(array( 'success' => true, 'message' => '分类批量删除成功' )); } else { throw new Exception('删除失败'); } } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '批量删除失败: ' . $e->getMessage() )); } } /** * 获取单个分类 - 模仿Collection的get方法 */ public function getCategory() { try { $id = $this->request->get('id'); if (empty($id)) { throw new Exception('ID不能为空'); } $stmt = $this->db->prepare("SELECT * FROM urlnav_categories WHERE id = ?"); $stmt->execute(array($id)); $category = $stmt->fetch(PDO::FETCH_ASSOC); if ($category) { $this->response->throwJson(array( 'success' => true, 'data' => $category )); } else { throw new Exception('分类不存在'); } } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '查询失败: ' . $e->getMessage() )); } } /** * 获取所有分类 - 模仿Collection的getAll方法 */ public function getAllCategories() { try { $stmt = $this->db->query("SELECT * FROM urlnav_categories WHERE is_active = 1 ORDER BY sort_order, created_at DESC"); $categories = $stmt->fetchAll(PDO::FETCH_ASSOC); $this->response->throwJson(array( 'success' => true, 'data' => $categories )); } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '查询失败: ' . $e->getMessage() )); } } /** * 添加网址 - 模仿Collection的add方法 */ public function addUrl() { try { $title = $this->request->get('title'); $url = $this->request->get('url'); $description = $this->request->get('description'); $rss_url = $this->request->get('rss_url', ''); $category_id = $this->request->get('category_id'); $sort_order = $this->request->get('sort_order', 0); if (empty($title)) { throw new Exception('网站标题不能为空'); } if (empty($url)) { throw new Exception('网站地址不能为空'); } // 验证URL格式 if (!UrlNav_Plugin::validateUrl($url)) { throw new Exception('网站地址格式无效,请使用http://或https://开头的完整地址'); } // 如果提供了RSS地址,验证格式 if (!empty($rss_url) && !UrlNav_Plugin::validateUrl($rss_url)) { throw new Exception('RSS地址格式无效,请使用http://或https://开头的完整地址'); } // 检查标题是否已存在 $stmt = $this->db->prepare("SELECT COUNT(*) FROM urlnav_urls WHERE title = ? AND is_active = 1"); $stmt->execute(array($title)); $count = $stmt->fetchColumn(); if ($count > 0) { throw new Exception('网站标题已存在'); } // 获取星级评分 $starRating = $this->request->get('star_rating', 0); $starRating = intval($starRating); // 限制在0-3之间 $starRating = max(0, min(3, $starRating)); // 检查URL是否已存在 $stmt = $this->db->prepare("SELECT COUNT(*) FROM urlnav_urls WHERE url = ? AND is_active = 1"); $stmt->execute(array($url)); $count = $stmt->fetchColumn(); if ($count > 0) { throw new Exception('网站地址已存在'); } $stmt = $this->db->prepare("INSERT INTO urlnav_urls (title, url, description, rss_url, star_rating, category_id, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)"); $stmt->execute(array($title, $url, $description, $rss_url, $starRating, $category_id, $sort_order)); $id = $this->db->lastInsertId(); $this->response->throwJson(array( 'success' => true, 'message' => '网址添加成功', 'data' => array('id' => $id) )); } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '添加失败: ' . $e->getMessage() )); } } /** * 更新网址 - 模仿Collection的update方法 */ /** * 更新网址 - 模仿Collection的update方法 */ public function updateUrl() { try { $id = $this->request->get('id'); $title = $this->request->get('title'); $url = $this->request->get('url'); $description = $this->request->get('description'); $rss_url = $this->request->get('rss_url', ''); $category_id = $this->request->get('category_id'); $sort_order = $this->request->get('sort_order', 0); // 新增:获取星级评分 $star_rating = $this->request->get('star_rating', 0); $star_rating = intval($star_rating); // 限制在0-3之间 $star_rating = max(0, min(3, $star_rating)); if (empty($id) || empty($title) || empty($url)) { throw new Exception('ID、标题和网址不能为空'); } // 验证URL格式 if (!UrlNav_Plugin::validateUrl($url)) { throw new Exception('网站地址格式无效,请使用http://或https://开头的完整地址'); } // 如果提供了RSS地址,验证格式 if (!empty($rss_url) && !UrlNav_Plugin::validateUrl($rss_url)) { throw new Exception('RSS地址格式无效,请使用http://或https://开头的完整地址'); } // 检查标题是否已存在(排除自身) $stmt = $this->db->prepare("SELECT COUNT(*) FROM urlnav_urls WHERE title = ? AND id != ? AND is_active = 1"); $stmt->execute(array($title, $id)); $count = $stmt->fetchColumn(); if ($count > 0) { throw new Exception('网站标题已存在'); } // 检查URL是否已存在(排除自身) $stmt = $this->db->prepare("SELECT COUNT(*) FROM urlnav_urls WHERE url = ? AND id != ? AND is_active = 1"); $stmt->execute(array($url, $id)); $count = $stmt->fetchColumn(); if ($count > 0) { throw new Exception('网站地址已存在'); } // 修改这里:添加 star_rating 字段 $stmt = $this->db->prepare("UPDATE urlnav_urls SET title = ?, url = ?, description = ?, rss_url = ?, category_id = ?, sort_order = ?, star_rating = ? WHERE id = ?"); $result = $stmt->execute(array($title, $url, $description, $rss_url, $category_id, $sort_order, $star_rating, $id)); if ($result) { $this->response->throwJson(array( 'success' => true, 'message' => '网址更新成功' )); } else { throw new Exception('网址不存在或更新失败'); } } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '更新失败: ' . $e->getMessage() )); } } /** * 删除网址 - 模仿Collection的delete方法 */ public function deleteUrl() { try { $id = $this->request->get('id'); if (empty($id)) { throw new Exception('ID不能为空'); } $stmt = $this->db->prepare("UPDATE urlnav_urls SET is_active = 0 WHERE id = ?"); $result = $stmt->execute(array($id)); if ($result) { $this->response->throwJson(array( 'success' => true, 'message' => '网址删除成功' )); } else { throw new Exception('网址不存在或删除失败'); } } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '删除失败: ' . $e->getMessage() )); } } /** * 批量删除网址 - 模仿Collection的batchDelete方法 */ public function batchDeleteUrls() { try { // Collection插件处理数组的方式 $ids = $this->request->filter('int')->getArray('url'); if (empty($ids)) { $rawIds = $this->request->get('url'); if (!empty($rawIds)) { $ids = is_array($rawIds) ? $rawIds : array($rawIds); $ids = array_map('intval', $ids); } } if (empty($ids) || !is_array($ids)) { throw new Exception('请选择要删除的网址'); } $ids = array_filter($ids, function($id) { return is_numeric($id) && $id > 0; }); if (empty($ids)) { throw new Exception('无效的网址ID'); } $placeholders = implode(',', array_fill(0, count($ids), '?')); $stmt = $this->db->prepare("UPDATE urlnav_urls SET is_active = 0 WHERE id IN ($placeholders)"); $result = $stmt->execute($ids); if ($result) { $this->response->throwJson(array( 'success' => true, 'message' => '网址批量删除成功' )); } else { throw new Exception('删除失败'); } } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '批量删除失败: ' . $e->getMessage() )); } } /** * 获取单个网址 - 模仿Collection的get方法 */ public function getUrl() { try { $id = $this->request->get('id'); if (empty($id)) { throw new Exception('ID不能为空'); } $stmt = $this->db->prepare("SELECT * FROM urlnav_urls WHERE id = ?"); $stmt->execute(array($id)); $url = $stmt->fetch(PDO::FETCH_ASSOC); if ($url) { $this->response->throwJson(array( 'success' => true, 'data' => $url )); } else { throw new Exception('网址不存在'); } } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '查询失败: ' . $e->getMessage() )); } } /** * 获取所有网址 - 模仿Collection的getAll方法 */ public function getAllUrls() { try { $stmt = $this->db->query("SELECT * FROM urlnav_urls WHERE is_active = 1 ORDER BY sort_order, created_at DESC"); $urls = $stmt->fetchAll(PDO::FETCH_ASSOC); $this->response->throwJson(array( 'success' => true, 'data' => $urls )); } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '查询失败: ' . $e->getMessage() )); } } /** * 导出OPML格式数据 */ public function exportOpml() { try { // 获取所有分类和网址 $categories = $this->db->query("SELECT * FROM urlnav_categories WHERE is_active = 1 ORDER BY sort_order")->fetchAll(PDO::FETCH_ASSOC); $urls = $this->db->query("SELECT * FROM urlnav_urls WHERE is_active = 1 AND rss_url != '' AND rss_url IS NOT NULL AND TRIM(rss_url) != '' ORDER BY sort_order")->fetchAll(PDO::FETCH_ASSOC); // 按分类组织网址 $urlsByCategory = []; foreach ($urls as $url) { $categoryId = $url['category_id'] ? $url['category_id'] : 'uncategorized'; if (!isset($urlsByCategory[$categoryId])) { $urlsByCategory[$categoryId] = []; } $urlsByCategory[$categoryId][] = $url; } // 创建OPML XML $xml = new DOMDocument('1.0', 'UTF-8'); $xml->formatOutput = true; $opml = $xml->createElement('opml'); $opml->setAttribute('version', '2.0'); $opml->setAttribute('xmlns:frss', 'https://freshrss.org/opml'); $xml->appendChild($opml); // head部分 $head = $xml->createElement('head'); $opml->appendChild($head); $title = $xml->createElement('title', 'UrlNav RSS订阅'); $head->appendChild($title); $dateCreated = $xml->createElement('dateCreated', date('r')); $head->appendChild($dateCreated); // body部分 $body = $xml->createElement('body'); $opml->appendChild($body); // 处理有分类的网址 foreach ($categories as $category) { $categoryId = $category['id']; if (isset($urlsByCategory[$categoryId]) && !empty($urlsByCategory[$categoryId])) { $outline = $xml->createElement('outline'); $outline->setAttribute('text', $category['name']); foreach ($urlsByCategory[$categoryId] as $url) { $feedOutline = $xml->createElement('outline'); $feedOutline->setAttribute('text', $url['title']); $feedOutline->setAttribute('type', 'rss'); $feedOutline->setAttribute('xmlUrl', $url['rss_url']); $feedOutline->setAttribute('htmlUrl', $url['url']); if (!empty($url['description'])) { $feedOutline->setAttribute('description', $url['description']); } $outline->appendChild($feedOutline); } $body->appendChild($outline); } } // 处理未分类的网址 if (isset($urlsByCategory['uncategorized']) && !empty($urlsByCategory['uncategorized'])) { $outline = $xml->createElement('outline'); $outline->setAttribute('text', '未分类'); foreach ($urlsByCategory['uncategorized'] as $url) { $feedOutline = $xml->createElement('outline'); $feedOutline->setAttribute('text', $url['title']); $feedOutline->setAttribute('type', 'rss'); $feedOutline->setAttribute('xmlUrl', $url['rss_url']); $feedOutline->setAttribute('htmlUrl', $url['url']); if (!empty($url['description'])) { $feedOutline->setAttribute('description', $url['description']); } $outline->appendChild($feedOutline); } $body->appendChild($outline); } // 设置响应头 $filename = 'urlnav_feeds_' . date('Y-m-d') . '.opml.xml'; $this->response->setHeader('Content-Type', 'application/xml'); $this->response->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"'); $this->response->setHeader('Cache-Control', 'no-cache, must-revalidate'); $this->response->setHeader('Pragma', 'no-cache'); $this->response->setHeader('Expires', '0'); echo $xml->saveXML(); } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '导出失败: ' . $e->getMessage() )); } } /** * 导入OPML格式数据 - 修复版 */ public function importOpml() { try { // 检查是否有文件上传 if (!isset($_FILES['opml_file']) || $_FILES['opml_file']['error'] !== UPLOAD_ERR_OK) { throw new Exception('请选择要上传的OPML文件,错误代码: ' . ($_FILES['opml_file']['error'] ?? '未知')); } $file = $_FILES['opml_file']; // 检查文件大小(限制为2MB) if ($file['size'] > 2 * 1024 * 1024) { throw new Exception('文件大小超过2MB限制'); } if ($file['size'] == 0) { throw new Exception('文件为空'); } // 读取文件内容 $content = file_get_contents($file['tmp_name']); if (empty($content)) { throw new Exception('文件内容为空'); } // 检查文件编码并转换为UTF-8 $encoding = mb_detect_encoding($content, ['UTF-8', 'GBK', 'GB2312', 'BIG5', 'ASCII'], true); if ($encoding && $encoding != 'UTF-8') { $content = mb_convert_encoding($content, 'UTF-8', $encoding); } // 尝试移除可能的BOM头 if (substr($content, 0, 3) == "\xEF\xBB\xBF") { $content = substr($content, 3); } // 解析XML libxml_use_internal_errors(true); $xml = simplexml_load_string($content); if ($xml === false) { $errors = libxml_get_errors(); $errorMsg = 'XML解析失败: '; foreach ($errors as $error) { $errorMsg .= '行 ' . $error->line . ', 列 ' . $error->column . ': ' . $error->message . '; '; } libxml_clear_errors(); throw new Exception($errorMsg); } // 检查是否是有效的OPML格式 if (!isset($xml->head) || !isset($xml->body)) { throw new Exception('无效的OPML格式:缺少head或body元素'); } $importResults = [ 'total' => 0, 'success' => 0, 'failed' => 0, 'categories_created' => 0, 'urls_added' => 0, 'urls_skipped' => 0, 'details' => [] ]; // 开始事务 $this->db->beginTransaction(); try { // 获取现有分类映射 $existingCategories = []; $stmt = $this->db->query("SELECT id, name FROM urlnav_categories WHERE is_active = 1"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $existingCategories[$row['name']] = $row['id']; } // 获取现有网址映射 $existingUrls = []; $existingRssUrls = []; $stmt = $this->db->query("SELECT url, rss_url, title FROM urlnav_urls WHERE is_active = 1"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { if (!empty($row['url'])) { $existingUrls[$row['url']] = true; } if (!empty($row['rss_url'])) { $existingRssUrls[$row['rss_url']] = true; } } // 处理OPML结构 - 简化版本 $processedCount = 0; // 遍历body下的所有outline元素 foreach ($xml->body->children() as $outline) { $processedCount++; // 检查是否有子元素(可能是分类) $hasChildren = $outline->children()->count() > 0; if ($hasChildren) { // 可能是分类 $categoryName = (string)$outline['text']; if (empty($categoryName)) { $categoryName = '未命名分类_' . $processedCount; } // 处理分类 if (!isset($existingCategories[$categoryName])) { // 创建新分类 $stmt = $this->db->prepare("INSERT INTO urlnav_categories (name, description, sort_order) VALUES (?, ?, ?)"); $stmt->execute([$categoryName, '从OPML导入', 999]); $categoryId = $this->db->lastInsertId(); $existingCategories[$categoryName] = $categoryId; $importResults['categories_created']++; $importResults['details'][] = [ 'type' => 'category', 'name' => $categoryName, 'status' => 'created', 'message' => '分类创建成功' ]; } else { $categoryId = $existingCategories[$categoryName]; } // 处理该分类下的feed foreach ($outline->children() as $feed) { $importResults['total']++; $title = (string)$feed['text']; $rssUrl = (string)$feed['xmlUrl']; $htmlUrl = (string)$feed['htmlUrl']; $description = isset($feed['description']) ? (string)$feed['description'] : ''; // 如果htmlUrl为空,尝试使用link属性 if (empty($htmlUrl) && isset($feed['link'])) { $htmlUrl = (string)$feed['link']; } // 如果htmlUrl仍然为空,使用RSS地址作为网站地址 if (empty($htmlUrl) && !empty($rssUrl)) { $htmlUrl = $rssUrl; } // 验证数据 if (empty($title) && empty($rssUrl)) { $importResults['failed']++; $importResults['details'][] = [ 'type' => 'url', 'title' => '未命名', 'status' => 'failed', 'message' => '标题和RSS地址都为空' ]; continue; } // 如果没有标题,使用RSS地址的一部分作为标题 if (empty($title)) { $parsedUrl = parse_url($rssUrl); $title = $parsedUrl['host'] ?? '未命名网站'; } // 验证URL格式 if (!empty($rssUrl) && !UrlNav_Plugin::validateUrl($rssUrl)) { // 尝试修复URL if (!preg_match('/^https?:\/\//', $rssUrl)) { $rssUrl = 'https://' . $rssUrl; if (!UrlNav_Plugin::validateUrl($rssUrl)) { $importResults['failed']++; $importResults['details'][] = [ 'type' => 'url', 'title' => $title, 'status' => 'failed', 'message' => 'RSS地址格式无效: ' . $rssUrl ]; continue; } } } if (!empty($htmlUrl) && !UrlNav_Plugin::validateUrl($htmlUrl)) { // 尝试修复URL if (!preg_match('/^https?:\/\//', $htmlUrl)) { $htmlUrl = 'https://' . $htmlUrl; if (!UrlNav_Plugin::validateUrl($htmlUrl)) { $importResults['failed']++; $importResults['details'][] = [ 'type' => 'url', 'title' => $title, 'status' => 'failed', 'message' => '网站地址格式无效: ' . $htmlUrl ]; continue; } } } // 检查网址是否已存在 $skipDuplicate = false; if (!empty($htmlUrl) && isset($existingUrls[$htmlUrl])) { $skipDuplicate = true; } if (!empty($rssUrl) && isset($existingRssUrls[$rssUrl])) { $skipDuplicate = true; } // 检查标题是否已存在 if (!$skipDuplicate) { $checkStmt = $this->db->prepare("SELECT COUNT(*) FROM urlnav_urls WHERE title = ? AND is_active = 1"); $checkStmt->execute([$title]); if ($checkStmt->fetchColumn() > 0) { $skipDuplicate = true; } } if ($skipDuplicate) { $importResults['urls_skipped']++; $importResults['details'][] = [ 'type' => 'url', 'title' => $title, 'status' => 'skipped', 'message' => '网址已存在' ]; continue; } try { // 插入新网址 $stmt = $this->db->prepare(" INSERT INTO urlnav_urls (title, url, description, rss_url, category_id, sort_order) VALUES (?, ?, ?, ?, ?, ?) "); $result = $stmt->execute([ $title, $htmlUrl, $description, $rssUrl, $categoryId, 999 ]); if ($result) { if (!empty($htmlUrl)) { $existingUrls[$htmlUrl] = true; } if (!empty($rssUrl)) { $existingRssUrls[$rssUrl] = true; } $importResults['success']++; $importResults['urls_added']++; $importResults['details'][] = [ 'type' => 'url', 'title' => $title, 'status' => 'added', 'message' => '网址添加成功' ]; } else { $importResults['failed']++; $importResults['details'][] = [ 'type' => 'url', 'title' => $title, 'status' => 'failed', 'message' => '数据库插入失败' ]; } } catch (Exception $e) { $importResults['failed']++; $importResults['details'][] = [ 'type' => 'url', 'title' => $title, 'status' => 'failed', 'message' => '数据库错误: ' . $e->getMessage() ]; } } } else { // 可能是独立的feed $importResults['total']++; $title = (string)$outline['text']; $rssUrl = (string)$outline['xmlUrl']; $htmlUrl = (string)$outline['htmlUrl']; $description = isset($outline['description']) ? (string)$outline['description'] : ''; // 验证数据 if (empty($title) && empty($rssUrl)) { continue; // 跳过无效条目 } // 如果没有标题,使用RSS地址的一部分作为标题 if (empty($title)) { $parsedUrl = parse_url($rssUrl); $title = $parsedUrl['host'] ?? '未命名网站'; } // 使用默认分类 $defaultCategoryName = 'OPML导入'; if (!isset($existingCategories[$defaultCategoryName])) { $stmt = $this->db->prepare("INSERT INTO urlnav_categories (name, description, sort_order) VALUES (?, ?, ?)"); $stmt->execute([$defaultCategoryName, '从OPML导入的网址', 999]); $categoryId = $this->db->lastInsertId(); $existingCategories[$defaultCategoryName] = $categoryId; $importResults['categories_created']++; } else { $categoryId = $existingCategories[$defaultCategoryName]; } // 检查是否已存在 $skipDuplicate = false; if (!empty($htmlUrl) && isset($existingUrls[$htmlUrl])) { $skipDuplicate = true; } if (!empty($rssUrl) && isset($existingRssUrls[$rssUrl])) { $skipDuplicate = true; } if ($skipDuplicate) { $importResults['urls_skipped']++; continue; } // 插入新网址 $stmt = $this->db->prepare(" INSERT INTO urlnav_urls (title, url, description, rss_url, category_id, sort_order) VALUES (?, ?, ?, ?, ?, ?) "); $result = $stmt->execute([ $title, $htmlUrl, $description, $rssUrl, $categoryId, 999 ]); if ($result) { if (!empty($htmlUrl)) { $existingUrls[$htmlUrl] = true; } if (!empty($rssUrl)) { $existingRssUrls[$rssUrl] = true; } $importResults['success']++; $importResults['urls_added']++; $importResults['details'][] = [ 'type' => 'url', 'title' => $title, 'status' => 'added', 'message' => '网址添加成功' ]; } } } // 提交事务 $this->db->commit(); // 检查是否有导入数据 if ($importResults['total'] == 0) { throw new Exception('OPML文件中没有找到有效的RSS订阅数据'); } $message = sprintf( '导入完成:共处理%d个项目,成功%d个,失败%d个。创建%d个分类,添加%d个网址,跳过%d个重复项。', $importResults['total'], $importResults['success'], $importResults['failed'], $importResults['categories_created'], $importResults['urls_added'], $importResults['urls_skipped'] ); $this->response->throwJson(array( 'success' => true, 'message' => $message, 'data' => $importResults )); } catch (Exception $e) { $this->db->rollBack(); throw $e; } } catch (Exception $e) { $this->response->throwJson(array( 'success' => false, 'message' => '导入失败: ' . $e->getMessage() )); } } /** * 获取网站信息(标题、描述、RSS地址) */ public function fetchRss() { try { $url = $this->request->get('url'); if (empty($url)) { throw new Exception('网址不能为空'); } // 验证URL格式 if (!UrlNav_Plugin::validateUrl($url)) { throw new Exception('网站地址格式无效'); } // 获取网页内容 $html = @file_get_contents($url, false, stream_context_create(array( 'http' => array( 'timeout' => 10, 'header' => "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36\r\n" . "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\r\n" . "Accept-Language: zh-CN,zh;q=0.8,en;q=0.6\r\n" ) ))); if ($html === false) { throw new Exception('无法访问该网站'); } // 检测编码并转换为UTF-8 $encoding = mb_detect_encoding($html, array('UTF-8', 'GBK', 'GB2312', 'BIG5', 'ASCII'), true); if ($encoding && $encoding != 'UTF-8') { $html = mb_convert_encoding($html, 'UTF-8', $encoding); } // 移除BOM头 if (substr($html, 0, 3) == "\xEF\xBB\xBF") { $html = substr($html, 3); } // 1. 获取网站标题 $siteTitle = ''; if (preg_match('/