Files
YwxAppThink/addon/haonav/controller/Index.php
T

870 lines
32 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
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 <admin@ywxapp.cn>
*/
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)->order('click_count', 'desc');
},
'children' => function ($query) {
$query->where('status', 1)->order('sort', 'desc')->with([
'links' => function ($q) {
$q->where('status', 1)->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-LDWebSite + 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-LDWebPage
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');
},
'children' => function ($query) {
$query->where('status', 1)->order('sort', 'desc')->with([
'links' => function ($q) {
$q->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 = '<?xml version="1.0" encoding="UTF-8"?>' . "\n"
. '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
foreach ($urls as $u) {
$xml .= " <url>\n <loc>" . htmlspecialchars($u['loc']) . "</loc>\n";
if (! empty($u['lastmod'])) {
$xml .= ' <lastmod>' . $u['lastmod'] . "</lastmod>\n";
}
$xml .= ' <changefreq>' . $u['changefreq'] . "</changefreq>\n"
. ' <priority>' . $u['priority'] . "</priority>\n </url>\n";
}
$xml .= '</urlset>';
\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,解析 <link rel="icon/shortcut icon/apple-touch-icon"> 指示的图标
* 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,解析 <link rel="icon*"> 拿到真实图标地址
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('/<link\b[^>]*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)
{
//
}
}