// +---------------------------------------------------------------------- declare(strict_types=1); namespace addon\haonav\controller; use ywxapp\controller\FrontendBase; use think\Request; use think\Response; use think\exception\ValidateException; use think\facade\Db; use addon\haonav\model\Links as LinksModel; use addon\haonav\model\Category as CategoryModel; use addon\haonav\model\Configure as ConfigureModel; use addon\haonav\model\Ad as AdModel; use addon\haonav\model\Apply as ApplyModel; /** * Index 类 * * @author ywxapp */ class Index extends FrontendBase { /** * Summary of needLogin * @var array */ protected $noNeedLogin = ['*']; /** * Summary of needRight * @var array */ protected $noNeedVerify = ['*']; public function test() { $routes = \think\facade\Route::getRules(); dump($routes); } /** * 控制器初始化 * @return void */ protected function initialize() {} /** * 读取导航相关配置 */ protected function siteConfig(): array { return [ 'default_engine' => ConfigureModel::getVal('default_engine', 'baidu'), 'enable_submit' => (int)ConfigureModel::getVal('enable_submit', '1'), 'enable_hot_api' => (int)ConfigureModel::getVal('enable_hot_api', '0'), ]; } /** * 读取 SEO 配置(后台「导航配置 > seo」分组可改,缺省用内置文案) */ protected function seoConfig(): array { return [ 'title' => (string)ConfigureModel::getVal('seo_title', '网址导航 - 精选实用网站大全'), 'keywords' => (string)ConfigureModel::getVal('seo_keywords', '网址导航,常用网址,网站大全,上网导航'), 'description' => (string)ConfigureModel::getVal('seo_description', '简洁实用的网址导航,收录精选优质网站,支持分类浏览、站内搜索、热门排行与网址投稿。'), ]; } /** * 统一注入 SEO 相关模板变量(seo/siteUrl) */ protected function assignSeo(): array { $seo = $this->seoConfig(); $this->view->assign('seo', $seo); $this->view->assign('siteUrl', $this->request->domain()); return $seo; } /** * 首页 */ public function index() { $data = CategoryModel::with(['links' => function ($query) { $query->where('status', 1)->limit(10)->order('click_count', 'desc'); }])->where('status', 1)->order('sort', 'desc')->select(); $hotspot = LinksModel::where('is_hot', 1)->where('status', 1)->limit(18)->order('click_count', 'desc')->select(); $links = LinksModel::where('is_recommend', 1)->where('status', 1)->limit(10)->order('sort', 'desc')->select(); $this->view->assign('hotspot', $hotspot); $this->view->assign('data', $data); $this->view->assign('links', $links); $this->view->assign('config', $this->siteConfig()); $seo = $this->assignSeo(); $domain = $this->request->domain(); // 首页 JSON-LD:WebSite + SearchAction(搜索引擎站内搜索直达框) $this->view->assign('jsonld', json_encode([ '@context' => 'https://schema.org', '@type' => 'WebSite', 'name' => $seo['title'], 'description' => $seo['description'], 'url' => $domain . '/haonav/index.html', 'potentialAction' => [ '@type' => 'SearchAction', 'target' => $domain . '/haonav/search.html?q={search_term_string}', 'query-input' => 'required name=search_term_string', ], ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); $this->assignAds(); return $this->view->fetch(); } /** * 站内搜索 * 普通请求渲染搜索结果页;ajax=1 返回 JSON(供前端无刷新搜索) */ public function search() { $q = $this->request->param('q', ''); $page = $this->request->param('page/d', 1); $limit = 20; if ($this->request->isAjax() || $this->request->param('ajax/d', 0)) { $data = LinksModel::search($q, $page, $limit); $this->result->setCount($data->total()); $this->result->success($data->items()); } $results = []; if ($q !== '') { $results = LinksModel::search($q, 1, 50)->items(); } $this->view->assign('q', $q); $this->view->assign('results', $results); $this->view->assign('config', $this->siteConfig()); $this->assignSeo(); return $this->view->fetch('search'); } /** * 搜索建议(自动补全) */ public function suggest() { $q = trim((string)$this->request->param('q', '')); $list = []; if ($q !== '') { // 标题/关键词/网址/拼音(首字母+全拼)均可命中 $isAlpha = (bool)preg_match('/^[a-zA-Z]+$/', $q); $list = LinksModel::where('status', 1) ->where(function ($query) use ($q, $isAlpha) { $query->where('title', 'like', "%{$q}%") ->whereOr('keywords', 'like', "%{$q}%") ->whereOr('url', 'like', "%{$q}%"); if ($isAlpha) { $query->whereOr('pinyin', 'like', '%' . strtolower($q) . '%'); } }) ->field('id,title,url') ->order('click_count', 'desc') ->limit(10) ->select(); } return $this->result->success($list); } /** * 网站评分(登录后 1-5 星,按用户去重可改分;聚合更新综合评分) * POST /haonav/rate {id, score} */ public function rate() { if (!$this->request->isPost()) { return $this->result->error('请求方式错误'); } $uid = $this->uid(); if (!$uid) { return $this->result->error('请先登录后再评分', 401); } $id = (int)$this->request->post('id/d', 0); $score = (int)$this->request->post('score/d', 0); if ($id <= 0 || $score < 1 || $score > 5) { return $this->result->error('参数错误'); } $link = LinksModel::find($id); if (!$link || $link->status != 1) { return $this->result->error('网站不存在或已下架'); } // 评分记录表由安装/升级流程(install.sql + Addon::upgrade 钩子)保证存在 $now = time(); $exists = Db::name('haonav_ratings')->where('user_id', $uid)->where('link_id', $id)->find(); if ($exists) { Db::name('haonav_ratings')->where('id', $exists['id'])->update([ 'score' => $score, 'update_at' => $now, ]); } else { Db::name('haonav_ratings')->insert([ 'user_id' => $uid, 'link_id' => $id, 'score' => $score, 'create_at' => $now, 'update_at' => $now, ]); } // 重新聚合该站评分,保证 rating / rating_count 精确 $agg = Db::name('haonav_ratings') ->where('link_id', $id) ->field('AVG(score) as avg_score, COUNT(*) as cnt') ->find(); $rating = ($agg && $agg['cnt']) ? round((float)$agg['avg_score'], 1) : 0.0; $count = $agg ? (int)$agg['cnt'] : 0; LinksModel::where('id', $id)->update([ 'rating' => $rating, 'rating_count' => $count, ]); return $this->result->success([ 'rating' => $rating, 'rating_count' => $count, 'my_score' => $score, ], '评分成功'); } /** * 当前登录用户ID,未登录返回 0(与 Favorite 控制器一致) */ protected function uid(): int { if ($this->auth && $this->auth->isLogin) { $info = $this->auth->info; if (is_array($info)) { return (int)($info['id'] ?? 0); } return (int)($info->id ?? 0); } return 0; } /** * 热门排行榜(按点击量) */ public function rank() { $list = LinksModel::where('status', 1) ->order('click_count', 'desc') ->limit(50) ->select(); $this->view->assign('list', $list); $this->view->assign('config', $this->siteConfig()); $this->assignSeo(); return $this->view->fetch('rank'); } /** * 网址投稿(公开,提交后进入待审核 status=2) */ public function submit() { if ($this->request->isPost()) { $params = $this->request->post(); try { validate(\addon\haonav\validate\Link::class)->check($params); } catch (ValidateException $e) { return $this->result->error('数据验证失败: ' . $e->getMessage()); } $enable = (int)ConfigureModel::getVal('enable_submit', '1'); $model = new LinksModel(); $model->cid = $params['cid']; $model->title = $params['title']; $model->url = $params['url']; $model->description = $params['description'] ?? ''; $model->keywords = $params['keywords'] ?? ''; $model->status = $enable ? 2 : 1; // 待审核 or 直接上架 $model->save(); return $this->result->success([], $enable ? '提交成功,等待管理员审核' : '提交成功'); } $cates = CategoryModel::where('status', 1)->order('sort', 'desc')->select(); $this->view->assign('cates', $cates); $this->view->assign('config', $this->siteConfig()); $this->assignSeo(); return $this->view->fetch('submit'); } /** * 友链/广告合作自助申请(公开) * GET 渲染表单页;POST 提交入库(待审核),后台「申请审核」处理。 * 防滥用:蜜罐字段 + 同 IP 限频(1小时5条)+ 同 URL 去重。 */ public function apply() { if ($this->request->isPost()) { // 蜜罐:正常用户不可见不填写,机器人常会填 if ((string)$this->request->post('website', '') !== '') { return $this->result->success([], '提交成功,等待管理员审核'); } $type = (int)$this->request->post('type/d', ApplyModel::TYPE_LINK); $title = trim((string)$this->request->post('title', '')); $url = trim((string)$this->request->post('url', '')); $desc = trim((string)$this->request->post('description', '')); $contact = trim((string)$this->request->post('contact', '')); $slot = trim((string)$this->request->post('slot', '')); if (!in_array($type, [ApplyModel::TYPE_LINK, ApplyModel::TYPE_AD], true)) { return $this->result->error('申请类型错误'); } if ($title === '' || mb_strlen($title) > 100) { return $this->result->error('请填写正确的名称(100字以内)'); } if (!filter_var($url, FILTER_VALIDATE_URL) || !preg_match('#^https?://#i', $url)) { return $this->result->error('请填写正确的网址(http/https 开头)'); } if ($contact === '' || mb_strlen($contact) > 100) { return $this->result->error('请留下联系方式,便于审核后联系您'); } if ($type === ApplyModel::TYPE_AD && $slot !== '' && !array_key_exists($slot, AdModel::slots())) { return $this->result->error('意向广告位不存在'); } $ip = (string)$this->request->ip(); if (ApplyModel::ipOverLimit($ip)) { return $this->result->error('提交过于频繁,请稍后再试'); } if (ApplyModel::urlExists($url)) { return $this->result->error('该网址已提交过申请,请勿重复提交'); } $model = new ApplyModel(); $model->type = $type; $model->title = $title; $model->url = $url; $model->description = mb_substr($desc, 0, 500); $model->contact = $contact; $model->slot = $type === ApplyModel::TYPE_AD ? $slot : ''; $model->ip = $ip; $model->status = ApplyModel::STATUS_PENDING; $model->save(); return $this->result->success([], '提交成功,管理员审核后会通过您留下的联系方式回复'); } $this->view->assign('slots', AdModel::slots()); $this->view->assign('config', $this->siteConfig()); $this->assignSeo(); return $this->view->fetch('apply'); } /** * 跳转中间页(累加点击) */ public function site($id = 0) { $data = LinksModel::find($id); if ($data) { LinksModel::where('id', $id) ->inc('click_count') ->update(['last_click_at' => time()]); $data = LinksModel::find($id); $this->recordClickStat((int)$data->id, (int)$data->cid); } $this->view->assign('info', $data); $this->assignAds(); $this->assignSeo(); // 详情页 JSON-LD:WebPage if ($data) { $this->view->assign('jsonld', json_encode([ '@context' => 'https://schema.org', '@type' => 'WebPage', 'name' => (string)$data->title, 'description' => (string)($data->description ?: $data->title), 'url' => $this->request->domain() . '/haonav/site/' . $data->id . '.html', ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); } return $this->view->fetch('detail'); } /** * 点击时段聚合(供后台「点击热力图」)。表结构首次访问自愈,写入失败静默不影响跳转。 * 仅按 (link_id, date, hour) 聚合,全站每天最多 N链接×24 行,规模可控。 */ protected function recordClickStat(int $linkId, int $cid): void { $table = 'wxapp_haonav_click_stats'; try { $ymd = date('Y-m-d'); $hr = (int)date('G'); \think\facade\Db::execute( "INSERT INTO `$table` (`link_id`,`cid`,`date`,`hour`,`clicks`) VALUES (?,?,?,?,1) " . "ON DUPLICATE KEY UPDATE `clicks` = `clicks` + 1", [$linkId, $cid, $ymd, $hr] ); } catch (\Throwable $e) { // 统计写入失败不影响跳转 } } /** * 统一注入各广告位(按 slot 分组),前台模板用 {volist name="ads.home_top"} 等渲染 */ protected function assignAds() { $this->view->assign('ads', AdModel::allActiveBySlot()); } /** * 广告点击中转(仅图片广告有跳转,累加 click_count 后 302 到目标) * /haonav/ad/click?id=广告ID */ public function adClick() { $id = (int)$this->request->param('id/d', 0); $ad = $id ? AdModel::find($id) : null; if (!$ad || (int)$ad->type !== 1 || !$ad->url) { return redirect('/haonav/index.html'); } AdModel::where('id', $id)->inc('click_count')->update(); return redirect($ad->url); } /** * 分类页 */ public function category($id = 0) { $data = CategoryModel::with(['links' => function ($query) { $query->where('status', 1)->order('click_count', 'desc'); }])->find($id); $this->view->assign('info', $data); $this->assignSeo(); return $this->view->fetch(); } /** * 站点地图(SEO) * /haonav/sitemap.xml 输出标准 sitemap 协议 XML: * 首页/排行/投稿 + 全部启用分类页 + 全部启用链接详情页;缓存 1 小时。 */ public function sitemap() { $domain = $this->request->domain(); $cacheKey = 'haonav_sitemap_' . md5($domain); $xml = \think\facade\Cache::get($cacheKey); if (! $xml) { $urls = [ ['loc' => $domain . '/haonav/index.html', 'priority' => '1.0', 'changefreq' => 'daily'], ['loc' => $domain . '/haonav/rank.html', 'priority' => '0.8', 'changefreq' => 'daily'], ['loc' => $domain . '/haonav/submit.html', 'priority' => '0.5', 'changefreq' => 'monthly'], ]; $cates = CategoryModel::where('status', 1)->field('id,update_at')->select(); foreach ($cates as $c) { $urls[] = [ 'loc' => $domain . '/haonav/category/' . $c->id . '.html', 'priority' => '0.8', 'changefreq' => 'weekly', 'lastmod' => $c->update_at ? date('Y-m-d', strtotime((string)$c->update_at)) : '', ]; } $links = LinksModel::where('status', 1)->field('id,update_at')->order('id', 'asc')->limit(5000)->select(); foreach ($links as $l) { $urls[] = [ 'loc' => $domain . '/haonav/site/' . $l->id . '.html', 'priority' => '0.6', 'changefreq' => 'weekly', 'lastmod' => $l->update_at ? date('Y-m-d', strtotime((string)$l->update_at)) : '', ]; } $xml = '' . "\n" . '' . "\n"; foreach ($urls as $u) { $xml .= " \n " . htmlspecialchars($u['loc']) . "\n"; if (! empty($u['lastmod'])) { $xml .= ' ' . $u['lastmod'] . "\n"; } $xml .= ' ' . $u['changefreq'] . "\n" . ' ' . $u['priority'] . "\n \n"; } $xml .= ''; \think\facade\Cache::set($cacheKey, $xml, 3600); } return Response::create($xml)->header([ 'Content-Type' => 'application/xml; charset=utf-8', 'Cache-Control' => 'public, max-age=3600', ]); } /** * 远程抓取网页元信息(后台「提取信息」按钮使用) */ public function read() { $url = $this->request->param('url', ''); if (! $url) { return $this->result->error('缺少 url 参数'); } $meta = LinksModel::fetchMeta($url); if ($meta) { return $this->result->success($meta); } return $this->result->error('无法获取网页信息'); } /** * 本地 favicon 代理 + 缓存 * 浏览器统一请求 /haonav/favicon.html?url=xxx,由服务端抓取并缓存到 * public/static/haonav/favicons/,避免直接依赖第三方服务、提升加载速度。 */ public function favicon() { $url = $this->request->param('url', ''); $host = $url ? parse_url($url, PHP_URL_HOST) : ''; if (! $host) { return $this->serveDefault(); } $root = app()->getRootPath() . 'public/static/haonav/favicons/'; if (! is_dir($root)) { @mkdir($root, 0755, true); } $key = md5($host); // 失败负缓存:24 小时内不再尝试远程抓取,直接返回默认图 $failFile = $root . $key . '.fail'; if (is_file($failFile) && (time() - filemtime($failFile)) < 86400) { return $this->serveDefault(); } // 已缓存则直接返回 foreach (['png', 'ico', 'jpg', 'jpeg', 'svg', 'webp', 'gif'] as $ext) { if (is_file($root . $key . '.' . $ext)) { return $this->serveFile($root . $key . '.' . $ext); } } $img = $this->fetchFavicon($host); if ($img) { $path = $root . $key . '.' . $img['ext']; @file_put_contents($path, $img['data']); return $this->serveFile($path); } @touch($failFile); return $this->serveDefault(); } /** * 网站截图缩略图代理 + 缓存 * 请求 /haonav/snapshot.html?id=链接ID(以 id 换 url,避免 SSRF), * 服务端经 WordPress mshots 免费截图服务生成,缓存 7 天到 * public/static/haonav/snapshots/。生成中/失败时返回默认小图, * 前端以 naturalWidth 判断是否为真实截图。 */ public function snapshot() { $id = (int)$this->request->param('id/d', 0); $link = $id > 0 ? LinksModel::find($id) : null; $url = $link ? (string)$link->url : ''; if (!$url) { return $this->serveDefault(); } $root = app()->getRootPath() . 'public/static/haonav/snapshots/'; if (! is_dir($root)) { @mkdir($root, 0755, true); } $key = md5($url); $file = $root . $key . '.jpg'; if (is_file($file) && (time() - filemtime($file)) < 7 * 86400) { return $this->serveFile($file); } // 失败/生成中负缓存 1 小时,避免每次 hover 都打远程 $failFile = $root . $key . '.fail'; if (is_file($failFile) && (time() - filemtime($failFile)) < 3600) { return is_file($file) ? $this->serveFile($file) : $this->serveDefault(); } $img = $this->fetchSnapshot($url); if ($img) { @file_put_contents($file, $img); return $this->serveFile($file); } @touch($failFile); return is_file($file) ? $this->serveFile($file) : $this->serveDefault(); } /** * 抓取 mshots 截图;生成中(返回 loading gif)或失败返回 false */ protected function fetchSnapshot(string $url) { if (! class_exists(\GuzzleHttp\Client::class)) { return false; } $api = 'https://s0.wp.com/mshots/v1/' . urlencode($url) . '?w=480'; try { $client = new \GuzzleHttp\Client(['timeout' => 8, 'verify' => false]); $resp = $client->get($api, ['headers' => ['Member-Agent' => 'Mozilla/5.0']]); $body = (string)$resp->getBody(); $ct = strtolower($resp->getHeaderLine('Content-Type')); // mshots 首次请求会 307 到 loading gif,表示截图排队生成中,不缓存 if (stripos($ct, 'gif') !== false || strlen($body) < 2048) { return false; } return $body; } catch (\Throwable $e) { return false; } } /** * Service Worker 出口(PWA) * SW 的作用域由其 URL 目录决定,静态目录 /static/... 无法覆盖 /haonav/, * 故经路由 /haonav/sw.html 输出 JS,使作用域为 /haonav/。 */ public function sw() { $path = app()->getRootPath() . 'public/static/haonav/sw.js'; $js = is_file($path) ? (string)file_get_contents($path) : ''; return Response::create($js)->header([ 'Content-Type' => 'application/javascript; charset=utf-8', 'Service-Worker-Allowed' => '/haonav/', 'Cache-Control' => 'no-cache', ]); } /** * 远程抓取 favicon。 * 抓取顺序(可靠性从高到低、对网络环境最友好): * 1) 目标站点根路径 /favicon.ico(绝大多数站点自带,不依赖第三方) * 2) 抓取首页 HTML,解析 指示的图标 * 3) 第三方兜底(icon.horse / Google,国内常被墙/慢,仅作保底) * 任一步成功即返回 ['ext'=>, 'data'=>],全部失败返回 false。 */ protected function fetchFavicon(string $host) { if (! class_exists(\GuzzleHttp\Client::class)) { return false; } $ua = ['Member-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36']; $client = new \GuzzleHttp\Client(['timeout' => 4, 'verify' => false]); // 1) 站点自有 /favicon.ico(先 https 后 http) foreach (['https://' . $host, 'http://' . $host] as $base) { try { $resp = $client->get($base . '/favicon.ico', ['headers' => $ua]); if ($resp->getStatusCode() === 200) { $ct = strtolower($resp->getHeaderLine('Content-Type')); $body = (string)$resp->getBody(); // 服务器可能把 404 页面当 html 返回,需排除 if (strlen($body) >= 32 && stripos($ct, 'html') === false) { return ['ext' => $this->extFromCt($ct, 'ico'), 'data' => $body]; } } } catch (\Throwable $e) { // 继续尝试 } } // 2) 抓首页 HTML,解析 拿到真实图标地址 foreach (['https://' . $host, 'http://' . $host] as $base) { try { $resp = $client->get($base . '/', ['headers' => $ua]); if ($resp->getStatusCode() !== 200) { continue; } $html = (string)$resp->getBody(); $icon = $this->parseIconHref($html, $base); if ($icon) { $resp2 = $client->get($icon, ['headers' => $ua]); if ($resp2->getStatusCode() === 200) { $ct = strtolower($resp2->getHeaderLine('Content-Type')); $body = (string)$resp2->getBody(); if (strlen($body) >= 32 && stripos($ct, 'html') === false) { return ['ext' => $this->extFromCt($ct, 'ico'), 'data' => $body]; } } } } catch (\Throwable $e) { // 继续尝试 } } // 3) 第三方兜底(可能被墙/慢,仅保底) $sources = [ 'https://www.google.com/s2/favicons?domain=' . $host . '&sz=64', 'https://icon.horse/icon/' . $host, ]; foreach ($sources as $u) { try { $resp = $client->get($u, ['headers' => $ua]); $body = (string)$resp->getBody(); if (strlen($body) < 32) { continue; } $ct = strtolower($resp->getHeaderLine('Content-Type')); $ext = 'png'; if (stripos($ct, 'svg') !== false) { $ext = 'svg'; } elseif (stripos($ct, 'ico') !== false) { $ext = 'ico'; } elseif (stripos($ct, 'jpeg') !== false) { $ext = 'jpg'; } elseif (stripos($ct, 'webp') !== false) { $ext = 'webp'; } elseif (stripos($ct, 'gif') !== false) { $ext = 'gif'; } return ['ext' => $ext, 'data' => $body]; } catch (\Throwable $e) { continue; } } return false; } /** * 根据 Content-Type 推断图片扩展名 */ protected function extFromCt(string $ct, string $default = 'ico'): string { if (stripos($ct, 'svg') !== false) { return 'svg'; } if (stripos($ct, 'png') !== false) { return 'png'; } if (stripos($ct, 'jpeg') !== false) { return 'jpg'; } if (stripos($ct, 'webp') !== false) { return 'webp'; } if (stripos($ct, 'gif') !== false) { return 'gif'; } if (stripos($ct, 'ico') !== false || stripos($ct, 'x-icon') !== false) { return 'ico'; } return $default; } /** * 从首页 HTML 中解析图标地址,兼容相对/绝对/协议相对路径与 apple-touch-icon。 * 优先返回普通 icon(体积小),apple-touch-icon 作为兜底。 */ protected function parseIconHref(string $html, string $base): string { if (! preg_match_all('/]*rel=["\'][^"\']*icon[^"\']*["\'][^>]*>/is', $html, $matches)) { return ''; } $candidates = []; foreach ($matches[0] as $tag) { if (! preg_match('/href=["\']([^"\']+)["\']/i', $tag, $m)) { continue; } $candidates[] = [ 'href' => $m[1], 'apple' => stripos($tag, 'apple-touch-icon') !== false, ]; } usort($candidates, fn($a, $b) => ($a['apple'] <=> $b['apple'])); $base = rtrim($base, '/'); foreach ($candidates as $c) { $href = $c['href']; if (preg_match('/^https?:\/\//i', $href)) { return $href; } if (strpos($href, '//') === 0) { return 'https:' . $href; } return $base . ($href[0] === '/' ? $href : '/' . $href); } return ''; } /** * 输出本地图片文件 */ protected function serveFile(string $path) { $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); $map = [ 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif', 'ico' => 'image/x-icon', 'svg' => 'image/svg+xml', 'webp' => 'image/webp', ]; $ct = $map[$ext] ?? 'image/png'; $data = file_get_contents($path); return Response::create($data)->header([ 'Content-Type' => $ct, 'Cache-Control' => 'public, max-age=86400', 'Expires' => gmdate('D, d M Y H:i:s', time() + 86400) . ' GMT', ]); } /** * 输出默认图标(文件不存在时回退到 1x1 透明图) */ protected function serveDefault() { $path = app()->getRootPath() . 'public/static/haonav/img/default.png'; if (is_file($path)) { return $this->serveFile($path); } $gif = base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'); return Response::create($gif)->header([ 'Content-Type' => 'image/gif', 'Cache-Control' => 'public, max-age=3600', ]); } public function edit($id = null) { // } public function update(Request $request, $id) { // } public function delete($id) { // } }