chore: 重写初始提交(清空历史,整理后全量提交)

This commit is contained in:
ywxapp
2026-08-16 16:54:14 +08:00
commit 6c1a106bc1
1808 changed files with 238144 additions and 0 deletions
+121
View File
@@ -0,0 +1,121 @@
<?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;
use think\facade\Config;
use think\facade\Db;
use ywxapp\AddonBase;
use ywxapp\model\BaseModel;
/**
* 网址导航插件
*
* 安装于框架自动导入 install.sql 建表(wxapp_haonav_category / links / config /
* favorites / favorite_shares / ads / apply / click_stats);
* 卸载时按统一前缀清理全部表;升级时经 callAddonHook('upgrade') 收敛表结构。
*/
class Addon extends addon
{
/**
* 安装钩子(框架自动导入 install.sql 后调用)。
*/
public function install(): bool
{
return true;
}
/**
* 卸载钩子:清理本插件全部表。
* 与 install.sql 表名前缀严格对齐(wxapp_haonav_),直接复用统一前缀,
* 避免依赖 database.prefix 配置(install.sql 为硬写前缀)。
*/
public function uninstall(): bool
{
$prefix = 'wxapp_haonav_';
$tables = [
'category', // 网址分类表
'config', // 导航配置表
'links', // 网址信息表
'favorites', // 用户云端收藏表
'favorite_shares', // 收藏夹分享配置表
'ads', // 广告位表
'apply', // 自助申请(友链/广告)表
'click_stats', // 点击统计聚合表
'ratings', // 网址评分记录表
];
foreach ($tables as $t) {
try {
Db::execute("DROP TABLE IF EXISTS `{$prefix}{$t}`");
} catch (\Exception $e) {
// 忽略
}
}
return true;
}
/**
* 升级时确保全部表存在(读 install.sqlCREATE TABLE IF NOT EXISTS 幂等建表)。
* 单一事实源 = install.sql,避免复制 DDL 导致漂移。
*/
private function ensureTablesFromInstallSql(): void
{
$sqlFile = __DIR__ . DIRECTORY_SEPARATOR . 'install.sql';
if (!is_file($sqlFile)) {
return;
}
$content = (string) file_get_contents($sqlFile);
$content = preg_replace('/--.*|\/\*[\s\S]*?\*\//', '', $content);
$stmts = array_filter(
array_map('trim', explode(';', $content)),
function ($s) {
return strlen($s) > 5 && preg_match('/^CREATE\s+TABLE/i', $s);
}
);
foreach ($stmts as $sql) {
if (preg_match('/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?([\w]+)`?/i', $sql, $m)) {
BaseModel::ensureTable($m[1], $sql);
}
}
}
/**
* 升级钩子(在线升级 / 后台手动升级均会触发)。
*
* 在线升级(AddonService::onlineUpgrade)已重导 install.sql 补全表;
* 但「已存在表新增列」install.sql 的 CREATE TABLE IF NOT EXISTS 对已有表无效。
* 此处作为升级收敛点:确保全部表存在 + 对已知易漂移列做幂等补列兜底。
* 未来新增列统一在 $columnFixes 登记(须带 DEFAULT x 或 NULL,保证降级安全),
* 避免各控制器/模型重复 ensureColumn/ALTER 导致 DDL 漂移。
*
* @param string $currentVersion 升级前版本号
*/
public function upgrade($currentVersion = ''): bool
{
// 1) 确保全部表存在(幂等,单一事实源 install.sql
$this->ensureTablesFromInstallSql();
// 2) 补列兜底:'完整表名(含前缀)' => ['列名' => '列定义']
// BaseModel::ensureColumn 先探测存在性,重复执行安全。
// install.sql 当前已含全部列,故此处留空;新增列在此登记即可。
$columnFixes = [
// 'wxapp_haonav_links' => [
// 'new_col' => "varchar(50) NOT NULL DEFAULT '' COMMENT '示例'",
// ],
];
foreach ($columnFixes as $table => $cols) {
foreach ($cols as $column => $def) {
BaseModel::ensureColumn($table, $column, $def);
}
}
return true;
}
}
+68
View File
@@ -0,0 +1,68 @@
<?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\command;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
use think\console\Output;
use addon\haonav\model\Links as LinksModel;
/**
* haonav 死链巡检命令
*
* 用法:
* php think haonav:checklinks # 全量检测
* php think haonav:checklinks --limit=50 # 只检测最久未检测的 50 条
*
* crontab 示例(每天凌晨 3 点全量):
* 0 3 * * * cd /path/to/site && php think haonav:checklinks >> runtime/haonav_check.log 2>&1
*/
class CheckLinks extends Command
{
protected function configure()
{
$this->setName('haonav:checklinks')
->addOption('limit', null, Option::VALUE_OPTIONAL, '本次最多检测条数(0=全部,按最久未检测优先)', '0')
->addOption('timeout', null, Option::VALUE_OPTIONAL, '单条检测超时秒数', '8')
->setDescription('haonav 网址导航死链巡检(更新 status_code / last_check_at');
}
protected function execute(Input $input, Output $output)
{
$limit = max(0, (int)$input->getOption('limit'));
$timeout = max(1, (int)$input->getOption('timeout'));
set_time_limit(0);
$output->writeln('[haonav] 开始死链巡检 limit=' . ($limit ?: '全部') . ' timeout=' . $timeout . 's ...');
$start = microtime(true);
$stats = $limit > 0
? LinksModel::checkBatch($limit, $timeout)
: LinksModel::checkAllLinks($timeout);
$output->writeln(sprintf(
'[haonav] 完成:共 %d 条,正常 %d,异常 %d,耗时 %.1fs',
$stats['total'],
$stats['ok'],
$stats['dead'],
microtime(true) - $start
));
if (!empty($stats['offlined'])) {
$output->writeln('[haonav] 已自动下线 ' . $stats['offlined'] . ' 条死链(阈值见 deadlink_threshold 配置)');
}
if (!empty($stats['recovered'])) {
$output->writeln('[haonav] 已自动恢复 ' . $stats['recovered'] . ' 条(deadlink_recover 已开启)');
}
return 0;
}
}
+9
View File
@@ -0,0 +1,9 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
// 这是系统自动生成的公共文件
+135
View File
@@ -0,0 +1,135 @@
<?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 ywxapp\model\MemberUser as UserModel;
/**
* 前台会员登录 / 注册 / 退出 / 资料
*
* 复用框架前台 AuthAuthorization 头或 access_token Cookie)。
* 登录/注册成功后 JwtService 把 token 写入 Result 单例,
* 由 Result::applyTokenCookies() 随响应写回非 httpOnly Cookiepath=/),
* 前端(整页/iframe)后续请求自动带登录态,无需前端额外存储。
*
* 注:框架 Auth::register() 内部依赖不存在的 UserModel::checkExists()
* 故注册在此控制器用 Member 模型直接建号,再用 Auth::login() 建立登录态与 token。
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Account extends FrontendBase
{
protected $noNeedLogin = ['login', 'register'];
protected $noNeedVerify = ['*'];
/**
* 规整要返回给前端的用户资料
*/
protected function userInfo(): array
{
$info = $this->auth->info ?? [];
if (is_object($info)) {
$info = $info->toArray();
}
return [
'uid' => (int)($info['uid'] ?? 0),
'account' => (string)($info['account'] ?? ''),
'nickname' => (string)($info['nickname'] ?? ''),
'avatar' => (string)($info['avatar'] ?? ''),
];
}
/**
* 登录
* POST /haonav/account/login {account, password}
*/
public function login()
{
$account = trim((string)$this->request->post('account', ''));
$password = (string)$this->request->post('password', '');
if ($account === '' || $password === '') {
return $this->result->error('请输入账号和密码');
}
try {
$this->auth->login($account, $password);
} catch (\Throwable $e) {
// Auth::login 内部已通过 Result 输出错误则不会到这里;此处兜底
return $this->result->error('登录失败:' . $e->getMessage());
}
return $this->result->success($this->userInfo(), '登录成功');
}
/**
* 注册
* POST /haonav/account/register {account, password, nickname?}
*/
public function register()
{
$account = trim((string)$this->request->post('account', ''));
$password = (string)$this->request->post('password', '');
$nickname = trim((string)$this->request->post('nickname', ''));
if ($account === '' || $password === '') {
return $this->result->error('请输入账号和密码');
}
if (mb_strlen($password) < 6) {
return $this->result->error('密码至少 6 位');
}
if (!preg_match('/^[\x{4e00}-\x{9fa5}a-zA-Z0-9_@.\-]+$/u', $account)) {
return $this->result->error('账号仅支持中英文、数字、_@.- 等字符');
}
// 账号查重(框架 UserModel::checkExists 不存在,这里直接查询)
if (UserModel::where('account', $account)->findOrEmpty()->isExists()) {
return $this->result->error('该账号已被注册');
}
$user = new UserModel();
$user->account = $account;
$user->nickname = $nickname !== '' ? mb_substr($nickname, 0, 30) : ('用户' . mb_substr($account, 0, 4));
// email 为 NOT NULL + 唯一:用本地占位邮箱保证唯一且不报错(非真实邮箱)
$user->email = $account . '@haonav.local';
$user->password = $password; // 触发 Member 模型 setPasswordAttr 自动加盐加密
$user->status = 1;
$user->gid = (int)config('fastadmin.user_default_group') ?: 0;
$user->create_ip = $this->request->ip();
$user->update_ip = $this->request->ip();
$user->save();
// 建号后建立登录态并写回 token Cookie
try {
$this->auth->login($account, $password);
} catch (\Throwable $e) {
return $this->result->error('注册成功但自动登录失败,请手动登录');
}
return $this->result->success($this->userInfo(), '注册成功');
}
/**
* 退出登录
* POST /haonav/account/logout
*/
public function logout()
{
$this->auth->logout();
return $this->result->success([], '已退出登录');
}
/**
* 当前登录资料(用于前端刷新登录态)
* GET /haonav/account/profile
*/
public function profile()
{
if (! $this->auth || ! $this->auth->isLogin) {
return $this->result->error('未登录', 401);
}
return $this->result->success($this->userInfo());
}
}
+274
View File
@@ -0,0 +1,274 @@
<?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 addon\haonav\model\Favorite as FavoriteModel;
use addon\haonav\model\FavoriteShare as FavoriteShareModel;
use addon\haonav\model\Configure as ConfigureModel;
/**
* 用户云端收藏(登录后「我的导航」跨端同步)
*
* 认证:复用框架前台 AuthAuthorization 头或 access_token Cookie)。
* 未登录时接口返回 code=401,前端自动回退 localStorage。
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Favorite extends FrontendBase
{
protected $noNeedLogin = ['*'];
protected $noNeedVerify = ['*'];
protected function initialize()
{
}
/**
* 当前登录用户ID,未登录返回 0
*/
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;
}
/**
* 登录态 + 收藏列表
* GET /haonav/favorite/list
*/
public function list()
{
$uid = $this->uid();
if (!$uid) {
return $this->result->error('未登录', 401);
}
$list = FavoriteModel::where('user_id', $uid)
->order('sort', 'asc')
->order('id', 'asc')
->select();
return $this->result->success($list);
}
/**
* 新增收藏
* POST /haonav/favorite/add {link_id?,title,url,icon?}
*/
public function add()
{
$uid = $this->uid();
if (!$uid) {
return $this->result->error('请先登录', 401);
}
$url = trim((string)$this->request->post('url', ''));
$title = trim((string)$this->request->post('title', ''));
if ($url === '' || $title === '') {
return $this->result->error('参数不完整');
}
if (!preg_match('#^https?://#i', $url) && strpos($url, '/') !== 0) {
return $this->result->error('网址格式不正确');
}
$exists = FavoriteModel::where('user_id', $uid)->where('url', $url)->find();
if ($exists) {
return $this->result->success([], '已在我的导航');
}
$maxSort = (int)FavoriteModel::where('user_id', $uid)->max('sort');
$model = new FavoriteModel();
$model->user_id = $uid;
$model->link_id = (int)$this->request->post('link_id', 0);
$model->title = mb_substr($title, 0, 100);
$model->url = mb_substr($url, 0, 500);
$model->icon = mb_substr((string)$this->request->post('icon', ''), 0, 200);
$model->sort = $maxSort + 1;
$model->save();
return $this->result->success(['id' => $model->id], '已收藏');
}
/**
* 移除收藏
* POST /haonav/favorite/remove {id} 或 {url}
*/
public function remove()
{
$uid = $this->uid();
if (!$uid) {
return $this->result->error('请先登录', 401);
}
$id = (int)$this->request->post('id', 0);
$url = trim((string)$this->request->post('url', ''));
$q = FavoriteModel::where('user_id', $uid);
if ($id) {
$q->where('id', $id);
} elseif ($url !== '') {
$q->where('url', $url);
} else {
return $this->result->error('参数不完整');
}
$q->delete();
return $this->result->success([], '已移除');
}
/**
* 保存排序
* POST /haonav/favorite/sort {ids:[3,1,2]}
*/
public function sort()
{
$uid = $this->uid();
if (!$uid) {
return $this->result->error('请先登录', 401);
}
$ids = $this->request->post('ids', []);
if (is_string($ids)) {
$ids = json_decode($ids, true);
}
if (!is_array($ids) || !$ids) {
return $this->result->error('参数不完整');
}
$sort = 1;
foreach ($ids as $id) {
FavoriteModel::where('user_id', $uid)->where('id', (int)$id)->update(['sort' => $sort++]);
}
return $this->result->success([], '已保存');
}
/**
* 一次性把本地收藏合并到云端(登录后同步)
* POST /haonav/favorite/merge {items:[{title,url,icon},...]}
*/
public function merge()
{
$uid = $this->uid();
if (!$uid) {
return $this->result->error('请先登录', 401);
}
$items = $this->request->post('items', []);
if (is_string($items)) {
$items = json_decode($items, true);
}
if (is_array($items)) {
$maxSort = (int)FavoriteModel::where('user_id', $uid)->max('sort');
foreach ($items as $it) {
$url = trim((string)($it['url'] ?? ''));
$title = trim((string)($it['title'] ?? ''));
if ($url === '' || $title === '') {
continue;
}
$exists = FavoriteModel::where('user_id', $uid)->where('url', $url)->find();
if ($exists) {
continue;
}
$model = new FavoriteModel();
$model->user_id = $uid;
$model->link_id = (int)($it['id'] ?? 0);
$model->title = mb_substr($title, 0, 100);
$model->url = mb_substr($url, 0, 500);
$model->icon = mb_substr((string)($it['icon'] ?? ''), 0, 200);
$model->sort = ++$maxSort;
$model->save();
}
}
$list = FavoriteModel::where('user_id', $uid)->order('sort', 'asc')->order('id', 'asc')->select();
return $this->result->success($list, '同步完成');
}
/**
* 获取当前用户的收藏夹分享配置(登录)
* GET /haonav/favorite/share
*/
public function share()
{
$uid = $this->uid();
if (!$uid) {
return $this->result->error('请先登录', 401);
}
$row = FavoriteShareModel::forUser($uid);
return $this->result->success($this->shareData($row));
}
/**
* 保存分享配置:开关 / 标题 / 重置令牌(登录)
* POST /haonav/favorite/sharesave {enabled,title?,reset?}
*/
public function shareSave()
{
$uid = $this->uid();
if (!$uid) {
return $this->result->error('请先登录', 401);
}
$row = FavoriteShareModel::forUser($uid);
$row->enabled = (int)$this->request->post('enabled', $row->enabled) ? 1 : 0;
$title = trim((string)$this->request->post('title', ''));
if ($title !== '') {
$row->title = mb_substr($title, 0, 100);
}
if ((int)$this->request->post('reset', 0) === 1) {
$row->token = FavoriteShareModel::genToken();
}
$row->save();
return $this->result->success($this->shareData($row), '已保存');
}
/**
* 组装分享配置返回数据(含完整分享链接)
*/
protected function shareData(FavoriteShareModel $row): array
{
return [
'enabled' => (int)$row->enabled,
'token' => (string)$row->token,
'title' => (string)$row->title,
'views' => (int)$row->views,
'url' => $this->request->domain() . '/haonav/favorite/shared/' . $row->token . '.html',
];
}
/**
* 公开的收藏夹分享页(无需登录,凭 token 访问)
* GET /haonav/favorite/shared/:token
*/
public function shared($token = '')
{
$token = trim((string)$token);
$row = $token !== '' ? FavoriteShareModel::where('token', $token)->find() : null;
$valid = $row && (int)$row->enabled === 1;
$list = [];
if ($valid) {
$list = FavoriteModel::where('user_id', $row->user_id)
->order('sort', 'asc')
->order('id', 'asc')
->select();
// 访问计数(不含所有者本人重复刷新的精确去重,简单自增即可)
FavoriteShareModel::where('id', $row->id)->inc('views')->update();
}
$title = $valid ? ($row->title ?: '收藏夹分享') : '收藏夹不存在或未公开';
$this->view->assign('valid', $valid);
$this->view->assign('shareTitle', $title);
$this->view->assign('list', $list);
$this->view->assign('count', is_countable($list) ? count($list) : 0);
$this->view->assign('seo', [
'title' => $title . ' - 网址导航',
'keywords' => '收藏夹,分享,网址导航',
'description' => (string)ConfigureModel::getVal('seo_description', '简洁实用的网址导航'),
]);
$this->view->assign('siteUrl', $this->request->domain());
return $this->view->fetch('favorite/shared');
}
}
+869
View File
@@ -0,0 +1,869 @@
<?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)
{
//
}
}
+72
View File
@@ -0,0 +1,72 @@
<?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 addon\haonav\model\Links as LinksModel;
use addon\haonav\model\Configure as ConfigureModel;
/**
* 计划任务入口(可通过服务器 cron 调用)
*
* 示例:curl "https://你的域名/haonav/task/checklinks?token=你的检测令牌"
* 令牌在「导航配置」中设置 check_token,为空则拒绝执行。
*/
class Task extends FrontendBase
{
protected $noNeedLogin = ['*'];
protected $noNeedVerify = ['*'];
protected function initialize()
{
}
/**
* 死链检测(供 cron 调用)
*/
public function checkLinks()
{
$token = $this->request->param('token', '');
$realToken = ConfigureModel::getVal('check_token', '');
if ($realToken === '' || $token !== $realToken) {
return $this->result->error('token 校验失败', 403);
}
$stats = LinksModel::checkAllLinks();
return $this->result->success($stats, '检测完成');
}
/**
* 惰性自动巡检(前台页面加载后异步 ping,无 cron 环境的兜底方案)
*
* - 需在「导航配置」开启 enable_auto_check
* - 缓存锁限频:每小时最多执行一批(5 条,超时 5s),不影响页面渲染
*/
public function autoCheck()
{
if ((int)ConfigureModel::getVal('enable_auto_check', '0') !== 1) {
return $this->result->success(['skipped' => 'disabled']);
}
$lockKey = 'haonav_autocheck_lock';
if (\think\facade\Cache::get($lockKey)) {
return $this->result->success(['skipped' => 'locked']);
}
\think\facade\Cache::set($lockKey, 1, 3600);
ignore_user_abort(true);
@set_time_limit(60);
$stats = LinksModel::checkBatch(5, 5);
return $this->result->success($stats, '巡检完成');
}
}
+171
View File
@@ -0,0 +1,171 @@
<?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\facade\Cache;
use addon\haonav\model\Configure as ConfigureModel;
/**
* 首页小组件:实时热搜榜 + 天气(服务端代理 + 缓存,规避跨域并保护第三方接口)
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Widget extends FrontendBase
{
protected $noNeedLogin = ['*'];
protected $noNeedVerify = ['*'];
protected function initialize()
{
}
/**
* 实时热搜榜(缓存 10 分钟)
* GET /haonav/widget/hot?source=baidu|weibo|zhihu
*/
public function hot()
{
if ((int)ConfigureModel::getVal('enable_hot_api', '0') !== 1) {
return $this->result->success([], '未开启');
}
$source = $this->request->param('source', ConfigureModel::getVal('hot_api_source', 'baidu'));
$source = in_array($source, ['baidu', 'weibo', 'zhihu'], true) ? $source : 'baidu';
$cacheKey = 'haonav_hot_' . $source;
$data = Cache::get($cacheKey);
if (is_array($data)) {
return $this->result->success($data, '缓存');
}
$map = [
'baidu' => 'https://api.vvhan.com/api/hotlist/baiduRD',
'weibo' => 'https://api.vvhan.com/api/hotlist/wbHot',
'zhihu' => 'https://api.vvhan.com/api/hotlist/zhihuHot',
];
$list = $this->fetchHot($map[$source]);
// 即便为空也短缓存,避免频繁打第三方
Cache::set($cacheKey, $list, $list ? 600 : 120);
return $this->result->success($list);
}
/**
* 天气(缓存 30 分钟)
* GET /haonav/widget/weather?city=北京
*/
public function weather()
{
if ((int)ConfigureModel::getVal('enable_weather', '0') !== 1) {
return $this->result->success([], '未开启');
}
$city = trim((string)$this->request->param('city', ConfigureModel::getVal('weather_city', '')));
$cacheKey = 'haonav_weather_' . ($city !== '' ? md5($city) : 'auto');
$data = Cache::get($cacheKey);
if (is_array($data) && $data) {
return $this->result->success($data, '缓存');
}
$info = $this->fetchWeather($city);
if ($info) {
Cache::set($cacheKey, $info, 1800);
return $this->result->success($info);
}
return $this->result->success([], '暂无数据');
}
/**
* 拉取并归一化热搜列表 -> [ {title,url,hot}, ... ]
*/
protected function fetchHot(string $api): array
{
$json = $this->httpGet($api);
if (!$json) {
return [];
}
$arr = json_decode($json, true);
if (!is_array($arr)) {
return [];
}
$rows = $arr['data'] ?? ($arr['result'] ?? []);
if (!is_array($rows)) {
return [];
}
$out = [];
foreach ($rows as $r) {
if (!is_array($r)) {
continue;
}
$title = $r['title'] ?? ($r['name'] ?? '');
if ($title === '') {
continue;
}
$out[] = [
'title' => (string)$title,
'url' => (string)($r['url'] ?? ($r['mobil_url'] ?? '')),
'hot' => (string)($r['hot'] ?? ($r['num'] ?? '')),
];
if (count($out) >= 20) {
break;
}
}
return $out;
}
/**
* 拉取并归一化天气 -> {city,type,low,high,tips,...}
*/
protected function fetchWeather(string $city)
{
$api = 'https://api.vvhan.com/api/weather';
if ($city !== '') {
$api .= '?city=' . urlencode($city);
}
$json = $this->httpGet($api);
if (!$json) {
return false;
}
$arr = json_decode($json, true);
if (!is_array($arr) || empty($arr['success'])) {
return false;
}
$today = $arr['data'][0] ?? [];
return [
'city' => (string)($arr['city'] ?? $city),
'date' => (string)($today['date'] ?? ''),
'type' => (string)($today['type'] ?? ''),
'low' => (string)($today['low'] ?? ''),
'high' => (string)($today['high'] ?? ''),
'fx' => (string)($today['fengxiang'] ?? ''),
'tips' => (string)($arr['tip'] ?? ''),
];
}
/**
* 轻量 GET(优先 Guzzle,失败回退 file_get_contents),失败返回 ''
*/
protected function httpGet(string $url): string
{
try {
if (class_exists(\GuzzleHttp\Client::class)) {
$client = new \GuzzleHttp\Client(['timeout' => 5, 'verify' => false]);
$resp = $client->get($url, ['headers' => ['Member-Agent' => 'Mozilla/5.0']]);
return (string)$resp->getBody();
}
$ctx = stream_context_create(['http' => ['timeout' => 5, 'header' => "Member-Agent: Mozilla/5.0\r\n"]]);
$body = @file_get_contents($url, false, $ctx);
return $body === false ? '' : (string)$body;
} catch (\Throwable $e) {
return '';
}
}
}
+190
View File
@@ -0,0 +1,190 @@
<?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\backend;
use think\facade\Db;
use addon\haonav\model\Ad as AdModel;
/**
* 广告位后台管理
*/
class Ad extends HaonavBackend
{
protected $noNeedVerify = ['*'];
protected function initialize()
{
$this->model = new AdModel();
}
public function index()
{
if ($this->request->isAjax()) {
$slot = $this->request->param('slot', '');
$status = $this->request->param('status', '');
$page = $this->request->param('page/d', 1);
$limit = $this->request->param('limit/d', 20);
$data = $this->model
->when($slot, fn($q, $s) => $q->where('slot', $s))
->when($status !== '', fn($q, $s) => $q->where('status', $s))
->order('slot')->order('sort', 'desc')->order('id', 'desc')
->paginate(['page' => $page, 'list_rows' => $limit]);
$this->result->setCount($data->total());
$this->result->success($data->items());
}
return $this->view->fetch('ad/index');
}
/**
* 表单下拉数据(广告位列表)
*/
public function create()
{
if ($this->request->isAjax()) {
$slots = [];
foreach (AdModel::slots() as $k => $v) {
$slots[] = ['id' => $k, 'title' => $v];
}
// 直接返回数组,前端 res.data 即数组(勿再包 data
$this->result->success($slots);
}
}
public function save()
{
if (! $this->request->isPost()) {
return $this->result->error('请求方式错误');
}
$post = $this->request->post();
$this->normalizeDates($post);
Db::startTrans();
try {
$this->model->save($post);
AdModel::clearCache();
Db::commit();
$this->result->success($this->model, '保存成功');
} catch (\Exception $e) {
Db::rollback();
$this->result->error('保存失败: ' . $e->getMessage());
}
}
public function edit($id = 0)
{
$id = $this->request->param('id');
$model = $this->model->find($id);
if (! $model) {
return $this->result->error('数据不存在');
}
if ($this->request->isAjax()) {
$slots = [];
foreach (AdModel::slots() as $k => $v) {
$slots[] = ['id' => $k, 'title' => $v];
}
$info = $model->toArray();
$info['start_at_text'] = $model->start_at ? date('Y-m-d H:i:s', (int)$model->start_at) : '';
$info['end_at_text'] = $model->end_at ? date('Y-m-d H:i:s', (int)$model->end_at) : '';
$this->result->success(['info' => $info, 'slots' => $slots]);
}
}
public function update()
{
if (! ($this->request->isAjax() && $this->request->isPut())) {
return $this->result->error('请求方式错误');
}
$id = $this->request->param('id');
$post = $this->request->param();
$this->normalizeDates($post);
Db::startTrans();
try {
$model = $this->model->find($id);
if (! $model) {
throw new \think\exception\ValidateException('数据不存在');
}
$model->save($post);
AdModel::clearCache();
Db::commit();
$this->result->success($model, '更新成功');
} catch (\Exception $e) {
Db::rollback();
$this->result->error('更新失败: ' . $e->getMessage());
}
}
public function delete()
{
$ids = (string)$this->request->param('ids', '');
$arr = array_values(array_filter(array_map('intval', explode(',', $ids))));
if (empty($arr)) {
return $this->result->error('请选择数据');
}
$this->model->whereIn('id', $arr)->delete();
AdModel::clearCache();
$this->result->success([], '已删除');
}
/**
* 状态开关(列表内 switch)
*/
public function status()
{
if (! ($this->request->isAjax() && $this->request->isPost())) {
return $this->result->error('请求方式错误');
}
$id = (int)$this->request->param('id');
$status = (int)$this->request->param('status');
$model = $this->model->find($id);
if (! $model) {
return $this->result->error('数据不存在');
}
$model->status = $status;
$model->save();
AdModel::clearCache();
$this->result->success([], '操作成功');
}
/**
* 排序(列表内行内编辑)
*/
public function sort()
{
if (! ($this->request->isAjax() && $this->request->isPost())) {
return $this->result->error('请求方式错误');
}
$id = (int)$this->request->param('id');
$sort = (int)$this->request->param('sort', 0);
$model = $this->model->find($id);
if ($model) {
$model->sort = $sort;
$model->save();
AdModel::clearCache();
}
$this->result->success([], '已保存');
}
/**
* 将日期字符串转为时间戳;空值置 null
*/
protected function normalizeDates(array &$post): void
{
foreach (['start_at', 'end_at'] as $k) {
if (empty($post[$k])) {
$post[$k] = null;
} else {
$t = strtotime((string)$post[$k]);
$post[$k] = $t === false ? null : $t;
}
}
}
}
+151
View File
@@ -0,0 +1,151 @@
<?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\backend;
use think\facade\Db;
use addon\haonav\model\Apply as ApplyModel;
use addon\haonav\model\Links as LinksModel;
use addon\haonav\model\Category as CategoryModel;
/**
* 友链/广告 申请审核
* 通过友链申请时可指定分类并一键写入 Links(默认待审核状态可直接上架)。
*/
class Apply extends HaonavBackend
{
protected $noNeedVerify = ['*'];
protected function initialize()
{
$this->model = new ApplyModel();
}
public function index()
{
if ($this->request->isAjax()) {
$type = $this->request->param('type', '');
$status = $this->request->param('status', '');
$page = $this->request->param('page/d', 1);
$limit = $this->request->param('limit/d', 20);
$data = $this->model
->when($type !== '', fn($q, $v) => $q->where('type', $type))
->when($status !== '', fn($q, $v) => $q->where('status', $status))
->order('status', 'asc')->order('id', 'desc')
->paginate(['page' => $page, 'list_rows' => $limit]);
$this->result->setCount($data->total());
$this->result->success($data->items());
}
return $this->view->fetch('apply/index');
}
/**
* 表单辅助数据:分类列表(通过友链时选分类)
*/
public function create()
{
if ($this->request->isAjax()) {
$cates = CategoryModel::where('status', 1)
->order('sort', 'desc')
->field('id,title')
->select()
->toArray();
$this->result->success($cates);
}
}
/**
* 通过申请
* 友链(type=1):可带 cid(分类)与 online(1=直接上架 0=进待审核),自动写入 Links 并回填 link_id
* 广告(type=2):仅标记通过,商务细节线下沟通后在「广告管理」建广告
*/
public function approve()
{
if (! ($this->request->isAjax() && $this->request->isPost())) {
return $this->result->error('请求方式错误');
}
$id = (int)$this->request->param('id');
$reply = (string)$this->request->param('reply', '');
$model = $this->model->find($id);
if (! $model) {
return $this->result->error('数据不存在');
}
if ((int)$model->status === ApplyModel::STATUS_APPROVED) {
return $this->result->error('该申请已通过,请勿重复操作');
}
Db::startTrans();
try {
$linkId = 0;
if ((int)$model->type === ApplyModel::TYPE_LINK) {
$cid = (int)$this->request->param('cid/d', 0);
$online = (int)$this->request->param('online/d', 1);
if ($cid <= 0) {
throw new \think\exception\ValidateException('请选择收录分类');
}
// 已有同 URL 链接则复用,避免重复收录
$exists = LinksModel::where('url', $model->url)->find();
if ($exists) {
$linkId = (int)$exists->id;
} else {
$link = new LinksModel();
$link->cid = $cid;
$link->title = $model->title;
$link->url = $model->url;
$link->description = (string)$model->description;
$link->status = $online ? 1 : 2; // 1上架 2待审核
$link->save();
$linkId = (int)$link->id;
}
}
$model->status = ApplyModel::STATUS_APPROVED;
$model->reply = mb_substr($reply, 0, 255);
$model->link_id = $linkId;
$model->save();
Db::commit();
$this->result->success([], '已通过' . ($linkId ? ',并已收录(links#' . $linkId . '' : ''));
} catch (\Exception $e) {
Db::rollback();
$this->result->error('操作失败: ' . $e->getMessage());
}
}
/**
* 拒绝申请(可填审核备注)
*/
public function reject()
{
if (! ($this->request->isAjax() && $this->request->isPost())) {
return $this->result->error('请求方式错误');
}
$id = (int)$this->request->param('id');
$reply = (string)$this->request->param('reply', '');
$model = $this->model->find($id);
if (! $model) {
return $this->result->error('数据不存在');
}
$model->status = ApplyModel::STATUS_REJECTED;
$model->reply = mb_substr($reply, 0, 255);
$model->save();
$this->result->success([], '已拒绝');
}
public function delete()
{
$ids = (string)$this->request->param('ids', '');
$arr = array_values(array_filter(array_map('intval', explode(',', $ids))));
if (empty($arr)) {
return $this->result->error('请选择数据');
}
$this->model->whereIn('id', $arr)->delete();
$this->result->success([], '已删除');
}
}
@@ -0,0 +1,255 @@
<?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\backend;
use think\Request;
use think\Response;
use think\exception\ValidateException;
use think\facade\Db;
use addon\haonav\validate\Category as CategoryValidate;
/**
* Category 类
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Category extends HaonavBackend
{
/**
* Summary of needLogin
* @var array
*/
/**
* Summary of needRight
* @var array
*/
protected $noNeedVerify = ['*'];
/**
* 控制器初始化 _initialize
* @return void
*/
protected function initialize()
{
$this->model = new \addon\haonav\model\Category();
}
/**
* 显示资源列表
*
* @return \think\Response
*/
public function index()
{
if ($this->request->isAjax()) {
$title = $this->request->param('title', '');
$page = $this->request->param('page/d', 1);
$limit = $this->request->param('limit/d', 20);
$data = $this->model->withAttr('cate')
->when($title, fn($q, $t) => $q->whereLike('title', "%{$t}%"))
->paginate(['page' => $page, 'list_rows' => $limit]);
$this->result->setCount($data->total());
$this->result->success($data->items());
}
return $this->view->fetch('category/index');
}
/**
* 显示创建资源表单页.
*
* @return \think\Response
*/
public function create()
{
if ($this->request->isAjax()) {
$data = $this->model->cateTree($this->model->select()->toArray());
$this->result->success(['data' => $data]);
}
}
/**
* 保存新建的资源
*
* @param \think\Request $request
* @return \think\Response
*/
public function save(Request $request)
{
if ($this->request->isPost()) {
$params = $this->request->post();
try {
validate(CategoryValidate::class)->check($params);
} catch (ValidateException $e) {
$this->result->error('数据验证失败: ' . $e->getMessage());
}
Db::startTrans();
try {
$data = $this->model->save($params);
Db::commit();
$this->result->success($data, '保存成功');
} catch (\Exception $e) {
Db::rollback();
$this->result->error('保存失败: ' . $e->getMessage());
}
}
}
/**
* 显示指定的资源
*
* @param int $id
* @return \think\Response
*/
public function read($id)
{
//
}
/**
* 显示编辑资源表单页.
*
* @param int $id
* @return \think\Response
*/
public function edit($id = null)
{
$id = $this->request->param('id');
$model = $this->model->find($id);
if (! $model) {
$this->result->error('数据不存在');
}
if ($this->request->isAjax()) {
$powers = $this->model->cateTree($this->model->select()->toArray());
$this->result->success(['power' => $powers, 'info' => $model]);
}
}
/**
* 保存更新的资源
*
* @param \think\Request $request
* @param int $id
* @return \think\Response
*/
public function update(Request $request, $id = 0)
{
$id = $id ? $id : $this->request->param('id');
if ($this->request->isAjax() && $this->request->isPut()) {
$params = $this->request->param();
try {
validate(CategoryValidate::class)->check($params);
} catch (ValidateException $e) {
$this->result->error('数据验证失败: ' . $e->getMessage());
}
Db::startTrans();
try {
$model = $this->model->find($id);
if (! $model) {
throw new ValidateException('数据不存在');
}
$model->save($params);
Db::commit();
$this->result->success($model, '更新成功');
} catch (\Exception $e) {
Db::rollback();
$this->result->error('更新失败: ' . $e->getMessage());
}
}
}
/**
* 显示回收站列表
*
* @param \think\Request $request
* @return \think\Response
*/
public function recyclebin()
{
$page = $this->request->param('page', 1);
$size = $this->request->param('size', 15);
if ($this->request->isAjax()) {
$data = $this->model->onlyTrashed()->paginate([
'page' => $page,
'list_rows' => $size,
]);
$this->result->success($data->items());
}
$this->view->assign('title', '回收站');
return $this->view->fetch('category/recyclebin');
}
/**
* 删除权限
*
* @param \think\Request $request
* @return \think\Response
*/
public function delete()
{
if ($this->request->isAjax() && $this->request->isDelete()) {
$ids = $this->request->param('ids', '');
$force = $this->request->param('force', false);
if (empty($ids)) {
$this->result->error('请选择要删除的数据');
}
try {
Db::transaction(function () use ($ids, $force) {
if ($force) {
$this->model->onlyTrashed()->whereIn('id', $ids)->select()->each(function ($item) {
$item->force()->delete();
});
} else {
$this->model->destroy($ids);
}
});
$this->result->success();
} catch (\think\exception\HttpResponseException $e) {
throw $e; // 不要 return,不要吞掉!
} catch (\Throwable $th) {
$this->result->error('删除失败: ' . $th->getMessage());
}
}
}
/**
* 还原权限
*
* @param \think\Request $request
* @return \think\Response
*/
public function restore($ids = null)
{
if ($this->request->isAjax() && $this->request->isPut()) {
$ids = $this->request->param('ids', '');
if (empty($ids)) {
$this->result->error('请选择要还原的数据');
}
$idsArray = explode(',', $ids);
try {
Db::transaction(function () use ($idsArray) {
$this->model->onlyTrashed()->whereIn('id', $idsArray)->select()->each(function ($item) {
$item->restore();
});
});
$this->result->success();
} catch (\think\exception\HttpResponseException $e) {
throw $e; // 不要 return,不要吞掉!
} catch (\Throwable $th) {
$this->result->error('还原失败: ' . $th->getMessage());
}
}
}
}
+122
View File
@@ -0,0 +1,122 @@
<?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\backend;
use think\Request;
use addon\haonav\model\Configure as ConfigureModel;
use think\facade\Db;
use ywxapp\model\BaseModel;
/**
* Configs 类
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Configs extends HaonavBackend
{
/**
* Summary of needLogin
* @var array
*/
/**
* Summary of needRight
* @var array
*/
protected $noNeedVerify = ['*'];
/**
* 控制器初始化 _initialize
* @return void
*/
protected function initialize()
{
$this->model = new ConfigureModel;
}
/**
* 显示资源列表
*
* @param \think\Request $request
* @return \think\Response
*/
public function index()
{
ConfigureModel::ensureRebateConfig();
ConfigureModel::ensureDeadlinkConfig();
$page = $this->request->param('page', 1);
$size = $this->request->param('size', 15);
if ($this->request->isAjax()) {
$data = ConfigureModel::order('id')->select();
$this->result->success($data);
}
$this->view->assign('title', '网址导航配置');
return $this->view->fetch('configs/index');
}
/**
* 保存新建的资源
*
* @param \think\Request $request
* @return \think\Response
*/
public function update()
{
if (!$this->request->isPost()) {
return $this->result->error('请求方式错误');
}
$postData = $this->request->post();
BaseModel::ensureAutoIncrementPk('wxapp_haonav_config');
$allConfigs = ConfigureModel::column('name,type,rule,value', 'name');
Db::startTrans();
try {
foreach ($postData as $name => $value) {
if (!isset($allConfigs[$name])) {
continue;
}
$config = $allConfigs[$name];
if (!empty($config['rule'])) {
$validate = validate([
$name => $config['rule']
]);
//if (!$validate->check([$name => $value])) {
// throw new \Exception("配置项 [{$name}] 验证失败:" . $validate->getError());
// }
}
// 特殊处理:复选框数组转字符串
if (is_array($value)) {
$value = implode(',', $value);
}
$exists = ConfigureModel::where('name', $name)->find();
if ($exists) {
$exists->save(['value' => $value ?? ""]);
} else {
// 如果没有记录,创建新记录
ConfigureModel::create([
'name' => $name,
'value' => $value,
'group' => $postData['group'] ?? 'default',
'type' => $config['type'] ?? 'string'
]);
}
}
// Cache::delete('system_config_all');
Db::commit();
$this->result->success('配置保存成功');
} catch (\Exception $e) {
Db::rollback();
$this->result->error('保存失败:' . $e->getMessage());
}
}
}
@@ -0,0 +1,145 @@
<?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\backend;
use think\facade\Db;
use ywxapp\model\BaseModel;
use addon\haonav\model\Links as LinksModel;
use addon\haonav\model\Category as CategoryModel;
/**
* 后台数据概览仪表盘
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Dashboard extends HaonavBackend
{
protected $noNeedVerify = ['*'];
protected function initialize()
{
}
public function index()
{
if ($this->request->isAjax()) {
return $this->stats();
}
// 顺带为存量链接回填拼音搜索串(每次最多 300 条,幂等、失败静默)
LinksModel::backfillPinyin(300);
$this->view->assign('title', '数据概览');
return $this->view->fetch('dashboard/index');
}
/**
* 汇总统计
*/
protected function stats()
{
// 基础计数
$summary = [
'links' => LinksModel::count(),
'online' => LinksModel::where('status', 1)->count(),
'pending' => LinksModel::where('status', 2)->count(),
'disabled' => LinksModel::where('status', 0)->count(),
'categories' => CategoryModel::count(),
'hot' => LinksModel::where('is_hot', 1)->count(),
'recommend' => LinksModel::where('is_recommend', 1)->count(),
'clicks' => (int)LinksModel::sum('click_count'),
];
// Top 分类(按链接数)
$cates = CategoryModel::field('id,title,icon')->select();
$catStat = [];
foreach ($cates as $c) {
$cnt = LinksModel::where('cid', $c->id)->count();
$catStat[] = ['title' => $c->title, 'icon' => $c->icon, 'count' => $cnt];
}
usort($catStat, function ($a, $b) {
return $b['count'] <=> $a['count'];
});
$topCategories = array_slice($catStat, 0, 10);
// Top 点击
$topClicks = LinksModel::field('id,title,url,click_count,cid')
->where('status', 1)
->order('click_count', 'desc')
->limit(10)
->select();
// 死链概览(status_code 已检测且非 2xx/3xx,或为 0
$deadCount = LinksModel::whereNotNull('status_code')
->where(function ($q) {
$q->where('status_code', 0)->whereOr('status_code', '>=', 400);
})
->count();
$deadList = LinksModel::field('id,title,url,status_code,last_check_at')
->whereNotNull('status_code')
->where(function ($q) {
$q->where('status_code', 0)->whereOr('status_code', '>=', 400);
})
->limit(20)
->select();
$lastCheckAt = (int)LinksModel::max('last_check_at');
// 分类点击热度(基于 links.click_count 聚合,零额外表)
$catHeat = [];
$allCates = CategoryModel::field('id,title,icon')->select();
foreach ($allCates as $c) {
$catHeat[] = [
'cid' => $c->id,
'title' => $c->title,
'icon' => $c->icon,
'clicks' => (int)LinksModel::where('cid', $c->id)->sum('click_count'),
];
}
usort($catHeat, function ($a, $b) { return $b['clicks'] <=> $a['clicks']; });
// 访问时段热力图(星期×小时),取最近 30 天,按 date 归并星期;表不存在或查询失败均静默兜底
$timeHeat = [];
for ($d = 0; $d < 7; $d++) { $timeHeat[$d] = array_fill(0, 24, 0); }
$timeTotal = 0;
$timeRange = date('Y-m-d', strtotime('-30 days')) . ' ~ ' . date('Y-m-d');
try {
if (BaseModel::tableExists('wxapp_haonav_click_stats')) {
$rows = Db::name('haonav_click_stats')
->where('date', '>=', date('Y-m-d', strtotime('-30 days')))
->field('date,hour,clicks')
->select();
foreach ($rows as $r) {
$w = (int)date('w', strtotime($r['date'])); // 0=周日..6=周六
$h = (int)$r['hour'];
if ($w >= 0 && $w < 7 && $h >= 0 && $h < 24) {
$timeHeat[$w][$h] += (int)$r['clicks'];
$timeTotal += (int)$r['clicks'];
}
}
}
} catch (\Throwable $e) {
// 统计查询失败不阻断概览
}
return $this->result->success([
'summary' => $summary,
'topCategories' => $topCategories,
'topClicks' => $topClicks,
'dead' => ['count' => $deadCount, 'list' => $deadList, 'last_check_at' => $lastCheckAt ? date('Y-m-d H:i', $lastCheckAt) : ''],
'heatmap' => [
'category' => $catHeat,
'time' => $timeHeat,
'timeRange' => $timeRange,
'timeTotal' => $timeTotal,
],
]);
}
}
@@ -0,0 +1,35 @@
<?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\backend;
use ywxapp\controller\BackendBase;
/**
* 网址导航后台控制器基类
*
* 说明:addon 后台路由(/<plugin>/backend/...)经全局路由分发落在默认 frontend 应用,
* 容器 auth 绑定会解析成前台 Auth(isAdmin=false),后台 token 上下文不匹配会被拒。
* AddonBackend::_initialize() 已统一处理:当 $this->auth 非 AdminAuth 时强制还原为
* AdminAuth 并 tryInitByToken(),再按子类声明的 noNeedLogin/noNeedVerify 做登录与权限校验。
*
* 因此本基类【不应】再在构造阶段自行 verifyAuth——那时 AdminAuth 实例尚未初始化登录态,
* 会恒判未登录并跳转登录页(即「后台总是要登录」的根因)。
* 这里仅声明跳过后台细粒度权限校验,避免权限规则未配置时锁死后台;登录仍强制要求。
*/
class HaonavBackend extends BackendBase
{
/**
* 跳过后台细粒度权限校验(权限规则未配置时避免锁死后台)。
* 登录要求仍由 AddonBackend::_initialize() 强制(noNeedLogin 默认空=需要登录)。
*/
protected $noNeedVerify = ['*'];
}
+469
View File
@@ -0,0 +1,469 @@
<?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\backend;
use think\Request;
use think\Response;
use think\exception\ValidateException;
use think\facade\Db;
use addon\haonav\validate\Link as LinkValidate;
use addon\haonav\model\Category as CategoryModel;
/**
* Links 类
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Links extends HaonavBackend
{
/**
* Summary of needLogin
* @var array
*/
/**
* Summary of needRight
* @var array
*/
protected $noNeedVerify = ['*'];
/**
* 控制器初始化
* @return void
*/
protected function initialize()
{
$this->model = new \addon\haonav\model\Links();
}
/**
* 列表(支持 status 过滤:1启用 0禁用 2待审核)
*/
public function index()
{
if ($this->request->isAjax()) {
$title = $this->request->param('title', '');
$url = $this->request->param('url', '');
$keywords = $this->request->param('keywords', '');
$description = $this->request->param('description', '');
$status = $this->request->param('status', '');
$page = $this->request->param('page/d', 1);
$limit = $this->request->param('limit/d', 20);
$data = $this->model
->when($title, fn($q, $t) => $q->whereLike('title', "%{$t}%"))
->when($url, fn($q, $t) => $q->whereLike('url', "%{$t}%"))
->when($keywords, fn($q, $t) => $q->whereLike('keywords', "%{$t}%"))
->when($description, fn($q, $t) => $q->whereLike('description', "%{$t}%"))
->when($status !== '', function ($q) use ($status) {
// dead = 仅筛选被检测器判定为死链(dead_at>0)的链接
if ($status === 'dead') {
$q->where('dead_at', '>', 0);
} else {
$q->where('status', $status);
}
})
->paginate([
'page' => $page,
'list_rows' => $limit,
]);
$this->result->setCount($data->total());
$this->result->success($data->items());
}
return $this->view->fetch('links/index');
}
public function create()
{
if ($this->request->isAjax()) {
$data = CategoryModel::cateTree(CategoryModel::select()->toArray());
// 直接返回数组,勿再包一层 ['data'=>...](否则 JSON 变 data.data,前端 res.data 拿到对象)
$this->result->success($data);
}
}
public function save(Request $request)
{
if ($this->request->isPost()) {
$params = $this->request->post();
try {
validate(LinkValidate::class)->check($params);
} catch (ValidateException $e) {
$this->result->error('数据验证失败: ' . $e->getMessage());
}
Db::startTrans();
try {
$this->model->save($params);
Db::commit();
$this->result->success($this->model, '保存成功');
} catch (\Exception $e) {
Db::rollback();
$this->result->error('保存失败: ' . $e->getMessage());
}
}
}
public function read($id = 0) {}
public function cates()
{
if ($this->request->isAjax()) {
$cates = CategoryModel::where('status', 1)->select()->toArray();
$this->result->success($cates);
}
}
public function edit($id = null)
{
$id = $this->request->param('id');
$model = $this->model->find($id);
if (! $model) {
$this->result->error('数据不存在');
}
if ($this->request->isAjax()) {
$cates = CategoryModel::where('status', 1)->select()->toArray();
$this->result->success(['info' => $model, 'cates' => $cates]);
}
}
public function update(Request $request)
{
$id = $this->request->param('id');
if ($this->request->isAjax() && $this->request->isPut()) {
$params = $this->request->param();
try {
// 只验证提交的字段:状态开关/热点开关等局部更新只传 id+单字段,
// 全量验证会被 title/url/cid 的 require 规则误杀
validate(LinkValidate::class)->only(array_keys($params))->check($params);
} catch (ValidateException $e) {
$this->result->error('数据验证失败: ' . $e->getMessage());
}
Db::startTrans();
try {
$model = $this->model->find($id);
if (! $model) {
throw new ValidateException('数据不存在');
}
$model->save($params);
Db::commit();
$this->result->success($model, '更新成功');
} catch (\Exception $e) {
Db::rollback();
$this->result->error('更新失败: ' . $e->getMessage());
}
}
}
/**
* 批量操作:enable 批量启用 / disable 批量禁用 / move 批量移动分类 / sort 修改排序
* POST: ids=1,2,3 op=enable|disable|move|sort [cid=分类ID] [sort=排序值]
*/
public function batch()
{
if (! ($this->request->isAjax() && $this->request->isPost())) {
return $this->result->error('请求方式错误');
}
$ids = (string)$this->request->param('ids', '');
$op = (string)$this->request->param('op', '');
$idArr = array_values(array_filter(array_map('intval', explode(',', $ids))));
if (empty($idArr)) {
return $this->result->error('请选择数据');
}
switch ($op) {
case 'enable':
$n = $this->model->whereIn('id', $idArr)->update(['status' => 1]);
return $this->result->success([], "已启用 {$n}");
case 'disable':
$n = $this->model->whereIn('id', $idArr)->update(['status' => 0]);
return $this->result->success([], "已禁用 {$n}");
case 'move':
$cid = (int)$this->request->param('cid', 0);
if ($cid <= 0 || ! CategoryModel::find($cid)) {
return $this->result->error('请选择有效的目标分类');
}
$n = $this->model->whereIn('id', $idArr)->update(['cid' => $cid]);
return $this->result->success([], "已移动 {$n}");
case 'sort':
$sort = (int)$this->request->param('sort', 0);
$n = $this->model->whereIn('id', $idArr)->update(['sort' => $sort]);
return $this->result->success([], '排序已保存');
case 'recoverdead':
// 恢复死链:重新启用 + 清空失败计数与死链标记(避免下次检测立即又被下线)
$n = $this->model->whereIn('id', $idArr)
->where('dead_at', '>', 0)
->update(['status' => 1, 'fail_count' => 0, 'dead_at' => null]);
return $this->result->success([], "已恢复 {$n} 条死链");
default:
return $this->result->error('不支持的操作类型');
}
}
/**
* 通过审核(待审核 -> 启用)
*/
public function approve()
{
if ($this->request->isAjax() && $this->request->isPost()) {
$id = $this->request->param('id');
$model = $this->model->find($id);
if (! $model) {
return $this->result->error('数据不存在');
}
$model->status = 1;
$model->save();
return $this->result->success([], '已通过');
}
}
/**
* 拒绝投稿(置为禁用)
*/
public function reject()
{
if ($this->request->isAjax() && $this->request->isPost()) {
$id = $this->request->param('id');
$model = $this->model->find($id);
if (! $model) {
return $this->result->error('数据不存在');
}
$model->status = 0;
$model->save();
return $this->result->success([], '已拒绝');
}
}
/**
* 刷新 favicon / icon(从域名自动生成)
*/
public function refreshFavicon()
{
if ($this->request->isAjax() && $this->request->isPost()) {
$ids = $this->request->param('ids', '');
if (empty($ids)) {
return $this->result->error('请选择链接');
}
$list = $this->model->whereIn('id', explode(',', $ids))->select();
$n = 0;
foreach ($list as $m) {
$favicon = \addon\haonav\model\Links::faviconOf($m->url);
if ($favicon) {
$m->favicon = $favicon;
$m->icon = $favicon;
$m->save();
$n++;
}
}
return $this->result->success([], "已刷新 {$n} 个图标");
}
}
/**
* 死链检测(手动触发,遍历全部链接)
*/
public function checkLinks()
{
if ($this->request->isAjax() && $this->request->isPost()) {
$stats = \addon\haonav\model\Links::checkAllLinks();
return $this->result->success($stats, '检测完成');
}
}
/**
* 导入书签(支持 Netscape HTML 书签 或 JSON 数组)
* 也可通过 POST content 直接传文本内容
*/
public function import()
{
if (! $this->request->isPost()) {
return $this->result->error('请求方式错误');
}
$content = '';
$file = $this->request->file('file');
if ($file) {
$content = file_get_contents($file->getRealPath());
} else {
$content = $this->request->param('content', '');
}
if (! $content) {
return $this->result->error('请上传书签文件或粘贴书签内容');
}
// 目标分类:优先使用提交的分类,否则取第一个启用分类
$cid = (int)$this->request->param('cid', 0);
if (! $cid) {
$first = CategoryModel::where('status', 1)->order('sort', 'desc')->find();
$cid = $first ? $first->id : 0;
}
if (! $cid) {
return $this->result->error('请先创建一个分类');
}
$enable = (int)\addon\haonav\model\Configure::getVal('enable_submit', '1');
$items = [];
$text = trim($content);
if (strpos($text, '[') === 0 || strpos($text, '{') === 0) {
// JSON 格式
$json = json_decode($text, true);
if (! is_array($json)) {
return $this->result->error('JSON 解析失败');
}
foreach ($json as $row) {
if (empty($row['url'])) {
continue;
}
$items[] = [
'title' => $row['title'] ?? parse_url($row['url'], PHP_URL_HOST),
'url' => $row['url'],
];
}
} else {
// Netscape 书签 HTML:提取 <A HREF> 标签
preg_match_all('/<a\s+[^>]*href="([^"]+)"[^>]*>(.*?)<\/a>/is', $content, $m, PREG_SET_ORDER);
foreach ($m as $row) {
$url = trim($row[1]);
if (! preg_match('/^https?:\/\//i', $url)) {
continue;
}
$title = trim(strip_tags($row[2]));
$items[] = [
'title' => $title ?: parse_url($url, PHP_URL_HOST),
'url' => $url,
];
}
}
if (empty($items)) {
return $this->result->error('未解析到有效书签');
}
// 去重(按 url
$exists = $this->model->whereIn('url', array_column($items, 'url'))->column('url');
$exists = array_flip($exists);
$count = 0;
Db::startTrans();
try {
foreach ($items as $it) {
if (isset($exists[$it['url']])) {
continue;
}
$model = new \addon\haonav\model\Links();
$model->cid = $cid;
$model->title = mb_substr($it['title'], 0, 100);
$model->url = $it['url'];
$model->status = $enable ? 2 : 1;
$model->save();
$count++;
}
Db::commit();
} catch (\Exception $e) {
Db::rollback();
return $this->result->error('导入失败: ' . $e->getMessage());
}
return $this->result->success([], "成功导入 {$count} 条(已忽略重复)");
}
/**
* 导出书签(Netscape 格式,附件下载)
*/
public function export()
{
$links = $this->model->where('status', '<>', 0)->order('cid', 'asc')->select();
$xml = "<!DOCTYPE NETSCAPE-Bookmark-file-1>\n"
. "<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=UTF-8\">\n"
. "<TITLE>网址导航书签</TITLE>\n<H1>网址导航书签</H1>\n<DL><p>\n";
foreach ($links as $l) {
$xml .= ' <A HREF="' . htmlspecialchars($l->url) . '" ADD_DATE="' . time() . '">'
. htmlspecialchars($l->title) . "</A>\n";
}
$xml .= "</DL><p>\n";
return Response::create($xml)->header([
'Content-Type' => 'text/html; charset=utf-8',
'Content-Disposition' => 'attachment; filename="haonav_bookmarks.html"',
]);
}
public function recyclebin()
{
$page = $this->request->param('page', 1);
$size = $this->request->param('size', 15);
if ($this->request->isAjax()) {
$data = $this->model->onlyTrashed()->paginate([
'page' => $page,
'list_rows' => $size,
]);
$this->result->success($data->items());
}
$this->view->assign('title', '回收站');
return $this->view->fetch('links/recyclebin');
}
public function delete(Request $request)
{
if ($this->request->isAjax() && $this->request->isDelete()) {
$ids = $this->request->param('ids', '');
$force = $this->request->param('force', false);
if (empty($ids)) {
$this->result->error('请选择要删除的数据');
}
try {
Db::transaction(function () use ($ids, $force) {
if ($force) {
$this->model->onlyTrashed()->whereIn('id', $ids)->select()->each(function ($item) {
$item->force()->delete();
});
} else {
$this->model->destroy($ids);
}
});
$this->result->success();
} catch (\think\exception\HttpResponseException $e) {
throw $e;
} catch (\Throwable $th) {
$this->result->error('删除失败: ' . $th->getMessage());
}
}
}
public function restore($ids = '')
{
if ($this->request->isAjax() && $this->request->isPut()) {
$ids = $this->request->param('ids', '');
if (empty($ids)) {
$this->result->error('请选择要还原的数据');
}
$idsArray = explode(',', $ids);
try {
Db::transaction(function () use ($idsArray) {
$this->model->onlyTrashed()->whereIn('id', $idsArray)->select()->each(function ($item) {
$item->restore();
});
});
$this->result->success();
} catch (\think\exception\HttpResponseException $e) {
throw $e;
} catch (\Throwable $th) {
$this->result->error('还原失败: ' . $th->getMessage());
}
}
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
return [
'name' => 'haonav',
'title' => '网址导航',
'intro' => '简洁实用的网址导航,支持分类管理、链接收录、热门/推荐与点击统计。',
'author' => 'ywxapp',
'website' => 'https://github.com',
'version' => '1.0.9',
'state' => 1,
'url' => '/haonav',
'license' => '',
'licenseto' => 0,
'config' => [
],
'events' => [
'bind' =>
[
],
'listen' =>
[
],
'subscribe' =>
[
],
],
'middleware' => [
'alias' =>
[
],
'priority' =>
[
],
],
'services' => [
],
'install_time' => 1785818546,
'update_time' => 1786365870,
];
+406
View File
@@ -0,0 +1,406 @@
-- ============================================================
-- addon/haonav/install.sql —— haonav 插件数据表
-- 由 docs/split_install_sql.py 从根 install.sql 抽取。
-- 框架约定:插件安装时由 ywxapp\service\AddonService 执行本文件
-- (仅允许 CREATE TABLE / INSERT,见 importsql 白名单)。
-- 表名须为 __PREFIX__<插件名>_*,与 __PREFIX__addon 等核心表命名一致。
-- ============================================================
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
CREATE TABLE IF NOT EXISTS `__PREFIX__haonav_category` (
`id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '分类ID',
`pid` int unsigned DEFAULT '0' COMMENT '父分类ID0表示顶级分类',
`title` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '分类名称',
`icon` varchar(100) COLLATE utf8mb4_0900_ai_ci DEFAULT '' COMMENT '分类图标emoji或图标路径',
`summary` varchar(50) COLLATE utf8mb4_0900_ai_ci DEFAULT '',
`keywords` varchar(100) COLLATE utf8mb4_0900_ai_ci DEFAULT '',
`description` varchar(200) COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '分类描述',
`sort` int DEFAULT '0' COMMENT '排序权重,数值越大越靠前',
`status` tinyint DEFAULT '1' COMMENT '状态:1-启用,0-禁用',
`website_count` int DEFAULT '0' COMMENT '该分类下的网址数量',
`click_count` int DEFAULT '0' COMMENT '该分类总点击数',
`create_at` int DEFAULT NULL COMMENT '创建时间',
`update_at` int DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_pid` (`pid`),
KEY `idx_status` (`status`),
KEY `idx_order` (`sort`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=143 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='网址分类表';
INSERT INTO `__PREFIX__haonav_category` (`id`, `pid`, `title`, `icon`, `summary`, `keywords`, `description`, `sort`, `status`, `website_count`, `click_count`, `create_at`, `update_at`) VALUES
-- 一级分类
(1, 0, '常用搜索', '🔍', '', '', '搜索引擎与门户入口', 140, 1, 0, 0, NULL, NULL),
(2, 0, '视频娱乐', '📺', '', '', '影视、短视频与音乐平台', 130, 1, 0, 0, NULL, NULL),
(3, 0, '社交沟通', '👥', '', '', '综合与海外社交平台', 120, 1, 0, 0, NULL, NULL),
(4, 0, '新闻资讯', '📰', '', '', '门户新闻与科技资讯', 110, 1, 0, 0, NULL, NULL),
(5, 0, '开发编程', '💻', '', '', '代码托管、技术社区与云服务', 100, 1, 0, 0, NULL, NULL),
(6, 0, '学习教育', '🎓', '', '', '在线课程与资料文库', 90, 1, 0, 0, NULL, NULL),
(7, 0, '购物电商', '🛒', '', '', '综合电商与海淘二手', 80, 1, 0, 0, NULL, NULL),
(8, 0, '办公效率', '📁', '', '', '文档协作、邮箱、云盘与会议', 70, 1, 0, 0, NULL, NULL),
(9, 0, '设计创意', '🎨', '', '', '设计素材与在线设计工具', 60, 1, 0, 0, NULL, NULL),
(10, 0, '实用工具', '🧰', '', '', '生活服务与在线小工具', 50, 1, 0, 0, NULL, NULL),
(11, 0, '财经金融', '💰', '', '', '行情理财与支付银行', 40, 1, 0, 0, NULL, NULL),
(12, 0, '旅游出行', '✈️', '', '', '交通出行与住宿预订', 30, 1, 0, 0, NULL, NULL),
(13, 0, '医疗健康', '🩺', '', '', '在线问诊与健康科普', 20, 1, 0, 0, NULL, NULL),
(14, 0, '政务民生', '🏛️', '', '', '政务服务与生活缴费', 10, 1, 0, 0, NULL, NULL),
-- 二级分类(pid 指向一级)
(11, 1, '综合搜索', '🌐', '', '', '百度、Google 等综合搜索引擎', 140, 1, 0, 0, NULL, NULL),
(12, 1, '学术资源', '📖', '', '', '学术、图书与知识检索', 139, 1, 0, 0, NULL, NULL),
(21, 2, '长视频', '🎬', '', '', '爱奇艺、腾讯视频等长视频', 130, 1, 0, 0, NULL, NULL),
(22, 2, '短视频直播', '📱', '', '', '抖音、快手等短视频平台', 129, 1, 0, 0, NULL, NULL),
(23, 2, '音乐', '🎵', '', '', '在线音乐播放平台', 128, 1, 0, 0, NULL, NULL),
(31, 3, '综合社交', '💬', '', '', '微信、微博等国内社交', 120, 1, 0, 0, NULL, NULL),
(32, 3, '国际社交', '🌍', '', '', 'X、Telegram 等海外社交', 119, 1, 0, 0, NULL, NULL),
(41, 4, '综合门户', '🗞️', '', '', '新浪、网易等综合门户', 110, 1, 0, 0, NULL, NULL),
(42, 4, '科技资讯', '', '', '', '36氪、少数派等科技媒体', 109, 1, 0, 0, NULL, NULL),
(51, 5, '代码托管', '🔧', '', '', 'GitHub、Gitee 等代码平台', 100, 1, 0, 0, NULL, NULL),
(52, 5, '技术社区', '📝', '', '', 'CSDN、掘金等技术社区', 99, 1, 0, 0, NULL, NULL),
(53, 5, '云服务', '☁️', '', '', '阿里云、腾讯云等云服务', 98, 1, 0, 0, NULL, NULL),
(61, 6, '慕课学习', '📚', '', '', '慕课网、Coursera 等课程', 90, 1, 0, 0, NULL, NULL),
(62, 6, '资料文库', '📄', '', '', '百度文库、道客巴巴等', 89, 1, 0, 0, NULL, NULL),
(71, 7, '综合电商', '🛍️', '', '', '淘宝、京东等综合电商', 80, 1, 0, 0, NULL, NULL),
(72, 7, '海淘二手', '♻️', '', '', '亚马逊、闲鱼等海淘二手', 79, 1, 0, 0, NULL, NULL),
(81, 8, '文档协作', '📝', '', '', '腾讯文档、Notion 等协作', 70, 1, 0, 0, NULL, NULL),
(82, 8, '邮箱', '📧', '', '', 'QQ邮箱、Gmail 等邮箱', 69, 1, 0, 0, NULL, NULL),
(83, 8, '云盘', '💾', '', '', '百度网盘、OneDrive 等', 68, 1, 0, 0, NULL, NULL),
(84, 8, '会议办公', '🎥', '', '', '腾讯会议、钉钉等', 67, 1, 0, 0, NULL, NULL),
(91, 9, '设计素材', '🖼️', '', '', '千图网、Unsplash 等素材', 60, 1, 0, 0, NULL, NULL),
(92, 9, '设计工具', '🛠️', '', '', 'Figma、Canva 等设计工具', 59, 1, 0, 0, NULL, NULL),
(101, 10, '生活服务', '🚌', '', '', '快递、地图、出行等', 50, 1, 0, 0, NULL, NULL),
(102, 10, '在线工具', '⚙️', '', '', 'JSON、二维码等在线工具', 49, 1, 0, 0, NULL, NULL),
(111, 11, '行情理财', '📈', '', '', '东方财富、雪球等', 40, 1, 0, 0, NULL, NULL),
(112, 11, '支付银行', '🏦', '', '', '支付宝、网银等', 39, 1, 0, 0, NULL, NULL),
(121, 12, '交通出行', '🚄', '', '', '携程、12306 等出行', 30, 1, 0, 0, NULL, NULL),
(122, 12, '住宿预订', '🏨', '', '', '美团、Booking 等住宿', 29, 1, 0, 0, NULL, NULL),
(131, 13, '在线问诊', '💊', '', '', '丁香医生、好大夫等', 20, 1, 0, 0, NULL, NULL),
(132, 13, '健康科普', '🌿', '', '', '丁香园等健康科普', 19, 1, 0, 0, NULL, NULL),
(141, 14, '政务服务', '📋', '', '', '国家政务平台、粤省事等', 10, 1, 0, 0, NULL, NULL),
(142, 14, '生活缴费', '💡', '', '', '社保、水电网查询', 9, 1, 0, 0, NULL, NULL);
CREATE TABLE IF NOT EXISTS `__PREFIX__haonav_config` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '变量名',
`group` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT '' COMMENT '分组',
`title` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT '' COMMENT '变量标题',
`tip` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT '' COMMENT '变量描述',
`type` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT '' COMMENT '类型:string,text,int,bool,array,datetime,date,file',
`value` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci COMMENT '变量值',
`content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci COMMENT '变量字典数据',
`rule` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT '' COMMENT '验证规则',
`extend` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT '' COMMENT '扩展属性',
`setting` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT '' COMMENT '配置',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_0900_ai_ci ROW_FORMAT=COMPACT COMMENT='系统配置';
CREATE TABLE IF NOT EXISTS `__PREFIX__haonav_links` (
`id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '网址ID',
`cid` int unsigned NOT NULL COMMENT '所属分类ID',
`title` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '网站名称',
`url` varchar(500) COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '网站URL',
`icon` varchar(200) COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '网站图标路径或emoji',
`description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci COMMENT '网站描述',
`keywords` varchar(200) COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '关键词,用于搜索',
`pinyin` varchar(255) COLLATE utf8mb4_0900_ai_ci DEFAULT '' COMMENT '标题拼音(首字母+全拼),用于拼音搜索',
`click_count` int unsigned DEFAULT '0' COMMENT '点击次数',
`sort` int DEFAULT '0' COMMENT '排序权重',
`is_hot` tinyint DEFAULT '0' COMMENT '是否热门:1-热门,0-普通',
`is_recommend` tinyint DEFAULT '0' COMMENT '是否推荐:1-推荐,0-不推荐',
`status` tinyint DEFAULT '1' COMMENT '状态:1-启用,0-禁用,2-待审核',
`screenshot` varchar(200) COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '网站截图路径',
`favicon` varchar(200) COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '网站favicon路径',
`rating` decimal(2,1) DEFAULT '0.0' COMMENT '综合评分(0-5)',
`rating_count` int unsigned DEFAULT '0' COMMENT '评分人数',
`status_code` int DEFAULT NULL COMMENT '最近死链检测HTTP状态码',
`last_check_at` int DEFAULT NULL COMMENT '最近死链检测时间',
`fail_count` int unsigned NOT NULL DEFAULT '0' COMMENT '连续检测失败次数(死链)',
`dead_at` int DEFAULT NULL COMMENT '最近被判定为死链(自动下线)的时间戳',
`create_at` int DEFAULT NULL COMMENT '创建时间',
`update_at` int DEFAULT NULL COMMENT '更新时间',
`last_click_at` int DEFAULT NULL COMMENT '最后点击时间',
`pid` varchar(100) COLLATE utf8mb4_0900_ai_ci DEFAULT '' COMMENT '返利PID/推广位ID,点击跳转拼接返利参数',
PRIMARY KEY (`id`),
KEY `idx_category_id` (`cid`),
KEY `idx_click_count` (`click_count`),
KEY `idx_is_hot` (`is_hot`),
KEY `idx_is_recommend` (`is_recommend`),
KEY `idx_status` (`status`),
KEY `idx_sort_order` (`sort`) USING BTREE,
FULLTEXT KEY `idx_search` (`title`,`description`,`keywords`)
) ENGINE=InnoDB AUTO_INCREMENT=121 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='网址信息表';
INSERT INTO `__PREFIX__haonav_links` (`id`, `cid`, `title`, `url`, `icon`, `description`, `keywords`, `click_count`, `sort`, `is_hot`, `is_recommend`, `status`, `screenshot`, `favicon`, `create_at`, `update_at`, `last_click_at`) VALUES
-- 综合搜索
(1, 11, '百度', 'https://www.baidu.com', '🔍', '全球最大的中文搜索引擎', '百度,搜索,引擎', 45000, 100, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(2, 11, 'Google', 'https://www.google.com', '🌐', '全球搜索引擎巨头', 'Google,搜索,英文', 30000, 95, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(3, 11, '必应', 'https://www.bing.com', '🔎', '微软旗下搜索引擎', '必应,bing,搜索', 18000, 90, 0, 1, 1, NULL, NULL, NULL, NULL, NULL),
(4, 11, '搜狗', 'https://www.sogou.com', '🐶', '腾讯旗下搜索引擎', '搜狗,搜索,输入法', 12000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(5, 11, '360搜索', 'https://www.so.com', '🛡️', '360安全搜索引擎', '360,搜索,安全', 9000, 80, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(6, 11, 'Yahoo', 'https://www.yahoo.com', '🟣', '老牌门户与搜索引擎', '雅虎,yahoo,门户', 7000, 75, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 学术资源
(7, 12, '中国知网', 'https://www.cnki.net', '📚', '中文学术文献数据库', '知网,论文,学术', 8000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(8, 12, '豆瓣', 'https://www.douban.com', '🎞️', '书影音与知识社区', '豆瓣,书影音,社区', 16000, 95, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(9, 12, '维基百科', 'https://www.wikipedia.org', '📖', '自由的百科全书', '维基,百科,知识', 14000, 90, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(10, 12, '微信读书', 'https://weread.qq.com', '📕', '腾讯在线阅读平台', '微信读书,电子书,阅读', 9000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(11, 12, '鸠摩搜书', 'https://www.jiumodiary.com', '🔏', '电子书聚合搜索', '鸠摩,电子书,搜书', 5000, 80, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 长视频
(12, 21, '爱奇艺', 'https://www.iqiyi.com', '🔴', '悦享品质视频平台', '爱奇艺,视频,会员', 26000, 100, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(13, 21, '腾讯视频', 'https://v.qq.com', '🟢', '海量正版高清视频', '腾讯视频,视频,电视剧', 24000, 95, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(14, 21, '优酷', 'https://www.youku.com', '🟠', '阿里巴巴视频平台', '优酷,视频,土豆', 20000, 90, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(15, 21, '芒果TV', 'https://www.mgtv.com', '🟡', '湖南卫视官方视频', '芒果TV,视频,综艺', 18000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(16, 21, '哔哩哔哩', 'https://www.bilibili.com', '🔵', '国内知名弹幕视频网', 'B站,视频,弹幕', 32000, 88, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(17, 21, '搜狐视频', 'https://tv.sohu.com', '', '搜狐高清视频平台', '搜狐视频,视频,美剧', 9000, 80, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 短视频直播
(18, 22, '抖音', 'https://www.douyin.com', '🎵', '记录美好生活的短视频', '抖音,短视频,直播', 35000, 100, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(19, 22, '快手', 'https://www.kuaishou.com', '🎬', '普惠的短视频社区', '快手,短视频,直播', 22000, 95, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(20, 22, '微信视频号', 'https://channels.weixin.qq.com', '💬', '微信生态短视频', '视频号,微信,短视频', 14000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 音乐
(21, 23, '网易云音乐', 'https://music.163.com', '🔴', '有态度的音乐平台', '网易云,音乐,歌单', 28000, 100, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(22, 23, 'QQ音乐', 'https://y.qq.com', '🟢', '腾讯在线音乐平台', 'QQ音乐,音乐,听歌', 26000, 95, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(23, 23, '酷狗音乐', 'https://www.kugou.com', '🟣', '庞大曲库音乐平台', '酷狗,音乐,听歌', 15000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(24, 23, '酷我音乐', 'https://www.kuwo.cn', '🔵', '无损音乐正版试听', '酷我,音乐,无损', 9000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(25, 23, '千千音乐', 'https://music.taihe.com', '🟡', '太合音乐旗下平台', '千千音乐,音乐,在线听', 5000, 80, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(26, 23, 'Spotify', 'https://www.spotify.com', '🟢', '全球流媒体音乐服务', 'Spotify,音乐,海外', 8000, 78, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 综合社交
(27, 31, '微信', 'https://weixin.qq.com', '💬', '腾讯即时通讯与社交', '微信,社交,聊天', 40000, 100, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(28, 31, 'QQ', 'https://im.qq.com', '🐧', '腾讯即时通讯工具', 'QQ,社交,聊天', 30000, 95, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(29, 31, '微博', 'https://www.weibo.com', '🔶', '随时随地发现新鲜事', '微博,社交,微博客', 28000, 90, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(30, 31, '小红书', 'https://www.xiaohongshu.com', '🔴', '标记生活的种草社区', '小红书,社交,种草', 24000, 88, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(31, 31, '知乎', 'https://www.zhihu.com', '🔵', '有问题就会有答案', '知乎,问答,知识', 20000, 85, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
-- 国际社交
(32, 32, 'X (Twitter)', 'https://x.com', '', '马斯卡旗下的社交平台', 'X,Twitter,海外社交', 16000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(33, 32, 'Telegram', 'https://telegram.org', '🔵', '加密即时通讯与频道', 'Telegram,电报,聊天', 12000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(34, 32, 'Facebook', 'https://www.facebook.com', '🔵', '全球最大社交网络', 'Facebook,脸书,海外社交', 14000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(35, 32, 'Instagram', 'https://www.instagram.com', '🔴', '图片与短视频社交', 'Instagram,ins,海外社交', 13000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(36, 32, 'Reddit', 'https://www.reddit.com', '🟠', '全球兴趣社区论坛', 'Reddit,论坛,海外', 9000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(37, 32, 'YouTube', 'https://www.youtube.com', '🔴', '全球最大视频平台', 'YouTube,视频,海外', 30000, 80, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
-- 综合门户
(38, 41, '新浪', 'https://www.sina.com.cn', '🔴', '综合门户与新闻', '新浪,门户,新闻', 22000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(39, 41, '搜狐', 'https://www.sohu.com', '🔵', '综合门户新闻平台', '搜狐,门户,新闻', 16000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(40, 41, '网易', 'https://www.163.com', '🔴', '综合门户与邮箱', '网易,门户,新闻', 18000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(41, 41, '腾讯新闻', 'https://news.qq.com', '🟢', '腾讯新闻资讯平台', '腾讯新闻,新闻,资讯', 14000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(42, 41, '今日头条', 'https://www.toutiao.com', '🔴', '个性化推荐资讯', '今日头条,新闻,资讯', 20000, 85, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(43, 41, '凤凰网', 'https://www.ifeng.com', '🔶', '全球华人资讯门户', '凤凰网,门户,新闻', 9000, 80, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 科技资讯
(44, 42, '36氪', 'https://36kr.com', '🔵', '新经济商业媒体', '36氪,科技,创业', 8000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(45, 42, '虎嗅', 'https://www.huxiu.com', '🟠', '有洞察的商业科技媒体', '虎嗅,科技,商业', 7000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(46, 42, '少数派', 'https://sspai.com', '🔵', '效率工具与数字生活', '少数派,科技,效率', 6000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(47, 42, '钛媒体', 'https://www.tmtpost.com', '🔴', '财经科技资讯平台', '钛媒体,科技,财经', 4000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 代码托管
(48, 51, 'GitHub', 'https://github.com', '🐙', '全球最大代码托管平台', 'GitHub,代码,开源', 35000, 100, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(49, 51, 'GitLab', 'https://gitlab.com', '🟠', 'DevOps 代码协作平台', 'GitLab,代码,协作', 9000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(50, 51, 'Gitee', 'https://gitee.com', '🔴', '国内代码托管平台', 'Gitee,码云,代码', 12000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(51, 51, 'Coding', 'https://dev.tencent.com', '🔵', '腾讯云代码托管', 'Coding,代码,腾讯云', 4000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 技术社区
(52, 52, 'CSDN', 'https://www.csdn.net', '🔴', '中文IT技术社区', 'CSDN,技术,博客', 20000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(53, 52, '掘金', 'https://juejin.cn', '🔵', '面向开发者的技术社区', '掘金,技术,前端', 14000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(54, 52, '博客园', 'https://www.cnblogs.com', '🔵', '开发者博客社区', '博客园,技术,博客', 8000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(55, 52, 'Stack Overflow', 'https://stackoverflow.com', '🟠', '全球编程问答社区', 'StackOverflow,技术,问答', 16000, 88, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(56, 52, 'V2EX', 'https://www.v2ex.com', '🔵', '创意工作者的社区', 'V2EX,技术,社区', 5000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(57, 52, '开源中国', 'https://www.oschina.net', '🔴', '中文开源技术社区', '开源中国,技术,开源', 6000, 82, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 云服务
(58, 53, '阿里云', 'https://www.aliyun.com', '🟠', '阿里云计算平台', '阿里云,云,服务器', 18000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(59, 53, '腾讯云', 'https://cloud.tencent.com', '🔵', '腾讯云计算平台', '腾讯云,云,服务器', 14000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(60, 53, '华为云', 'https://www.huaweicloud.com', '🔴', '华为云计算平台', '华为云,云,服务器', 9000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(61, 53, '百度智能云', 'https://cloud.baidu.com', '🔵', '百度云计算平台', '百度云,云,AI', 5000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(62, 53, 'Cloudflare', 'https://www.cloudflare.com', '🟠', '全球CDN与安全服务', 'Cloudflare,CDN,海外', 8000, 82, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(63, 53, '七牛云', 'https://www.qiniu.com', '🟢', '对象存储与CDN', '七牛云,存储,CDN', 4000, 80, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 慕课学习
(64, 61, '中国大学MOOC', 'https://www.icourse163.org', '🔴', '国家精品在线课程', '慕课,大学,课程', 9000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(65, 61, '网易云课堂', 'https://study.163.com', '🔴', '实用技能学习平台', '云课堂,课程,学习', 8000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(66, 61, '慕课网', 'https://www.imooc.com', '🟢', 'IT技能学习平台', '慕课网,IT,编程', 10000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(67, 61, 'Coursera', 'https://www.coursera.org', '🔵', '全球在线课程平台', 'Coursera,课程,海外', 7000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(68, 61, '学堂在线', 'https://www.xuetangx.com', '🔵', '清华出品慕课平台', '学堂在线,课程,大学', 4000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(69, 61, 'B站课堂', 'https://www.bilibili.com/v/cheese', '🔵', 'B站知识区课程', 'B站课堂,课程,学习', 5000, 82, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 资料文库
(70, 62, '百度文库', 'https://wenku.baidu.com', '🔵', '文档资料分享平台', '百度文库,文档,资料', 9000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(71, 62, '道客巴巴', 'https://www.doc88.com', '🔴', '在线文档分享平台', '道客巴巴,文档,资料', 4000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(72, 62, '原创力文档', 'https://max.book118.com', '🟠', '专业文档下载站', '原创力,文档,下载', 3000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 综合电商
(73, 71, '淘宝', 'https://www.taobao.com', '🔶', '淘!我喜欢', '淘宝,购物,电商', 40000, 100, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(74, 71, '京东', 'https://www.jd.com', '🔴', '正品低价品质保障', '京东,购物,自营', 38000, 95, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(75, 71, '拼多多', 'https://www.pinduoduo.com', '🔴', '拼着买更便宜', '拼多多,团购,便宜', 32000, 90, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(76, 71, '天猫', 'https://www.tmall.com', '🔴', '品质好物聚集地', '天猫,购物,品牌', 28000, 88, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(77, 71, '苏宁易购', 'https://www.suning.com', '🔵', '家电3C综合电商', '苏宁,购物,家电', 9000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 海淘二手
(78, 72, '亚马逊', 'https://www.amazon.cn', '🔴', '全球综合电商平台', '亚马逊,海淘,电商', 12000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(79, 72, '闲鱼', 'https://www.goofish.com', '🔶', '阿里二手交易社区', '闲鱼,二手,转卖', 16000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(80, 72, '转转', 'https://www.zhuanzhuan.com', '🔵', '二手交易平台', '转转,二手,交易', 7000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(81, 72, 'eBay', 'https://www.ebay.com', '🔴', '全球在线拍卖与购物', 'eBay,海淘,海外', 6000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 文档协作
(82, 81, '腾讯文档', 'https://docs.qq.com', '🔵', '多人实时在线文档', '腾讯文档,在线文档,协作', 14000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(83, 81, '石墨文档', 'https://shimo.im', '🟢', '轻盈的在线协作文档', '石墨文档,协作,文档', 6000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(84, 81, '飞书', 'https://www.feishu.cn', '🔵', '一站式办公协作平台', '飞书,办公,协作', 10000, 92, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(85, 81, '钉钉文档', 'https://www.dingtalk.com', '🔵', '阿里办公协作套件', '钉钉,办公,协作', 9000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(86, 81, 'Notion', 'https://www.notion.so', '', '全能笔记与协作工具', 'Notion,笔记,海外', 8000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(87, 81, 'Google Docs', 'https://docs.google.com', '🔵', '谷歌在线文档套件', 'GoogleDocs,文档,海外', 6000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 邮箱
(88, 82, 'QQ邮箱', 'https://mail.qq.com', '🔵', '腾讯免费邮箱', 'QQ邮箱,邮箱,邮件', 20000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(89, 82, '网易邮箱', 'https://mail.163.com', '🔴', '网易免费邮箱', '网易邮箱,邮箱,邮件', 12000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(90, 82, 'Outlook', 'https://outlook.live.com', '🔵', '微软邮箱服务', 'Outlook,邮箱,微软', 9000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(91, 82, 'Gmail', 'https://mail.google.com', '🔴', '谷歌邮箱服务', 'Gmail,邮箱,谷歌', 11000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(92, 82, 'Foxmail', 'https://www.foxmail.com', '🔵', '腾讯邮箱客户端', 'Foxmail,邮箱,客户端', 3000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 云盘
(93, 83, '百度网盘', 'https://pan.baidu.com', '🔵', '国内最大云存储', '百度网盘,云盘,存储', 26000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(94, 83, '阿里云盘', 'https://www.aliyundrive.com', '🔴', '不限速个人云盘', '阿里云盘,云盘,存储', 14000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(95, 83, 'OneDrive', 'https://onedrive.live.com', '🔵', '微软云存储服务', 'OneDrive,云盘,微软', 9000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(96, 83, '蓝奏云', 'https://www.lanzou.com', '🟢', '不限速文件分享', '蓝奏云,云盘,分享', 5000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(97, 83, '腾讯微云', 'https://www.weiyun.com', '🔵', '腾讯云存储服务', '微云,云盘,腾讯', 4000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 会议办公
(98, 84, '腾讯会议', 'https://meeting.tencent.com', '🔵', '高清流畅视频会议', '腾讯会议,会议,视频', 14000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(99, 84, '钉钉', 'https://www.dingtalk.com', '🔵', '阿里企业办公平台', '钉钉,办公,企业', 12000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(100, 84, '飞书会议', 'https://www.feishu.cn', '🔵', '字节一站式办公', '飞书,会议,办公', 7000, 92, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(101, 84, 'Zoom', 'https://www.zoom.us', '🔵', '国际视频会议工具', 'Zoom,会议,海外', 8000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(102, 84, '企业微信', 'https://work.weixin.qq.com', '🔢', '腾讯企业通讯工具', '企业微信,办公,企业', 9000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 设计素材
(103, 91, '千图网', 'https://www.58pic.com', '🔴', '原创设计素材库', '千图网,素材,设计', 6000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(104, 91, '包图网', 'https://www.ibaotu.com', '🔶', '商业设计素材平台', '包图网,素材,设计', 4000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(105, 91, '我图网', 'https://www.ooopic.com', '🔵', '正版设计素材下载', '我图网,素材,设计', 3000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(106, 91, 'Unsplash', 'https://unsplash.com', '', '免费高清摄影图库', 'Unsplash,图片,海外', 9000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(107, 91, 'Pexels', 'https://www.pexels.com', '🔵', '免费摄影视频素材', 'Pexels,图片,海外', 6000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(108, 91, 'Pixabay', 'https://pixabay.com', '🔵', '免费正版图库与视频', 'Pixabay,图片,海外', 4000, 82, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 设计工具
(109, 92, 'Figma', 'https://www.figma.com', '🔴', '协作式UI设计工具', 'Figma,设计,UI', 10000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(110, 92, 'Canva', 'https://www.canva.cn', '🔵', '在线平面设计工具', 'Canva,设计,海报', 8000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(111, 92, '稿定设计', 'https://www.gaoding.com', '🔴', '电商新媒体设计工具', '稿定设计,设计,模板', 4000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(112, 92, '即时设计', 'https://js.design', '🔵', '国产协作式UI工具', '即时设计,UI,设计', 4000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(113, 92, '墨刀', 'https://modao.cc', '🔵', '在线原型设计工具', '墨刀,原型,设计', 3000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 生活服务
(114, 101, '快递100', 'https://www.kuaidi100.com', '🔵', '快递查询与寄件', '快递100,快递,查询', 9000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(115, 101, '12306', 'https://www.12306.cn', '🔴', '中国铁路购票官网', '12306,火车票,铁路', 18000, 95, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(116, 101, '高德地图', 'https://www.amap.com', '🔵', '高德导航与地图', '高德,地图,导航', 16000, 92, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(117, 101, '百度地图', 'https://map.baidu.com', '🔵', '百度地图与导航', '百度地图,地图,导航', 12000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(118, 101, '中国天气', 'https://www.weather.com.cn', '🌤️', '中央气象台天气', '天气,气象,预报', 8000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(119, 101, '美团', 'https://www.meituan.com', '🔶', '本地生活服务平台', '美团,外卖,生活', 20000, 88, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 在线工具
(120, 102, 'JSON在线解析', 'https://www.json.cn', '🔧', 'JSON格式化与校验', 'JSON,工具,格式化', 8000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(121, 102, '草料二维码', 'https://cli.im', '🔲', '二维码生成与解码', '草料,二维码,工具', 5000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(122, 102, 'ProcessOn', 'https://www.processon.com', '🔵', '在线流程图与思维导图', 'ProcessOn,流程图,思维导图', 5000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(123, 102, '站长工具', 'https://tool.chinaz.com', '🔵', 'SEO与建站查询', '站长工具,SEO,查询', 6000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(124, 102, 'Regex101', 'https://regex101.com', '🔴', '正则表达式在线测试', 'Regex,正则,海外', 4000, 82, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 行情理财
(125, 111, '东方财富', 'https://www.eastmoney.com', '🔴', '财经金融资讯门户', '东方财富,财经,股票', 16000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(126, 111, '同花顺', 'https://www.10jqka.com.cn', '🔴', '股票行情交易软件', '同花顺,股票,行情', 9000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(127, 111, '雪球', 'https://xueqiu.com', '🔴', '投资者社区与行情', '雪球,股票,投资', 9000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(128, 111, '天天基金', 'https://fund.eastmoney.com', '🔵', '基金净值与申购', '天天基金,基金,理财', 6000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 支付银行
(129, 112, '支付宝', 'https://www.alipay.com', '🔵', '支付就用支付宝', '支付宝,支付,金融', 40000, 100, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(130, 112, '微信支付', 'https://pay.weixin.qq.com', '💚', '腾讯移动支付', '微信支付,支付,金融', 30000, 95, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(131, 112, '中国银联', 'https://www.unionpay.com', '🔴', '银行卡联合组织', '银联,银行,支付', 5000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(132, 112, '网银在线', 'https://www.chinabank.com.cn', '🔵', '京东旗下支付', '网银在线,支付,京东', 3000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 交通出行
(133, 121, '携程', 'https://www.ctrip.com', '🔵', '机票酒店预订平台', '携程,旅游,机票', 18000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(134, 121, '去哪儿', 'https://www.qunar.com', '🔶', '旅行预订搜索', '去哪儿,旅游,机票', 9000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(135, 121, '飞猪', 'https://www.fliggy.com', '🔴', '阿里旅行平台', '飞猪,旅游,机票', 8000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(136, 121, '12306', 'https://www.12306.cn', '🔴', '铁路购票官网', '12306,火车票,出行', 18000, 92, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(137, 121, '高德打车', 'https://www.amap.com', '🔵', '一键呼叫网约车', '高德打车,出行,网约车', 6000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 住宿预订
(138, 122, '美团酒店', 'https://hotel.meituan.com', '🔶', '本地酒店预订', '美团,酒店,住宿', 12000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(139, 122, 'Airbnb', 'https://www.airbnb.cn', '🔴', '全球民宿短租', 'Airbnb,民宿,海外', 6000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(140, 122, 'Booking', 'https://www.booking.com', '🔵', '全球酒店预订', 'Booking,酒店,海外', 7000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(141, 122, '途家', 'https://www.tujia.com', '🔵', '国内民宿预订', '途家,民宿,住宿', 4000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 在线问诊
(142, 131, '丁香医生', 'https://dxy.com', '🔴', '专业健康科普与问诊', '丁香医生,健康,问诊', 8000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(143, 131, '好大夫在线', 'https://www.haodf.com', '🔵', '医患对接问诊平台', '好大夫,问诊,医生', 6000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(144, 131, '微医', 'https://www.guahao.com', '🔵', '互联网医疗服务', '微医,挂号,问诊', 4000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(145, 131, '平安健康', 'https://health.pingan.com', '🔴', '平安互联网医疗', '平安健康,问诊,医疗', 4000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 健康科普
(146, 132, '丁香园', 'https://www.dxy.cn', '🔴', '医药健康专业社区', '丁香园,医学,科普', 5000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(147, 132, '腾讯健康', 'https://health.qq.com', '🔵', '腾讯健康科普服务', '腾讯健康,健康,科普', 4000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(148, 132, '39健康网', 'https://www.39.net', '🔵', '大众健康资讯门户', '39健康网,健康,科普', 3000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 政务服务
(149, 141, '国家政务服务平台', 'https://gjzwfw.www.gov.cn', '🔴', '全国一体化政务门户', '国家政务,政务,服务', 6000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(150, 141, '粤省事', 'https://www.gdyxzc.gov.cn', '🔵', '广东政务服务小程序', '粤省事,政务,广东', 4000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(151, 141, '浙里办', 'https://www.zjzwfw.gov.cn', '🔵', '浙江政务服务门户', '浙里办,政务,浙江', 3000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(152, 141, '国务院客户端', 'https://www.gov.cn', '🔴', '中央人民政府门户', '国务院,政务,政策', 3000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 生活缴费
(153, 142, '社保查询', 'http://si.12333.gov.cn', '🔵', '全国社保公共服务', '社保,查询,缴费', 4000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(154, 142, '水电网缴费', 'https://www.95598.cn', '', '国家电网与公共事业', '水电,缴费,电网', 3000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(155, 142, '个人所得税', 'https://etax.chinatax.gov.cn', '🔴', '自然人税务平台', '个税,税务,申报', 3000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL);
-- 导航配置种子(缺失时插入,供后台「导航配置」页管理)
INSERT INTO `__PREFIX__haonav_config` (`id`, `name`, `group`, `title`, `tip`, `type`, `value`, `content`, `rule`, `extend`, `setting`) VALUES
(1, 'default_engine', 'basic', '默认搜索引擎', '首页搜索框默认选中的引擎', 'select', 'baidu', 'baidu,bing,google,sogou,site', '', '', ''),
(2, 'enable_submit', 'basic', '开启网址投稿', '允许访客提交网址(需审核)', 'radio', '1', '1,0', '', '', ''),
(3, 'enable_hot_api', 'hot', '开启实时热搜', '首页展示第三方实时热搜榜', 'radio', '0', '1,0', '', '', ''),
(4, 'hot_api_source', 'hot', '热搜来源', '选择热搜数据来源', 'select', 'baidu', 'baidu,weibo,zhihu', '', '', ''),
(5, 'enable_weather', 'weather', '开启天气组件', '首页顶部展示实时天气', 'radio', '0', '1,0', '', '', ''),
(6, 'weather_city', 'weather', '默认城市', '留空则按访问者IP自动定位', 'string', '', '', '', '', ''),
(7, 'check_token', 'task', '死链检测令牌', 'cron 调用 /haonav/task/checklinks?token= 时校验', 'string', '', '', '', '', ''),
(8, 'enable_auto_check', 'task', '开启自动死链巡检', '无 cron 时由前台访问触发,每小时最多一批(5条)', 'radio', '0', '1,0', '', '', ''),
(9, 'seo_title', 'seo', 'SEO 标题', '首页 title 标签内容', 'string', '网址导航 - 精选实用网站大全', '', '', '', ''),
(10, 'seo_keywords', 'seo', 'SEO 关键词', '首页 keywords meta 标签', 'string', '网址导航,常用网址,网站大全,上网导航', '', '', '', ''),
(11, 'seo_description', 'seo', 'SEO 描述', '首页 description meta 标签', 'textarea', '简洁实用的网址导航,收录精选优质网站,支持分类浏览、站内搜索、热门排行与网址投稿。', '', '', '', ''),
(12, 'enable_rebate', '返利', '开启返利PID', '开启后,命中适用域名的链接点击将自动拼接返利PID', 'radio', '0', '1,0', '', '', ''),
(13, 'rebate_param', '返利', '返利参数名', '拼接在网址后的参数名,默认 pid', 'string', 'pid', '', '', '', ''),
(14, 'rebate_domains', '返利', '返利适用域名', '仅这些域名(含子域名)会拼接返利PID,逗号或换行分隔,如 taobao.com,jd.com,pinduoduo.com', 'textarea', 'taobao.com,jd.com,pinduoduo.com', '', '', '', ''),
(15, 'deadlink_auto', 'task', '死链自动下线', '连续检测失败达阈值后,自动将链接置为禁用(下线)', 'radio', '0', '1,0', '', '', ''),
(16, 'deadlink_threshold', 'task', '下线阈值(连续失败次数)', '达到该连续失败次数才下线,避免单次网络抖动误杀(建议2)', 'number', '2', '', '', '', ''),
(17, 'deadlink_recover', 'task', '死链恢复自动上线', '链接重新可达时,自动恢复为启用(仅对检测器自动下线的链接生效)', 'radio', '0', '1,0', '', '', '');
-- 广告位表(首次安装创建;老库由 SchemaGuard 运行时自愈)
CREATE TABLE IF NOT EXISTS `__PREFIX__haonav_ads` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`slot` varchar(30) NOT NULL DEFAULT 'home_top' COMMENT '广告位标识',
`title` varchar(100) NOT NULL DEFAULT '' COMMENT '广告标题',
`type` tinyint NOT NULL DEFAULT '1' COMMENT '1=图片广告 2=代码广告',
`image` varchar(500) DEFAULT '' COMMENT '图片地址',
`url` varchar(500) DEFAULT '' COMMENT '跳转链接(图片广告)',
`code` text COMMENT '自定义代码(联盟JS/HTML)',
`sort` int DEFAULT '0' COMMENT '排序权重',
`status` tinyint DEFAULT '1' COMMENT '1-启用 0-禁用',
`start_at` int DEFAULT NULL COMMENT '生效开始时间',
`end_at` int DEFAULT NULL COMMENT '生效结束时间',
`click_count` int unsigned DEFAULT '0' COMMENT '点击次数',
`create_at` int DEFAULT NULL,
`update_at` int DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_slot` (`slot`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_0900_ai_ci COMMENT='网址导航广告位';
-- 自助申请表(友链/广告;首次安装创建;老库由 SchemaGuard 运行时自愈)
CREATE TABLE IF NOT EXISTS `__PREFIX__haonav_apply` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`type` tinyint NOT NULL DEFAULT '1' COMMENT '1=收录/友链 2=广告合作',
`title` varchar(100) NOT NULL DEFAULT '' COMMENT '网站/品牌名称',
`url` varchar(500) NOT NULL DEFAULT '' COMMENT '网站地址',
`description` varchar(500) DEFAULT '' COMMENT '网站/合作说明',
`contact` varchar(100) DEFAULT '' COMMENT '联系方式(邮箱/QQ/微信)',
`slot` varchar(30) DEFAULT '' COMMENT '意向广告位(广告合作)',
`ip` varchar(46) DEFAULT '' COMMENT '提交IP',
`status` tinyint DEFAULT '0' COMMENT '0待审核 1已通过 2已拒绝',
`reply` varchar(255) DEFAULT '' COMMENT '审核备注',
`link_id` int unsigned DEFAULT '0' COMMENT '通过后写入的links表ID',
`create_at` int DEFAULT NULL,
`update_at` int DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_status` (`status`),
KEY `idx_type` (`type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_0900_ai_ci COMMENT='网址导航自助申请(友链/广告)';
-- 点击统计聚合表(按链接/日期/小时;后台「点击热力图」数据源;老库由 Index::site 运行时自愈)
CREATE TABLE IF NOT EXISTS `__PREFIX__haonav_click_stats` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`link_id` int unsigned NOT NULL DEFAULT '0' COMMENT '网址ID',
`cid` int unsigned NOT NULL DEFAULT '0' COMMENT '分类ID',
`date` date NOT NULL COMMENT '点击日期 YYYY-MM-DD',
`hour` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '点击小时 0-23',
`clicks` int unsigned NOT NULL DEFAULT '0' COMMENT '该日期该小时累计点击',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_link_date_hour` (`link_id`,`date`,`hour`),
KEY `idx_date` (`date`),
KEY `idx_cid` (`cid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_0900_ai_ci COMMENT='点击统计聚合(按链接/日期/小时)';
CREATE TABLE IF NOT EXISTS `__PREFIX__haonav_ratings` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`user_id` int unsigned NOT NULL COMMENT '用户ID',
`link_id` int unsigned NOT NULL COMMENT '网址ID',
`score` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '评分 1-5',
`create_at` int DEFAULT NULL,
`update_at` int DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_user_link` (`user_id`,`link_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_0900_ai_ci COMMENT='网址评分记录';
SET FOREIGN_KEY_CHECKS = 1;
+122
View File
@@ -0,0 +1,122 @@
<?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\library;
/**
* 轻量拼音工具(零依赖)
*
* - 首字母:基于 GBK 编码区间推导(覆盖 GB2312 常用汉字),如「淘宝网」-> tbw
* - 全拼:若安装了 overtrue/pinyin 则自动启用;未安装时降级为仅首字母
* - keywordsOf() 产出「首字母 + 全拼」搜索串,存入 links.pinyin LIKE 检索
*/
class Pinyin
{
/**
* GBK 双字节码位 -> 拼音首字母 区间表(GB2312 一级/二级汉字按拼音排序的特性)
* 每项:[起始码位, 结束码位, 字母]
*/
protected const RANGES = [
[0xB0A1, 0xB0C4, 'a'],
[0xB0C5, 0xB2C0, 'b'],
[0xB2C1, 0xB4ED, 'c'],
[0xB4EE, 0xB6E9, 'd'],
[0xB6EA, 0xB7A1, 'e'],
[0xB7A2, 0xB8C0, 'f'],
[0xB8C1, 0xB9FD, 'g'],
[0xB9FE, 0xBBF6, 'h'],
[0xBBF7, 0xBFA5, 'j'],
[0xBFA6, 0xC0AB, 'k'],
[0xC0AC, 0xC2E7, 'l'],
[0xC2E8, 0xC4C2, 'm'],
[0xC4C3, 0xC5B5, 'n'],
[0xC5B6, 0xC5BD, 'o'],
[0xC5BE, 0xC6D9, 'p'],
[0xC6DA, 0xC8BA, 'q'],
[0xC8BB, 0xC8F5, 'r'],
[0xC8F6, 0xCBF9, 's'],
[0xCBFA, 0xCDD9, 't'],
[0xCDDA, 0xCEF3, 'w'],
[0xCEF4, 0xD1B8, 'x'],
[0xD1B9, 0xD4D0, 'y'],
[0xD4D1, 0xD7F9, 'z'],
];
/**
* 取单字符的拼音首字母;ASCII 字母数字原样小写返回,非汉字返回空串
*/
public static function firstLetter(string $char): string
{
if ($char === '') {
return '';
}
if (preg_match('/^[a-zA-Z0-9]$/', $char)) {
return strtolower($char);
}
$gbk = @iconv('UTF-8', 'GBK//IGNORE', $char);
if ($gbk === false || strlen($gbk) < 2) {
return '';
}
$code = ord($gbk[0]) * 256 + ord($gbk[1]);
foreach (self::RANGES as [$min, $max, $letter]) {
if ($code >= $min && $code <= $max) {
return $letter;
}
}
return '';
}
/**
* 整串首字母:如「淘宝网」-> "tbw",「哔哩哔哩B站」-> "blblbz"
*/
public static function initials(string $str): string
{
$out = '';
foreach (preg_split('//u', $str, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $ch) {
$out .= self::firstLetter($ch);
}
return $out;
}
/**
* 全拼(依赖 overtrue/pinyin,未安装返回空串)
*/
public static function full(string $str): string
{
if (!class_exists(\Overtrue\Pinyin\Pinyin::class)) {
return '';
}
try {
// v5 静态 API
if (method_exists(\Overtrue\Pinyin\Pinyin::class, 'sentence')) {
$r = \Overtrue\Pinyin\Pinyin::sentence($str);
$s = is_object($r) && method_exists($r, 'join') ? $r->join('') : (string)$r;
return preg_replace('/[^a-z0-9]/', '', strtolower($s)) ?? '';
}
// v4 实例 API
$py = new \Overtrue\Pinyin\Pinyin();
return preg_replace('/[^a-z0-9]/', '', strtolower(implode('', $py->convert($str)))) ?? '';
} catch (\Throwable $e) {
return '';
}
}
/**
* 生成搜索串:「首字母 全拼」(全拼可能为空),限长 250
*/
public static function keywordsOf(string $str): string
{
$initials = self::initials($str);
$full = self::full($str);
$out = trim($initials . ($full !== '' && $full !== $initials ? ' ' . $full : ''));
return mb_substr($out, 0, 250);
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"backend": [
{
"name": "haonav",
"title": "网址导航",
"icon": "fa fa-link",
"type": 1,
"sort": 50,
"status": 1,
"child": [
{ "name": "haonav/dashboard", "title": "数据概览", "icon": "fa fa-dashboard", "type": 2, "sort": 0, "route": "/haonav/backend/dashboard/index" },
{ "name": "haonav/category", "title": "分类管理", "icon": "fa fa-list", "type": 2, "sort": 1, "route": "/haonav/backend/category/index" },
{ "name": "haonav/links", "title": "链接管理", "icon": "fa fa-link", "type": 2, "sort": 2, "route": "/haonav/backend/links/index" },
{ "name": "haonav/configs", "title": "导航配置", "icon": "fa fa-cog", "type": 2, "sort": 3, "route": "/haonav/backend/configs/index" },
{ "name": "haonav/ad", "title": "广告管理", "icon": "fa fa-bullhorn", "type": 2, "sort": 4, "route": "/haonav/backend/ad/index" },
{ "name": "haonav/apply", "title": "申请审核", "icon": "fa fa-handshake-o", "type": 2, "sort": 5, "route": "/haonav/backend/apply/index" }
]
}
],
"member": [],
"frontend": []
}
+104
View File
@@ -0,0 +1,104 @@
<?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\model;
use think\facade\Cache;
use ywxapp\model\BaseModel;
/**
* 广告位模型
* 支持多广告位(首页顶部/底部、详情页内联、侧边栏),可投放图片广告或自定义代码(联盟广告)。
* 表在首次访问时由 BaseModel 自愈引擎建立,无需重跑 install.sql。
*/
class Ad extends BaseModel
{
// 广告位标识
const SLOT_HOME_TOP = 'home_top';
const SLOT_HOME_BOTTOM = 'home_bottom';
const SLOT_DETAIL_INLINE = 'detail_inline';
const SLOT_SIDEBAR = 'sidebar';
// 类型:1=图片广告 2=代码广告
const TYPE_IMAGE = 1;
const TYPE_CODE = 2;
/**
* 广告位字典(标识 => 中文名)
*/
public static function slots(): array
{
return [
self::SLOT_HOME_TOP => '首页顶部',
self::SLOT_HOME_BOTTOM => '首页底部',
self::SLOT_DETAIL_INLINE => '详情页内联',
self::SLOT_SIDEBAR => '侧边栏',
];
}
protected function getOptions(): array
{
return [
'strict' => false,
'name' => 'haonav_ads',
'autoRelation' => [],
'createTime' => 'create_at',
'updateTime' => 'update_at',
'dateFormat' => 'Y-m-d H:i:s',
];
}
/**
* 取某广告位当前生效的广告列表(状态启用 + 在生效时间范围内)
*/
public static function getBySlot(string $slot): array
{
$now = time();
return self::where('slot', $slot)
->where('status', 1)
->where(function ($q) use ($now) {
$q->whereNull('start_at')->whereOr('start_at', '<=', $now);
})
->where(function ($q) use ($now) {
$q->whereNull('end_at')->whereOr('end_at', '>=', $now);
})
->order('sort', 'desc')
->order('id', 'desc')
->select()
->toArray();
}
/**
* 所有广告位当前生效广告(按 slot 分组),带 60s 缓存,供前台统一 assign
*/
public static function allActiveBySlot(): array
{
$cacheKey = 'haonav_ads_all';
$out = Cache::get($cacheKey);
if (is_array($out)) {
return $out;
}
$out = [];
foreach (self::slots() as $k => $v) {
$out[$k] = self::getBySlot($k);
}
Cache::set($cacheKey, $out, 60);
return $out;
}
/**
* 清除广告缓存(后台增删改时调用)
*/
public static function clearCache(): void
{
Cache::delete('haonav_ads_all');
}
}
+71
View File
@@ -0,0 +1,71 @@
<?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\model;
use ywxapp\model\BaseModel;
/**
* 友链/广告 自助申请模型
* 访客在前台提交收录或广告合作申请,后台审核;通过的友链可一键写入 Links。
* 表在首次访问时由 BaseModel 自愈引擎 自愈建立,无需重跑 install.sql。
*/
class Apply extends BaseModel
{
// 申请类型
const TYPE_LINK = 1; // 友链/收录申请
const TYPE_AD = 2; // 广告合作申请
// 审核状态
const STATUS_PENDING = 0; // 待审核
const STATUS_APPROVED = 1; // 已通过
const STATUS_REJECTED = 2; // 已拒绝
/**
* 类型字典
*/
public static function types(): array
{
return [
self::TYPE_LINK => '收录/友链',
self::TYPE_AD => '广告合作',
];
}
protected function getOptions(): array
{
return [
'strict' => false,
'name' => 'haonav_apply',
'autoRelation' => [],
'createTime' => 'create_at',
'updateTime' => 'update_at',
'dateFormat' => 'Y-m-d H:i:s',
];
}
/**
* 同一 IP 限频:窗口期内最多 N 条(默认 1 小时 5 条)
*/
public static function ipOverLimit(string $ip, int $max = 5, int $window = 3600): bool
{
$count = self::where('ip', $ip)->where('create_at', '>=', time() - $window)->count();
return $count >= $max;
}
/**
* URL 去重:待审/已通过中已存在同地址申请则拒收
*/
public static function urlExists(string $url): bool
{
return self::where('url', $url)->whereIn('status', [self::STATUS_PENDING, self::STATUS_APPROVED])->count() > 0;
}
}
+68
View File
@@ -0,0 +1,68 @@
<?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\model;
use ywxapp\model\BaseModel;
class Category extends BaseModel
{
protected function getOptions(): array
{
// 所有的参数配置统一返回
return [
'strict' => false,
'name' => 'haonav_category',
// 'connection' => '',
// 'query' => [],
// 'type' => [],
// 'hidden' => [],
// 'visible' => [],
// 'append' => [],
'autoRelation' => [],
'createTime' => 'create_at',
'updateTime' => 'update_at',
'dateFormat' => 'Y-m-d H:i:s',
];
}
public function links()
{
return $this->hasMany(Links::class, 'cid', 'id');
}
/**
* 子分类(二级及更深层次)
*/
public function children()
{
return $this->hasMany(self::class, 'pid', 'id');
}
/**
* 删除前保护:存在子分类或下属链接时禁止删除,避免产生孤儿数据
* @param \think\Model $model
* @throws \think\Exception
*/
public static function onBeforeDelete($model)
{
$childCount = self::where('pid', $model->id)->count();
if ($childCount > 0) {
throw new \think\Exception('该分类下还存在 ' . $childCount . ' 个子分类,请先处理子分类');
}
$linkCount = \addon\haonav\model\Links::where('cid', $model->id)->count();
if ($linkCount > 0) {
throw new \think\Exception('该分类下还存在 ' . $linkCount . ' 个链接,请先移走或删除这些链接');
}
}
}
+126
View File
@@ -0,0 +1,126 @@
<?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\model;
use ywxapp\model\BaseModel;
class Configure extends BaseModel
{
protected function getOptions(): array
{
// 所有的参数配置统一返回
return [
'strict' => false,
'name' => 'haonav_config',
'autoRelation' => [],
'createTime' => 'create_at',
'updateTime' => 'update_at',
'dateFormat' => 'Y-m-d H:i:s',
];
}
/**
* 读取单个配置值
* @param string $name
* @param mixed $default
* @return mixed
*/
public static function getVal(string $name, $default = '')
{
static $cache = [];
if (array_key_exists($name, $cache)) {
return $cache[$name];
}
$val = self::where('name', $name)->value('value');
$val = $val === null ? $default : $val;
$cache[$name] = $val;
return $val;
}
/**
* 读取全部配置为 name => value 数组
* @return array
*/
public static function getAll(): array
{
return self::column('value', 'name');
}
/**
* 老库结构自愈:早期 install.sql config id PRIMARY KEY/AUTO_INCREMENT
* create() 不带 id 会报 1364 Field 'id' doesn't have a default value,此处运行时修复。
*/
protected static function ensurePk(): void
{
static $done = false;
if ($done) {
return;
}
self::ensureAutoIncrementPk('wxapp_haonav_config');
$done = true;
}
/**
* 返利配置自愈:老库安装时 install.sql 尚未含这 3 行,访问配置页时补种,
* 保证后台「返利」分组始终可配置(INSERT 幂等,按 name 判重)。
*/
public static function ensureRebateConfig(): void
{
self::ensurePk();
$defaults = [
'enable_rebate' => ['返利', '开启返利PID', '开启后,命中适用域名的链接点击将自动拼接返利PID', 'radio', '0', '1,0'],
'rebate_param' => ['返利', '返利参数名', '拼接在网址后的参数名,默认 pid', 'string', 'pid', ''],
'rebate_domains' => ['返利', '返利适用域名', '仅这些域名(含子域名)会拼接返利PID,逗号或换行分隔,如 taobao.com,jd.com,pinduoduo.com', 'textarea', 'taobao.com,jd.com,pinduoduo.com', ''],
];
foreach ($defaults as $name => $cfg) {
if (self::where('name', $name)->count() === 0) {
self::create([
'name' => $name,
'group' => $cfg[0],
'title' => $cfg[1],
'tip' => $cfg[2],
'type' => $cfg[3],
'value' => $cfg[4],
'content' => $cfg[5],
]);
}
}
}
/**
* 死链自动下线配置自愈:老库安装时 install.sql 尚未含这 3 行,访问配置页时补种,
* 保证后台「导航配置」的 task 分组始终可配置(INSERT 幂等,按 name 判重)。
*/
public static function ensureDeadlinkConfig(): void
{
self::ensurePk();
$defaults = [
'deadlink_auto' => ['task', '死链自动下线', '连续检测失败达阈值后,自动将链接置为禁用(下线)', 'radio', '0', '1,0'],
'deadlink_threshold' => ['task', '下线阈值(连续失败次数)', '达到该连续失败次数才下线,避免单次网络抖动误杀(建议2)', 'number', '2', ''],
'deadlink_recover' => ['task', '死链恢复自动上线', '链接重新可达时自动恢复为启用(仅对检测器自动下线的链接生效)', 'radio', '0', '1,0'],
];
foreach ($defaults as $name => $cfg) {
if (self::where('name', $name)->count() === 0) {
self::create([
'name' => $name,
'group' => $cfg[0],
'title' => $cfg[1],
'tip' => $cfg[2],
'type' => $cfg[3],
'value' => $cfg[4],
'content' => $cfg[5],
]);
}
}
}
}
+33
View File
@@ -0,0 +1,33 @@
<?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\model;
use ywxapp\model\BaseModel;
/**
* 用户云端收藏
* @mixin \think\Model
*/
class Favorite extends BaseModel
{
protected function getOptions(): array
{
return [
'strict' => false,
'name' => 'haonav_favorites',
'autoRelation' => [],
'createTime' => 'create_at',
'updateTime' => false,
'dateFormat' => 'Y-m-d H:i:s',
];
}
}
+70
View File
@@ -0,0 +1,70 @@
<?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\model;
use ywxapp\model\BaseModel;
/**
* 用户收藏夹分享配置(一个用户一条)
*
* 用户可把「我的导航」生成一个只读分享链接,他人凭 token 访问 shared 页浏览,
* enabled=1 才对外可见;token 可重置(旧链接立即失效)。
*
* @mixin \think\Model
*/
class FavoriteShare extends BaseModel
{
protected function getOptions(): array
{
return [
'strict' => false,
'name' => 'haonav_favorite_shares',
'autoRelation' => [],
'createTime' => 'create_at',
'updateTime' => 'update_at',
'dateFormat' => 'Y-m-d H:i:s',
];
}
/**
* 生成一个随机分享令牌(16位十六进制)
*/
public static function genToken(): string
{
try {
return bin2hex(random_bytes(8));
} catch (\Throwable $e) {
return substr(md5(uniqid((string)mt_rand(), true)), 0, 16);
}
}
/**
* 取当前用户的分享配置,无则创建一条(默认关闭、已生成 token
*/
public static function forUser(int $uid): FavoriteShare
{
$row = self::where('user_id', $uid)->find();
if (!$row) {
$row = new self();
$row->user_id = $uid;
$row->token = self::genToken();
$row->title = '我的导航收藏夹';
$row->enabled = 0;
$row->views = 0;
$row->save();
} elseif (empty($row->token)) {
$row->token = self::genToken();
$row->save();
}
return $row;
}
}
+449
View File
@@ -0,0 +1,449 @@
<?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\model;
use ywxapp\model\BaseModel;
use addon\haonav\model\Configure as ConfigureModel;
use addon\haonav\library\Pinyin;
class Links extends BaseModel
{
/**
* 序列化时附加的虚拟字段(列表/详情输出分类名等)。
* 注意:getCateAttr 是虚拟字段获取器,ThinkPHP 默认 toArray 只输出真实表字段,
* 必须在此声明 append 才会进入 JSON(控制器里用 withAttr('cate') 无效,会被链式查询丢弃)。
* @var array
*/
protected $append = ['cate', 'outbound_url'];
protected function getOptions(): array
{
return [
'strict' => false,
'name' => 'haonav_links',
'autoRelation' => [],
'createTime' => 'create_at',
'updateTime' => 'update_at',
'dateFormat' => 'Y-m-d H:i:s',
];
}
/**
* 展示用图标:优先 favicon,其次 icon,最后默认图
*/
public function getShowIconAttr($value, $data)
{
$custom = !empty($data['favicon']) ? $data['favicon'] : (!empty($data['icon']) ? $data['icon'] : '');
// 自定义图标(本地上传或非 icon.horse 外链)直接返回,保留原图;
// 自动生成的 icon.horse 地址改为走本地代理(带缓存与失败降级),提升可靠性与速度。
if ($custom && stripos($custom, 'icon.horse') === false) {
return $custom;
}
$url = $data['url'] ?? '';
if ($url) {
return '/haonav/favicon.html?url=' . urlencode($url);
}
return '/static/haonav/img/default.png';
}
/**
* 根据 URL 自动生成 icon.horse favicon 地址
*/
public static function faviconOf(string $url): string
{
$host = parse_url($url, PHP_URL_HOST);
return $host ? 'https://icon.horse/icon/' . $host : '';
}
/**
* 返利外链:enable_rebate 开启、本链接 pid 非空、且域名命中 rebate_domains 时,
* 自动拼接返利参数(如 ?pid=xxx)。用于详情页「立即跳转」与倒计时自动跳转,让导航站赚返利。
*/
public function getOutboundUrlAttr($value, $data)
{
return self::buildOutboundUrl((string)($data['url'] ?? ''), (string)($data['pid'] ?? ''));
}
/**
* 根据原始 URL + 返利 PID 生成返利外链(纯函数,便于测试与复用)
*/
public static function buildOutboundUrl(string $url, string $pid): string
{
if ($url === '' || $pid === '') {
return $url;
}
if ((int)ConfigureModel::getVal('enable_rebate', '0') !== 1) {
return $url;
}
$domains = self::rebateDomains();
if (empty($domains)) {
return $url;
}
$host = parse_url($url, PHP_URL_HOST);
if (!is_string($host) || $host === '') {
return $url;
}
$host = strtolower($host);
$matched = false;
foreach ($domains as $d) {
if ($host === $d || substr($host, -strlen($d) - 1) === '.' . $d) {
$matched = true;
break;
}
}
if (!$matched) {
return $url;
}
$param = (string)ConfigureModel::getVal('rebate_param', 'pid');
if ($param === '') {
$param = 'pid';
}
$sep = strpos($url, '?') !== false ? '&' : '?';
return $url . $sep . urlencode($param) . '=' . urlencode($pid);
}
/**
* 解析返利适用域名后缀(配置为逗号/空格/换行分隔;支持子域名)
*/
protected static function rebateDomains(): array
{
$raw = (string)ConfigureModel::getVal('rebate_domains', '');
if ($raw === '') {
return [];
}
$list = preg_split('/[\s,;]+/', $raw, -1, PREG_SPLIT_NO_EMPTY);
return array_values(array_unique(array_map('strtolower', $list)));
}
/**
* 插入前:自动补全 favicon / icon
*/
public static function onBeforeInsert($data)
{
if (empty($data->url)) {
return true;
}
$favicon = self::faviconOf($data->url);
if ($favicon && empty($data->favicon)) {
$data->favicon = $favicon;
}
if (empty($data->icon) && !empty($data->favicon)) {
$data->icon = $data->favicon;
}
if (!empty($data->title)) {
$data->pinyin = Pinyin::keywordsOf((string)$data->title);
}
return true;
}
/**
* 更新前:仅当网址变更时才抓取远端元信息,且失败不影响保存;并同步刷新 favicon
*/
public static function onBeforeUpdate($data)
{
if (empty($data->url)) {
return true;
}
$changed = $data->getChangedData();
if (array_key_exists('url', $changed)) {
try {
$meta = self::fetchMeta($data->url);
} catch (\Throwable $e) {
$meta = false;
}
if (!empty($meta)) {
$data->keywords = $meta['keywords'] ?: $data->keywords;
$string = $meta['description'] ?: $data->description;
$position = strpos($string, '。');
$data->description = $position !== false ? substr($string, 0, $position) : $string;
}
}
// 始终保证 favicon/icon 存在
if (empty($data->favicon)) {
$favicon = self::faviconOf($data->url);
if ($favicon) {
$data->favicon = $favicon;
}
}
if (empty($data->icon) && !empty($data->favicon)) {
$data->icon = $data->favicon;
}
if (!empty($data->title) && (array_key_exists('title', $changed) || empty($data->pinyin))) {
$data->pinyin = Pinyin::keywordsOf((string)$data->title);
}
return true;
}
/**
* 拼音回填:为存量数据补 pinyin(每次最多 $limit 条,幂等)
* @return int 本次回填条数
*/
public static function backfillPinyin(int $limit = 300): int
{
try {
$rows = self::where(function ($q) {
$q->whereNull('pinyin')->whereOr('pinyin', '');
})->limit($limit)->select();
$n = 0;
foreach ($rows as $row) {
$row->pinyin = Pinyin::keywordsOf((string)$row->title);
$row->save();
$n++;
}
return $n;
} catch (\Throwable $e) {
return 0;
}
}
/**
* 关联分类
*/
public function category()
{
return $this->belongsTo(Category::class, 'cid', 'id');
}
/**
* 获取分类名称
*/
public function getCateAttr($value, $data)
{
static $cache = [];
$cid = $data['cid'] ?? 0;
if (! array_key_exists($cid, $cache)) {
$cache[$cid] = Category::where('id', $cid)->value('title');
}
return $cache[$cid];
}
/**
* 站内搜索(兼容 LIKE,避免 FULLTEXT 分词配置差异)
*/
public static function search(string $keyword, int $page = 1, int $limit = 20)
{
$kw = $keyword;
return self::where('status', 1)
->where(function ($q) use ($kw) {
$q->where('title', 'like', '%' . $kw . '%')
->whereOr('description', 'like', '%' . $kw . '%')
->whereOr('keywords', 'like', '%' . $kw . '%');
// 纯字母关键字同时匹配拼音(首字母+全拼),实现拼音搜索
if (preg_match('/^[a-zA-Z]+$/', $kw)) {
$q->whereOr('pinyin', 'like', '%' . strtolower($kw) . '%');
}
})
->order('click_count', 'desc')
->paginate(['page' => $page, 'list_rows' => $limit]);
}
/**
* 检测单个链接可达性,返回 HTTP 状态码;不可达返回 false
* 先用 HEAD,若返回 >=400(很多站点不支持 HEAD,会误判 403/405)再用 GET 复核一次,降低误杀。
*/
public static function checkLink(string $url, int $timeout = 8)
{
if (!class_exists(\GuzzleHttp\Client::class)) {
return false;
}
$opts = [
'headers' => ['Member-Agent' => 'Mozilla/5.0 (compatible; HaonavBot/1.0)'],
'allow_redirects' => ['max' => 5],
'stream' => true, // 只取状态码,不下载响应体
];
// Client 构造不做网络请求,提前实例化以保证下方 catch 中 $client 已定义
$client = new \GuzzleHttp\Client(['timeout' => $timeout, 'verify' => false]);
try {
$code = $client->request('HEAD', $url, $opts)->getStatusCode();
if ($code >= 400) {
// HEAD 被拒,改用 GET 复核(部分站点禁用 HEAD 返回 403/405/501
$code = $client->request('GET', $url, $opts)->getStatusCode();
}
return $code;
} catch (\GuzzleHttp\Exception\RequestException $e) {
if ($e->hasResponse()) {
$code = $e->getResponse()->getStatusCode();
if ($code >= 400) {
try {
return $client->request('GET', $url, $opts)->getStatusCode();
} catch (\Throwable $e2) {
return false;
}
}
return $code;
}
return false;
} catch (\Throwable $e) {
return false;
}
}
/**
* 批量检测全部链接,更新 last_check_at / status_code
* @return array [total, ok, dead]
*/
public static function checkAllLinks(int $timeout = 8): array
{
return self::checkList(self::select(), $timeout);
}
/**
* 小批量巡检:优先检测「最久未检测」的链接(NULL 最先)
* @return array [total, ok, dead]
*/
public static function checkBatch(int $limit = 20, int $timeout = 5): array
{
$links = self::order('last_check_at', 'asc')->limit(max(1, $limit))->select();
return self::checkList($links, $timeout);
}
/**
* 对给定链接集合执行检测并落库(含死链自动下线/恢复)
* @return array [total, ok, dead, offlined, recovered]
*/
protected static function checkList($links, int $timeout): array
{
// 死链自动下线配置(一次检测只读一次),与「导航配置」中 task 分组保持一致
$auto = (int)ConfigureModel::getVal('deadlink_auto', '0') === 1;
$threshold = max(1, (int)ConfigureModel::getVal('deadlink_threshold', '2'));
$recover = (int)ConfigureModel::getVal('deadlink_recover', '0') === 1;
$total = 0;
$ok = 0;
$dead = 0;
$offlined = 0;
$recovered = 0;
foreach ($links as $link) {
$total++;
$code = self::checkLink($link->url, $timeout);
$link->last_check_at = time();
$isDead = ($code === false) || ($code >= 400);
if ($isDead) {
$link->status_code = ($code === false) ? 0 : $code;
$dead++;
// 仅对启用中的链接累计失败并标记死链,避免误动管理员手动禁用/待审核项
if ((int)$link->status === 1) {
$link->fail_count = (int)($link->fail_count ?? 0) + 1;
$link->dead_at = time();
if ($auto && (int)$link->fail_count >= $threshold) {
$link->status = 0;
$offlined++;
}
}
} else {
$link->status_code = $code;
// dead_at 仅检测器下线的链接会带时间戳,手动禁用的为 0
$wasDead = (int)($link->dead_at ?? 0) > 0;
$link->fail_count = 0;
$link->dead_at = 0;
$ok++;
// 死链恢复:曾被检测器下线且开启自动恢复 -> 重新启用(手动禁用项 dead_at=0,不会误恢复)
if ($recover && $wasDead && (int)$link->status === 0) {
$link->status = 1;
$recovered++;
}
}
$link->save();
}
return ['total' => $total, 'ok' => $ok, 'dead' => $dead, 'offlined' => $offlined, 'recovered' => $recovered];
}
/**
* 获取网页元信息
*/
public static function fetchMeta($url)
{
libxml_use_internal_errors(true);
if (!class_exists(\GuzzleHttp\Client::class)) {
throw new \Exception('请先安装 Guzzle: composer require guzzlehttp/guzzle');
}
try {
$client = new \GuzzleHttp\Client(['timeout' => 10, 'verify' => false]);
$response = $client->get($url, [
'headers' => [
'Member-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
]
]);
$html = (string)$response->getBody();
$headers = $response->getHeader('Content-Type');
} catch (\Exception $e) {
return false;
}
$html = self::toUtf8($html, $headers);
$html = preg_replace('/<meta[^>]*charset=[^>]*>/is', '', $html);
$html = preg_replace('/<head>/i', '<head><meta charset="UTF-8">', $html, 1);
$dom = new \DOMDocument();
$dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
$xpath = new \DOMXPath($dom);
$titleNode = $xpath->query('//title');
$keywordsNode = $xpath->query('//meta[@name="keywords"]');
$descNode = $xpath->query('//meta[@name="description"]');
return [
'title' => $titleNode->length ? trim($titleNode->item(0)->nodeValue) : '',
'keywords' => $keywordsNode->length ? trim($keywordsNode->item(0)->getAttribute('content')) : '',
'description' => $descNode->length ? trim($descNode->item(0)->getAttribute('content')) : '',
];
}
public static function toUtf8($html, array $headers = [])
{
$charset = null;
if (!empty($headers)) {
foreach ($headers as $header) {
if (preg_match('/charset=([^\s;]+)/i', $header, $match)) {
$charset = strtoupper(trim($match[1]));
break;
}
}
}
if (!$charset) {
preg_match('/<meta[^>]*charset=["\']?\s*(gbk|gb2312|big5|iso-8859-1|utf-8)/i', $html, $match);
$charset = $match[1] ?? null;
}
if (!$charset) {
$charset = mb_detect_encoding($html, ['UTF-8', 'GBK', 'GB2312', 'BIG5', 'ISO-8859-1']);
}
if ($charset && strtoupper($charset) !== 'UTF-8') {
if (strtoupper($charset) === 'ISO-8859-1') {
$html = mb_convert_encoding($html, 'UTF-8', 'GBK');
} else {
$html = mb_convert_encoding($html, 'UTF-8', $charset);
}
}
return $html;
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
/*
* @Author: YwxApp <ywx@ywxapp.cn>
* @Date: 2026-07-31 13:28:15
* @LastEditors: YwxApp <ywx@ywxapp.cn>
* @LastEditTime: 2026-08-16 15:16:48
* @Description:
* @FilePath: \ywxapp_dev\addon\haonav\route\app.php
* @CustomString: Copyright (c) 2026 YwxApp
*/
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
use think\facade\Route;
// 说明:本文件由 ywxapp/service/AppService::loadAddonRoutes() 在 boot 阶段
// include,并统一被外层 Route::group('haonav', ...) 包住,因此下面写的都是
// 【相对规则】,最终自动加 /haonav 前缀:
// 顶层规则 -> /haonav/*
// backend 组 -> /haonav/*
// 对外 REST API 才需要带前导 / 写绝对规则(本项目 haonav 暂无对外 API)。
// 前台页面 / 接口(最终地址 /haonav/index/:id 等)
Route::rule('index/:id', 'Index/index');
Route::rule('index/test', 'Index/test');
Route::rule('category/:id', 'Index/category');
Route::rule('site/:id', 'Index/site');
Route::rule('search', 'Index/search');
Route::rule('suggest', 'Index/suggest');
Route::rule('rate', 'Index/rate');
Route::rule('rank', 'Index/rank');
Route::rule('submit', 'Index/submit');
Route::rule('apply', 'Index/apply');
Route::rule('favicon', 'Index/favicon');
Route::rule('snapshot', 'Index/snapshot');
Route::rule('sw', 'Index/sw');
Route::rule('ad/click', 'Index/adClick');
// 前台会员:登录 / 注册 / 退出 / 资料(复用框架前台 Authtoken 经 Cookie 落地)
Route::rule('account/login', 'Account/login');
Route::rule('account/register', 'Account/register');
Route::rule('account/logout', 'Account/logout');
Route::rule('account/profile', 'Account/profile');
// 首页小组件:实时热搜 / 天气(服务端代理 + 缓存)
Route::rule('widget/hot', 'Widget/hot');
Route::rule('widget/weather', 'Widget/weather');
// 用户云端收藏(登录后「我的导航」跨端同步;未登录返回 401,前端回退本地)
Route::rule('favorite/list', 'Favorite/list');
Route::rule('favorite/add', 'Favorite/add');
Route::rule('favorite/remove', 'Favorite/remove');
Route::rule('favorite/sort', 'Favorite/sort');
Route::rule('favorite/merge', 'Favorite/merge');
// 收藏夹分享:配置接口(登录) + 公开分享页(凭 token 只读浏览)
Route::rule('favorite/share', 'Favorite/share');
Route::rule('favorite/sharesave', 'Favorite/shareSave');
Route::rule('favorite/shared/:token', 'Favorite/shared');
// SEO:站点地图(/haonav/sitemap.xml 与 /haonav/sitemap.html 均可访问)
Route::rule('sitemap', 'Index/sitemap');
Route::rule('sitemap.xml', 'Index/sitemap');
// 计划任务入口(cron 调用,需携带 token;最终地址 /haonav/task/checklinks
Route::rule('task/checklinks', 'Task/checkLinks');
// 惰性自动巡检(前台页面异步 ping,缓存锁限频,无 cron 环境兜底)
Route::rule('task/autocheck', 'Task/autoCheck');
// 后台管理路由(对应 controller/ 下的控制器;最终地址 /haonav/*)
// 注意:全局 route_complete_match=false 时,裸规则(如 'links')会前缀匹配
// 'links/create' 等子路径并把多余段当参数吞掉(先注册先匹配),导致
// /links/create 被派发到 Links::index 而非 create。
// 因此裸规则必须 强制完整匹配。
Route::group('backend', function () {
Route::rule('index', 'Dashboard/index');
Route::rule('dashboard', 'Dashboard/index');
Route::rule('dashboard/:action', 'Dashboard/:action');
Route::rule('links', 'Links/index');
Route::rule('links/:action', 'Links/:action');
Route::rule('category', 'Category/index') ;
Route::rule('category/index', 'Category/index') ;
Route::rule('configs', 'Configs/index');
Route::rule('configs/:action', 'Configs/:action');
Route::rule('ad', 'Ad/index');
Route::rule('ad/:action', 'Ad/:action');
Route::rule('apply', 'Apply/index');
Route::rule('apply/:action', 'Apply/:action');
})->layer('backend');;
+261
View File
@@ -0,0 +1,261 @@
-- ============================================================
-- addon/haonav/upgrade.sql —— 分类体系重构迁移(一级 + 二级 + 常用网址)
-- 适用场景:已安装 haonav 插件的站点,数据库里还是旧的扁平分类(id 1~12)。
-- 作用:清空旧的 haonav_category / haonav_links 种子数据,插入新的
-- 14 个一级 + 30 个二级分类 + 155 条常用网址(国内外混合)。
-- ⚠️ 注意:本脚本会 DELETE 掉 haonav_category 和 haonav_links 的全部数据
-- (含你此前手动添加的分类/网址)。执行前请先备份,或确认可接受重置种子。
-- haonav_config / haonav_ads / haonav_apply 等表不受影响。
-- 执行方式:在 MySQL 客户端用你的库前缀替换 __PREFIX__ 后执行,例如
-- sed 's/__PREFIX__/ywx_/' upgrade.sql | mysql -u用户 -p 数据库
-- 或通过后台「数据库」工具导入(注意前缀替换)。
-- ============================================================
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- 1) 清空旧种子(按外键先清子表再清父表)
DELETE FROM `__PREFIX__haonav_links`;
DELETE FROM `__PREFIX__haonav_category`;
-- 2) 插入新分类体系(一级 + 二级)
INSERT INTO `__PREFIX__haonav_category` (`id`, `pid`, `title`, `icon`, `summary`, `keywords`, `description`, `sort`, `status`, `website_count`, `click_count`, `create_at`, `update_at`) VALUES
-- 一级分类
(1, 0, '常用搜索', '🔍', '', '', '搜索引擎与门户入口', 140, 1, 0, 0, NULL, NULL),
(2, 0, '视频娱乐', '📺', '', '', '影视、短视频与音乐平台', 130, 1, 0, 0, NULL, NULL),
(3, 0, '社交沟通', '👥', '', '', '综合与海外社交平台', 120, 1, 0, 0, NULL, NULL),
(4, 0, '新闻资讯', '📰', '', '', '门户新闻与科技资讯', 110, 1, 0, 0, NULL, NULL),
(5, 0, '开发编程', '💻', '', '', '代码托管、技术社区与云服务', 100, 1, 0, 0, NULL, NULL),
(6, 0, '学习教育', '🎓', '', '', '在线课程与资料文库', 90, 1, 0, 0, NULL, NULL),
(7, 0, '购物电商', '🛒', '', '', '综合电商与海淘二手', 80, 1, 0, 0, NULL, NULL),
(8, 0, '办公效率', '📁', '', '', '文档协作、邮箱、云盘与会议', 70, 1, 0, 0, NULL, NULL),
(9, 0, '设计创意', '🎨', '', '', '设计素材与在线设计工具', 60, 1, 0, 0, NULL, NULL),
(10, 0, '实用工具', '🧰', '', '', '生活服务与在线小工具', 50, 1, 0, 0, NULL, NULL),
(11, 0, '财经金融', '💰', '', '', '行情理财与支付银行', 40, 1, 0, 0, NULL, NULL),
(12, 0, '旅游出行', '✈️', '', '', '交通出行与住宿预订', 30, 1, 0, 0, NULL, NULL),
(13, 0, '医疗健康', '🩺', '', '', '在线问诊与健康科普', 20, 1, 0, 0, NULL, NULL),
(14, 0, '政务民生', '🏛️', '', '', '政务服务与生活缴费', 10, 1, 0, 0, NULL, NULL),
-- 二级分类(pid 指向一级)
(15, 1, '综合搜索', '🌐', '', '', '百度、Google 等综合搜索引擎', 140, 1, 0, 0, NULL, NULL),
(16, 1, '学术资源', '📖', '', '', '学术、图书与知识检索', 139, 1, 0, 0, NULL, NULL),
(21, 2, '长视频', '🎬', '', '', '爱奇艺、腾讯视频等长视频', 130, 1, 0, 0, NULL, NULL),
(22, 2, '短视频直播', '📱', '', '', '抖音、快手等短视频平台', 129, 1, 0, 0, NULL, NULL),
(23, 2, '音乐', '🎵', '', '', '在线音乐播放平台', 128, 1, 0, 0, NULL, NULL),
(31, 3, '综合社交', '💬', '', '', '微信、微博等国内社交', 120, 1, 0, 0, NULL, NULL),
(32, 3, '国际社交', '🌍', '', '', 'X、Telegram 等海外社交', 119, 1, 0, 0, NULL, NULL),
(41, 4, '综合门户', '🗞️', '', '', '新浪、网易等综合门户', 110, 1, 0, 0, NULL, NULL),
(42, 4, '科技资讯', '', '', '', '36氪、少数派等科技媒体', 109, 1, 0, 0, NULL, NULL),
(51, 5, '代码托管', '🔧', '', '', 'GitHub、Gitee 等代码平台', 100, 1, 0, 0, NULL, NULL),
(52, 5, '技术社区', '📝', '', '', 'CSDN、掘金等技术社区', 99, 1, 0, 0, NULL, NULL),
(53, 5, '云服务', '☁️', '', '', '阿里云、腾讯云等云服务', 98, 1, 0, 0, NULL, NULL),
(61, 6, '慕课学习', '📚', '', '', '慕课网、Coursera 等课程', 90, 1, 0, 0, NULL, NULL),
(62, 6, '资料文库', '📄', '', '', '百度文库、道客巴巴等', 89, 1, 0, 0, NULL, NULL),
(71, 7, '综合电商', '🛍️', '', '', '淘宝、京东等综合电商', 80, 1, 0, 0, NULL, NULL),
(72, 7, '海淘二手', '♻️', '', '', '亚马逊、闲鱼等海淘二手', 79, 1, 0, 0, NULL, NULL),
(81, 8, '文档协作', '📝', '', '', '腾讯文档、Notion 等协作', 70, 1, 0, 0, NULL, NULL),
(82, 8, '邮箱', '📧', '', '', 'QQ邮箱、Gmail 等邮箱', 69, 1, 0, 0, NULL, NULL),
(83, 8, '云盘', '💾', '', '', '百度网盘、OneDrive 等', 68, 1, 0, 0, NULL, NULL),
(84, 8, '会议办公', '🎥', '', '', '腾讯会议、钉钉等', 67, 1, 0, 0, NULL, NULL),
(91, 9, '设计素材', '🖼️', '', '', '千图网、Unsplash 等素材', 60, 1, 0, 0, NULL, NULL),
(92, 9, '设计工具', '🛠️', '', '', 'Figma、Canva 等设计工具', 59, 1, 0, 0, NULL, NULL),
(101, 10, '生活服务', '🚌', '', '', '快递、地图、出行等', 50, 1, 0, 0, NULL, NULL),
(102, 10, '在线工具', '⚙️', '', '', 'JSON、二维码等在线工具', 49, 1, 0, 0, NULL, NULL),
(111, 11, '行情理财', '📈', '', '', '东方财富、雪球等', 40, 1, 0, 0, NULL, NULL),
(112, 11, '支付银行', '🏦', '', '', '支付宝、网银等', 39, 1, 0, 0, NULL, NULL),
(121, 12, '交通出行', '🚄', '', '', '携程、12306 等出行', 30, 1, 0, 0, NULL, NULL),
(122, 12, '住宿预订', '🏨', '', '', '美团、Booking 等住宿', 29, 1, 0, 0, NULL, NULL),
(131, 13, '在线问诊', '💊', '', '', '丁香医生、好大夫等', 20, 1, 0, 0, NULL, NULL),
(132, 13, '健康科普', '🌿', '', '', '丁香园等健康科普', 19, 1, 0, 0, NULL, NULL),
(141, 14, '政务服务', '📋', '', '', '国家政务平台、粤省事等', 10, 1, 0, 0, NULL, NULL),
(142, 14, '生活缴费', '💡', '', '', '社保、水电网查询', 9, 1, 0, 0, NULL, NULL);
-- 3) 插入常用网址(国内外混合,cid 指向二级分类 id)
INSERT INTO `__PREFIX__haonav_links` (`id`, `cid`, `title`, `url`, `icon`, `description`, `keywords`, `click_count`, `sort`, `is_hot`, `is_recommend`, `status`, `screenshot`, `favicon`, `create_at`, `update_at`, `last_click_at`) VALUES
-- 综合搜索
(1, 11, '百度', 'https://www.baidu.com', '🔍', '全球最大的中文搜索引擎', '百度,搜索,引擎', 45000, 100, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(2, 11, 'Google', 'https://www.google.com', '🌐', '全球搜索引擎巨头', 'Google,搜索,英文', 30000, 95, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(3, 11, '必应', 'https://www.bing.com', '🔎', '微软旗下搜索引擎', '必应,bing,搜索', 18000, 90, 0, 1, 1, NULL, NULL, NULL, NULL, NULL),
(4, 11, '搜狗', 'https://www.sogou.com', '🐶', '腾讯旗下搜索引擎', '搜狗,搜索,输入法', 12000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(5, 11, '360搜索', 'https://www.so.com', '🛡️', '360安全搜索引擎', '360,搜索,安全', 9000, 80, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(6, 11, 'Yahoo', 'https://www.yahoo.com', '🟣', '老牌门户与搜索引擎', '雅虎,yahoo,门户', 7000, 75, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 学术资源
(7, 12, '中国知网', 'https://www.cnki.net', '📚', '中文学术文献数据库', '知网,论文,学术', 8000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(8, 12, '豆瓣', 'https://www.douban.com', '🎞️', '书影音与知识社区', '豆瓣,书影音,社区', 16000, 95, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(9, 12, '维基百科', 'https://www.wikipedia.org', '📖', '自由的百科全书', '维基,百科,知识', 14000, 90, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(10, 12, '微信读书', 'https://weread.qq.com', '📕', '腾讯在线阅读平台', '微信读书,电子书,阅读', 9000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(11, 12, '鸠摩搜书', 'https://www.jiumodiary.com', '🔏', '电子书聚合搜索', '鸠摩,电子书,搜书', 5000, 80, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 长视频
(12, 21, '爱奇艺', 'https://www.iqiyi.com', '🔴', '悦享品质视频平台', '爱奇艺,视频,会员', 26000, 100, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(13, 21, '腾讯视频', 'https://v.qq.com', '🟢', '海量正版高清视频', '腾讯视频,视频,电视剧', 24000, 95, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(14, 21, '优酷', 'https://www.youku.com', '🟠', '阿里巴巴视频平台', '优酷,视频,土豆', 20000, 90, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(15, 21, '芒果TV', 'https://www.mgtv.com', '🟡', '湖南卫视官方视频', '芒果TV,视频,综艺', 18000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(16, 21, '哔哩哔哩', 'https://www.bilibili.com', '🔵', '国内知名弹幕视频网', 'B站,视频,弹幕', 32000, 88, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(17, 21, '搜狐视频', 'https://tv.sohu.com', '', '搜狐高清视频平台', '搜狐视频,视频,美剧', 9000, 80, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 短视频直播
(18, 22, '抖音', 'https://www.douyin.com', '🎵', '记录美好生活的短视频', '抖音,短视频,直播', 35000, 100, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(19, 22, '快手', 'https://www.kuaishou.com', '🎬', '普惠的短视频社区', '快手,短视频,直播', 22000, 95, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(20, 22, '微信视频号', 'https://channels.weixin.qq.com', '💬', '微信生态短视频', '视频号,微信,短视频', 14000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 音乐
(21, 23, '网易云音乐', 'https://music.163.com', '🔴', '有态度的音乐平台', '网易云,音乐,歌单', 28000, 100, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(22, 23, 'QQ音乐', 'https://y.qq.com', '🟢', '腾讯在线音乐平台', 'QQ音乐,音乐,听歌', 26000, 95, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(23, 23, '酷狗音乐', 'https://www.kugou.com', '🟣', '庞大曲库音乐平台', '酷狗,音乐,听歌', 15000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(24, 23, '酷我音乐', 'https://www.kuwo.cn', '🔵', '无损音乐正版试听', '酷我,音乐,无损', 9000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(25, 23, '千千音乐', 'https://music.taihe.com', '🟡', '太合音乐旗下平台', '千千音乐,音乐,在线听', 5000, 80, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(26, 23, 'Spotify', 'https://www.spotify.com', '🟢', '全球流媒体音乐服务', 'Spotify,音乐,海外', 8000, 78, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 综合社交
(27, 31, '微信', 'https://weixin.qq.com', '💬', '腾讯即时通讯与社交', '微信,社交,聊天', 40000, 100, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(28, 31, 'QQ', 'https://im.qq.com', '🐧', '腾讯即时通讯工具', 'QQ,社交,聊天', 30000, 95, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(29, 31, '微博', 'https://www.weibo.com', '🔶', '随时随地发现新鲜事', '微博,社交,微博客', 28000, 90, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(30, 31, '小红书', 'https://www.xiaohongshu.com', '🔴', '标记生活的种草社区', '小红书,社交,种草', 24000, 88, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(31, 31, '知乎', 'https://www.zhihu.com', '🔵', '有问题就会有答案', '知乎,问答,知识', 20000, 85, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
-- 国际社交
(32, 32, 'X (Twitter)', 'https://x.com', '', '马斯卡旗下的社交平台', 'X,Twitter,海外社交', 16000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(33, 32, 'Telegram', 'https://telegram.org', '🔵', '加密即时通讯与频道', 'Telegram,电报,聊天', 12000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(34, 32, 'Facebook', 'https://www.facebook.com', '🔵', '全球最大社交网络', 'Facebook,脸书,海外社交', 14000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(35, 32, 'Instagram', 'https://www.instagram.com', '🔴', '图片与短视频社交', 'Instagram,ins,海外社交', 13000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(36, 32, 'Reddit', 'https://www.reddit.com', '🟠', '全球兴趣社区论坛', 'Reddit,论坛,海外', 9000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(37, 32, 'YouTube', 'https://www.youtube.com', '🔴', '全球最大视频平台', 'YouTube,视频,海外', 30000, 80, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
-- 综合门户
(38, 41, '新浪', 'https://www.sina.com.cn', '🔴', '综合门户与新闻', '新浪,门户,新闻', 22000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(39, 41, '搜狐', 'https://www.sohu.com', '🔵', '综合门户新闻平台', '搜狐,门户,新闻', 16000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(40, 41, '网易', 'https://www.163.com', '🔴', '综合门户与邮箱', '网易,门户,新闻', 18000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(41, 41, '腾讯新闻', 'https://news.qq.com', '🟢', '腾讯新闻资讯平台', '腾讯新闻,新闻,资讯', 14000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(42, 41, '今日头条', 'https://www.toutiao.com', '🔴', '个性化推荐资讯', '今日头条,新闻,资讯', 20000, 85, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(43, 41, '凤凰网', 'https://www.ifeng.com', '🔶', '全球华人资讯门户', '凤凰网,门户,新闻', 9000, 80, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 科技资讯
(44, 42, '36氪', 'https://36kr.com', '🔵', '新经济商业媒体', '36氪,科技,创业', 8000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(45, 42, '虎嗅', 'https://www.huxiu.com', '🟠', '有洞察的商业科技媒体', '虎嗅,科技,商业', 7000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(46, 42, '少数派', 'https://sspai.com', '🔵', '效率工具与数字生活', '少数派,科技,效率', 6000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(47, 42, '钛媒体', 'https://www.tmtpost.com', '🔴', '财经科技资讯平台', '钛媒体,科技,财经', 4000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 代码托管
(48, 51, 'GitHub', 'https://github.com', '🐙', '全球最大代码托管平台', 'GitHub,代码,开源', 35000, 100, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(49, 51, 'GitLab', 'https://gitlab.com', '🟠', 'DevOps 代码协作平台', 'GitLab,代码,协作', 9000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(50, 51, 'Gitee', 'https://gitee.com', '🔴', '国内代码托管平台', 'Gitee,码云,代码', 12000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(51, 51, 'Coding', 'https://dev.tencent.com', '🔵', '腾讯云代码托管', 'Coding,代码,腾讯云', 4000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 技术社区
(52, 52, 'CSDN', 'https://www.csdn.net', '🔴', '中文IT技术社区', 'CSDN,技术,博客', 20000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(53, 52, '掘金', 'https://juejin.cn', '🔵', '面向开发者的技术社区', '掘金,技术,前端', 14000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(54, 52, '博客园', 'https://www.cnblogs.com', '🔵', '开发者博客社区', '博客园,技术,博客', 8000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(55, 52, 'Stack Overflow', 'https://stackoverflow.com', '🟠', '全球编程问答社区', 'StackOverflow,技术,问答', 16000, 88, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(56, 52, 'V2EX', 'https://www.v2ex.com', '🔵', '创意工作者的社区', 'V2EX,技术,社区', 5000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(57, 52, '开源中国', 'https://www.oschina.net', '🔴', '中文开源技术社区', '开源中国,技术,开源', 6000, 82, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 云服务
(58, 53, '阿里云', 'https://www.aliyun.com', '🟠', '阿里云计算平台', '阿里云,云,服务器', 18000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(59, 53, '腾讯云', 'https://cloud.tencent.com', '🔵', '腾讯云计算平台', '腾讯云,云,服务器', 14000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(60, 53, '华为云', 'https://www.huaweicloud.com', '🔴', '华为云计算平台', '华为云,云,服务器', 9000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(61, 53, '百度智能云', 'https://cloud.baidu.com', '🔵', '百度云计算平台', '百度云,云,AI', 5000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(62, 53, 'Cloudflare', 'https://www.cloudflare.com', '🟠', '全球CDN与安全服务', 'Cloudflare,CDN,海外', 8000, 82, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(63, 53, '七牛云', 'https://www.qiniu.com', '🟢', '对象存储与CDN', '七牛云,存储,CDN', 4000, 80, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 慕课学习
(64, 61, '中国大学MOOC', 'https://www.icourse163.org', '🔴', '国家精品在线课程', '慕课,大学,课程', 9000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(65, 61, '网易云课堂', 'https://study.163.com', '🔴', '实用技能学习平台', '云课堂,课程,学习', 8000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(66, 61, '慕课网', 'https://www.imooc.com', '🟢', 'IT技能学习平台', '慕课网,IT,编程', 10000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(67, 61, 'Coursera', 'https://www.coursera.org', '🔵', '全球在线课程平台', 'Coursera,课程,海外', 7000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(68, 61, '学堂在线', 'https://www.xuetangx.com', '🔵', '清华出品慕课平台', '学堂在线,课程,大学', 4000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(69, 61, 'B站课堂', 'https://www.bilibili.com/v/cheese', '🔵', 'B站知识区课程', 'B站课堂,课程,学习', 5000, 82, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 资料文库
(70, 62, '百度文库', 'https://wenku.baidu.com', '🔵', '文档资料分享平台', '百度文库,文档,资料', 9000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(71, 62, '道客巴巴', 'https://www.doc88.com', '🔴', '在线文档分享平台', '道客巴巴,文档,资料', 4000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(72, 62, '原创力文档', 'https://max.book118.com', '🟠', '专业文档下载站', '原创力,文档,下载', 3000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 综合电商
(73, 71, '淘宝', 'https://www.taobao.com', '🔶', '淘!我喜欢', '淘宝,购物,电商', 40000, 100, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(74, 71, '京东', 'https://www.jd.com', '🔴', '正品低价品质保障', '京东,购物,自营', 38000, 95, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(75, 71, '拼多多', 'https://www.pinduoduo.com', '🔴', '拼着买更便宜', '拼多多,团购,便宜', 32000, 90, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(76, 71, '天猫', 'https://www.tmall.com', '🔴', '品质好物聚集地', '天猫,购物,品牌', 28000, 88, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(77, 71, '苏宁易购', 'https://www.suning.com', '🔵', '家电3C综合电商', '苏宁,购物,家电', 9000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 海淘二手
(78, 72, '亚马逊', 'https://www.amazon.cn', '🔴', '全球综合电商平台', '亚马逊,海淘,电商', 12000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(79, 72, '闲鱼', 'https://www.goofish.com', '🔶', '阿里二手交易社区', '闲鱼,二手,转卖', 16000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(80, 72, '转转', 'https://www.zhuanzhuan.com', '🔵', '二手交易平台', '转转,二手,交易', 7000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(81, 72, 'eBay', 'https://www.ebay.com', '🔴', '全球在线拍卖与购物', 'eBay,海淘,海外', 6000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 文档协作
(82, 81, '腾讯文档', 'https://docs.qq.com', '🔵', '多人实时在线文档', '腾讯文档,在线文档,协作', 14000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(83, 81, '石墨文档', 'https://shimo.im', '🟢', '轻盈的在线协作文档', '石墨文档,协作,文档', 6000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(84, 81, '飞书', 'https://www.feishu.cn', '🔵', '一站式办公协作平台', '飞书,办公,协作', 10000, 92, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(85, 81, '钉钉文档', 'https://www.dingtalk.com', '🔵', '阿里办公协作套件', '钉钉,办公,协作', 9000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(86, 81, 'Notion', 'https://www.notion.so', '', '全能笔记与协作工具', 'Notion,笔记,海外', 8000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(87, 81, 'Google Docs', 'https://docs.google.com', '🔵', '谷歌在线文档套件', 'GoogleDocs,文档,海外', 6000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 邮箱
(88, 82, 'QQ邮箱', 'https://mail.qq.com', '🔵', '腾讯免费邮箱', 'QQ邮箱,邮箱,邮件', 20000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(89, 82, '网易邮箱', 'https://mail.163.com', '🔴', '网易免费邮箱', '网易邮箱,邮箱,邮件', 12000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(90, 82, 'Outlook', 'https://outlook.live.com', '🔵', '微软邮箱服务', 'Outlook,邮箱,微软', 9000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(91, 82, 'Gmail', 'https://mail.google.com', '🔴', '谷歌邮箱服务', 'Gmail,邮箱,谷歌', 11000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(92, 82, 'Foxmail', 'https://www.foxmail.com', '🔵', '腾讯邮箱客户端', 'Foxmail,邮箱,客户端', 3000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 云盘
(93, 83, '百度网盘', 'https://pan.baidu.com', '🔵', '国内最大云存储', '百度网盘,云盘,存储', 26000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(94, 83, '阿里云盘', 'https://www.aliyundrive.com', '🔴', '不限速个人云盘', '阿里云盘,云盘,存储', 14000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(95, 83, 'OneDrive', 'https://onedrive.live.com', '🔵', '微软云存储服务', 'OneDrive,云盘,微软', 9000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(96, 83, '蓝奏云', 'https://www.lanzou.com', '🟢', '不限速文件分享', '蓝奏云,云盘,分享', 5000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(97, 83, '腾讯微云', 'https://www.weiyun.com', '🔵', '腾讯云存储服务', '微云,云盘,腾讯', 4000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 会议办公
(98, 84, '腾讯会议', 'https://meeting.tencent.com', '🔵', '高清流畅视频会议', '腾讯会议,会议,视频', 14000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(99, 84, '钉钉', 'https://www.dingtalk.com', '🔵', '阿里企业办公平台', '钉钉,办公,企业', 12000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(100, 84, '飞书会议', 'https://www.feishu.cn', '🔵', '字节一站式办公', '飞书,会议,办公', 7000, 92, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(101, 84, 'Zoom', 'https://www.zoom.us', '🔵', '国际视频会议工具', 'Zoom,会议,海外', 8000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(102, 84, '企业微信', 'https://work.weixin.qq.com', '🔢', '腾讯企业通讯工具', '企业微信,办公,企业', 9000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 设计素材
(103, 91, '千图网', 'https://www.58pic.com', '🔴', '原创设计素材库', '千图网,素材,设计', 6000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(104, 91, '包图网', 'https://www.ibaotu.com', '🔶', '商业设计素材平台', '包图网,素材,设计', 4000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(105, 91, '我图网', 'https://www.ooopic.com', '🔵', '正版设计素材下载', '我图网,素材,设计', 3000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(106, 91, 'Unsplash', 'https://unsplash.com', '', '免费高清摄影图库', 'Unsplash,图片,海外', 9000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(107, 91, 'Pexels', 'https://www.pexels.com', '🔵', '免费摄影视频素材', 'Pexels,图片,海外', 6000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(108, 91, 'Pixabay', 'https://pixabay.com', '🔵', '免费正版图库与视频', 'Pixabay,图片,海外', 4000, 82, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 设计工具
(109, 92, 'Figma', 'https://www.figma.com', '🔴', '协作式UI设计工具', 'Figma,设计,UI', 10000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(110, 92, 'Canva', 'https://www.canva.cn', '🔵', '在线平面设计工具', 'Canva,设计,海报', 8000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(111, 92, '稿定设计', 'https://www.gaoding.com', '🔴', '电商新媒体设计工具', '稿定设计,设计,模板', 4000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(112, 92, '即时设计', 'https://js.design', '🔵', '国产协作式UI工具', '即时设计,UI,设计', 4000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(113, 92, '墨刀', 'https://modao.cc', '🔵', '在线原型设计工具', '墨刀,原型,设计', 3000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 生活服务
(114, 101, '快递100', 'https://www.kuaidi100.com', '🔵', '快递查询与寄件', '快递100,快递,查询', 9000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(115, 101, '12306', 'https://www.12306.cn', '🔴', '中国铁路购票官网', '12306,火车票,铁路', 18000, 95, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(116, 101, '高德地图', 'https://www.amap.com', '🔵', '高德导航与地图', '高德,地图,导航', 16000, 92, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(117, 101, '百度地图', 'https://map.baidu.com', '🔵', '百度地图与导航', '百度地图,地图,导航', 12000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(118, 101, '中国天气', 'https://www.weather.com.cn', '🌤️', '中央气象台天气', '天气,气象,预报', 8000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(119, 101, '美团', 'https://www.meituan.com', '🔶', '本地生活服务平台', '美团,外卖,生活', 20000, 88, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 在线工具
(120, 102, 'JSON在线解析', 'https://www.json.cn', '🔧', 'JSON格式化与校验', 'JSON,工具,格式化', 8000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(121, 102, '草料二维码', 'https://cli.im', '🔲', '二维码生成与解码', '草料,二维码,工具', 5000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(122, 102, 'ProcessOn', 'https://www.processon.com', '🔵', '在线流程图与思维导图', 'ProcessOn,流程图,思维导图', 5000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(123, 102, '站长工具', 'https://tool.chinaz.com', '🔵', 'SEO与建站查询', '站长工具,SEO,查询', 6000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(124, 102, 'Regex101', 'https://regex101.com', '🔴', '正则表达式在线测试', 'Regex,正则,海外', 4000, 82, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 行情理财
(125, 111, '东方财富', 'https://www.eastmoney.com', '🔴', '财经金融资讯门户', '东方财富,财经,股票', 16000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(126, 111, '同花顺', 'https://www.10jqka.com.cn', '🔴', '股票行情交易软件', '同花顺,股票,行情', 9000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(127, 111, '雪球', 'https://xueqiu.com', '🔴', '投资者社区与行情', '雪球,股票,投资', 9000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(128, 111, '天天基金', 'https://fund.eastmoney.com', '🔵', '基金净值与申购', '天天基金,基金,理财', 6000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 支付银行
(129, 112, '支付宝', 'https://www.alipay.com', '🔵', '支付就用支付宝', '支付宝,支付,金融', 40000, 100, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(130, 112, '微信支付', 'https://pay.weixin.qq.com', '💚', '腾讯移动支付', '微信支付,支付,金融', 30000, 95, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(131, 112, '中国银联', 'https://www.unionpay.com', '🔴', '银行卡联合组织', '银联,银行,支付', 5000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(132, 112, '网银在线', 'https://www.chinabank.com.cn', '🔵', '京东旗下支付', '网银在线,支付,京东', 3000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 交通出行
(133, 121, '携程', 'https://www.ctrip.com', '🔵', '机票酒店预订平台', '携程,旅游,机票', 18000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(134, 121, '去哪儿', 'https://www.qunar.com', '🔶', '旅行预订搜索', '去哪儿,旅游,机票', 9000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(135, 121, '飞猪', 'https://www.fliggy.com', '🔴', '阿里旅行平台', '飞猪,旅游,机票', 8000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(136, 121, '12306', 'https://www.12306.cn', '🔴', '铁路购票官网', '12306,火车票,出行', 18000, 92, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(137, 121, '高德打车', 'https://www.amap.com', '🔵', '一键呼叫网约车', '高德打车,出行,网约车', 6000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 住宿预订
(138, 122, '美团酒店', 'https://hotel.meituan.com', '🔶', '本地酒店预订', '美团,酒店,住宿', 12000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(139, 122, 'Airbnb', 'https://www.airbnb.cn', '🔴', '全球民宿短租', 'Airbnb,民宿,海外', 6000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(140, 122, 'Booking', 'https://www.booking.com', '🔵', '全球酒店预订', 'Booking,酒店,海外', 7000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(141, 122, '途家', 'https://www.tujia.com', '🔵', '国内民宿预订', '途家,民宿,住宿', 4000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 在线问诊
(142, 131, '丁香医生', 'https://dxy.com', '🔴', '专业健康科普与问诊', '丁香医生,健康,问诊', 8000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(143, 131, '好大夫在线', 'https://www.haodf.com', '🔵', '医患对接问诊平台', '好大夫,问诊,医生', 6000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(144, 131, '微医', 'https://www.guahao.com', '🔵', '互联网医疗服务', '微医,挂号,问诊', 4000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(145, 131, '平安健康', 'https://health.pingan.com', '🔴', '平安互联网医疗', '平安健康,问诊,医疗', 4000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 健康科普
(146, 132, '丁香园', 'https://www.dxy.cn', '🔴', '医药健康专业社区', '丁香园,医学,科普', 5000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(147, 132, '腾讯健康', 'https://health.qq.com', '🔵', '腾讯健康科普服务', '腾讯健康,健康,科普', 4000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(148, 132, '39健康网', 'https://www.39.net', '🔵', '大众健康资讯门户', '39健康网,健康,科普', 3000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 政务服务
(149, 141, '国家政务服务平台', 'https://gjzwfw.www.gov.cn', '🔴', '全国一体化政务门户', '国家政务,政务,服务', 6000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(150, 141, '粤省事', 'https://www.gdyxzc.gov.cn', '🔵', '广东政务服务小程序', '粤省事,政务,广东', 4000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(151, 141, '浙里办', 'https://www.zjzwfw.gov.cn', '🔵', '浙江政务服务门户', '浙里办,政务,浙江', 3000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(152, 141, '国务院客户端', 'https://www.gov.cn', '🔴', '中央人民政府门户', '国务院,政务,政策', 3000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 生活缴费
(153, 142, '社保查询', 'http://si.12333.gov.cn', '🔵', '全国社保公共服务', '社保,查询,缴费', 4000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(154, 142, '水电网缴费', 'https://www.95598.cn', '', '国家电网与公共事业', '水电,缴费,电网', 3000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(155, 142, '个人所得税', 'https://etax.chinatax.gov.cn', '🔴', '自然人税务平台', '个税,税务,申报', 3000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL);
SET FOREIGN_KEY_CHECKS = 1;
+42
View File
@@ -0,0 +1,42 @@
<?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\validate;
use think\Validate;
/**
* Category
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Category extends Validate
{
protected $rule = [
'title' => 'require|chsDash|max:50',
'pid' => 'integer|egt:0',
'icon' => 'max:50',
'summary'=> 'max:255',
'sort' => 'integer',
'status' => 'in:0,1',
];
protected $message = [
'title.require' => '分类名称不能为空',
'title.chsDash' => '分类名称只能是汉字、字母、数字或破折号',
'title.max' => '分类名称不能超过 50 个字符',
'pid.integer' => '上级分类必须是数字',
'pid.egt' => '上级分类不能为负',
'icon.max' => '图标标识过长',
'summary.max' => '简介不能超过 255 个字符',
'sort.integer' => '排序必须是数字',
'status.in' => '状态值不合法',
];
}
+50
View File
@@ -0,0 +1,50 @@
<?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\validate;
use think\Validate;
/**
* Link
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Link extends Validate
{
protected $rule = [
'title' => 'require|max:100',
'url' => 'require|url|max:255',
'cid' => 'require|integer|gt:0',
'keywords' => 'max:255',
'description' => 'max:255',
'sort' => 'integer',
'status' => 'in:0,1,2',
'is_hot' => 'in:0,1',
'is_recommend' => 'in:0,1',
];
protected $message = [
'title.require' => '网站名称不能为空',
'title.max' => '网站名称不能超过 100 个字符',
'url.require' => '网址不能为空',
'url.url' => '网址格式不正确(需以 http:// 或 https:// 开头)',
'url.max' => '网址不能超过 255 个字符',
'cid.require' => '请选择所属分类',
'cid.integer' => '分类必须是数字',
'cid.gt' => '请选择有效的分类',
'keywords.max' => '关键词不能超过 255 个字符',
'description.max' => '描述不能超过 255 个字符',
'sort.integer' => '排序必须是数字',
'status.in' => '状态值不合法',
'is_hot.in' => '热门值不合法',
'is_recommend.in' => '推荐值不合法',
];
}
+271
View File
@@ -0,0 +1,271 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>广告管理</title>
<meta name="renderer" content="webkit">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link href="/assets/layui/css/layui.css" rel="stylesheet">
<link href="/assets/ywxapp/css/ywxapp.css" rel="stylesheet">
<script type="text/javascript">
var config = { root: "/" };
</script>
</head>
<body>
<div class="layui-card">
<div class="layui-form layui-card-header layuiadmin-card-header-auto">
<div class="layui-form-item">
<div class="layui-inline">
<select name="slot" id="slotFilter">
<option value="">全部广告位</option>
</select>
</div>
<div class="layui-inline">
<select name="status">
<option value="">全部状态</option>
<option value="1">启用</option>
<option value="0">禁用</option>
</select>
</div>
<div class="layui-inline">
<button class="layui-btn layuiadmin-btn-useradmin" lay-submit lay-filter="tableSearchButton">
<i class="layui-icon layui-icon-search"></i>
</button>
</div>
</div>
</div>
<div class="layui-card-body">
<table class="layui-hide" id="dataTable" lay-filter="dataTable"></table>
</div>
</div>
<script type="text/html" id="dataBar">
<div class="layui-btn-group">
<a class="layui-btn layui-btn-sm layui-btn-primary" title="编辑" lay-event="update"><i class="layui-icon layui-icon-edit"></i></a>
<a class="layui-btn layui-btn-sm layui-btn-primary" title="删除" lay-event="delete"><i class="layui-icon layui-icon-delete"></i></a>
</div>
</script>
<script type="text/html" id="statusTpl">
<input type="checkbox" name="status" value="{{d.id}}" lay-skin="switch" lay-text="启用|禁用" lay-filter="statusSwitch" {{ d.status == 1 ? 'checked' : '' }}>
</script>
<script type="text/html" id="typeTpl">
{{# if(d.type == 1){ }}图片{{# } else { }}代码{{# } }}
</script>
<script type="text/html" id="slotTpl">
{{# var m={'home_top':'首页顶部','home_bottom':'首页底部','detail_inline':'详情页内联','sidebar':'侧边栏'}; }}
{{ m[d.slot] || d.slot }}
</script>
<!-- 添加/编辑表单 -->
<script type="text/html" id="dataFormTpl">
<form class="layui-form" style="padding:20px 20px 0;">
<input type="hidden" name="id" value="{{d.id||''}}">
<div class="layui-form-item">
<label class="layui-form-label">广告位</label>
<div class="layui-input-block">
<select name="slot" lay-verify="required">
<option value="">请选择广告位</option>
{{# d.slots.forEach(function(item){ }}
<option value="{{item.id}}" {{ d.slot == item.id ? 'selected' : '' }}>{{item.title}}</option>
{{# }); }}
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">标题</label>
<div class="layui-input-block">
<input type="text" name="title" required lay-verify="required" placeholder="广告标题" class="layui-input" value="{{d.title||''}}">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">类型</label>
<div class="layui-input-block">
<input type="radio" name="type" value="1" title="图片广告" {{ d.type==undefined || d.type==1 ? 'checked' : '' }}>
<input type="radio" name="type" value="2" title="代码广告" {{ d.type==2 ? 'checked' : '' }}>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">图片</label>
<div class="layui-input-block">
<input type="text" name="image" placeholder="图片URL(图片广告必填)" class="layui-input" value="{{d.image||''}}">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">跳转链接</label>
<div class="layui-input-block">
<input type="text" name="url" placeholder="点击图片后跳转的URL" class="layui-input" value="{{d.url||''}}">
</div>
</div>
<div class="layui-form-item layui-form-text">
<label class="layui-form-label">广告代码</label>
<div class="layui-input-block">
<textarea name="code" placeholder="自定义HTML/JS代码(联盟广告等),代码广告必填" class="layui-textarea">{{d.code||''}}</textarea>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">排序</label>
<div class="layui-input-block">
<input type="number" name="sort" class="layui-input" value="{{d.sort||0}}">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">状态</label>
<div class="layui-input-block">
<input type="radio" name="status" value="1" title="启用" {{ d.status==undefined || d.status==1 ? 'checked' : '' }}>
<input type="radio" name="status" value="0" title="禁用" {{ d.status==0 ? 'checked' : '' }}>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">生效起</label>
<div class="layui-input-block">
<input type="text" name="start_at" id="startAt" placeholder="留空=立即生效" class="layui-input" value="{{d.start_at_text||''}}">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">生效止</label>
<div class="layui-input-block">
<input type="text" name="end_at" id="endAt" placeholder="留空=长期有效" class="layui-input" value="{{d.end_at_text||''}}">
</div>
</div>
<div class="layui-form-item layui-hide">
<button class="layui-btn" lay-submit="" lay-filter="wxapp-form-submit" id="wxapp-form-submit">提交</button>
</div>
</form>
</script>
<script src="/assets/layui/layui.js"></script>
<script src="/assets/ywxapp/ywxapp.js" module="backend"></script>
<script>
layui.use(['http', 'table', 'form', 'laytpl', 'laydate', 'layer'], function () {
var $ = layui.$, table = layui.table, form = layui.form, laytpl = layui.laytpl, laydate = layui.laydate, http = layui.http, layer = layui.layer;
var slots = [];
http.get('/haonav/backend/ad/create').then(function (res) {
if (res.code === 0) {
slots = res.data || [];
var sel = document.getElementById('slotFilter');
slots.forEach(function (s) { sel.appendChild(new Option(s.title, s.id)); });
form.render('select');
} else { layer.msg(res.msg || '加载失败'); }
});
var dataTable = table.render({
elem: '#dataTable',
defaultToolbar: [{ title: '创建广告', layEvent: 'dataCreate', icon: 'layui-icon-add-1' }, 'filter', 'exports', 'print' ],
url: '/haonav/backend/ad/index',
page: true, limit: 20,
parseData: function (res) {
return { code: res.code, msg: res.message || '', count: res.count || 0, data: res.data || [] };
},
cols: [[
{ type: 'checkbox', fixed: 'left' },
{ field: 'id', title: 'ID', width: 70, sort: true },
{ field: 'slot', title: '广告位', width: 110, templet: '#slotTpl' },
{ field: 'title', title: '标题', minWidth: 120 },
{ field: 'type', title: '类型', width: 90, templet: '#typeTpl' },
{ field: 'image', title: '预览', width: 90, align: 'center', templet: function (d) { return d.type == 1 && d.image ? '<img src="' + d.image + '" style="max-height:32px;max-width:60px;">' : (d.type == 2 ? '<span style="color:#999">代码</span>' : '-'); } },
{ field: 'url', title: '链接', minWidth: 120, templet: function (d) { return d.url ? '<a href="' + d.url + '" target="_blank" style="color:#01AAED;">' + d.url + '</a>' : '-'; } },
{ field: 'click_count', title: '点击', width: 80, sort: true },
{ field: 'sort', title: '排序', width: 80, sort: true, edit: 'text' },
{ field: 'status', title: '状态', width: 90, align: 'center', templet: '#statusTpl', unresize: true },
{ field: 'create_at', title: '创建时间', width: 160, align: 'center', templet: function (d) { return d.create_at ? d.create_at : '-'; } },
{ title: '操作', width: 110, align: 'center', toolbar: '#dataBar', fixed: 'right' }
]]
});
table.on('toolbar(dataTable)', function (obj) {
if (obj.event === 'dataCreate') { active.dataCreate(); }
});
table.on('edit(dataTable)', function (obj) {
if (obj.field === 'sort') {
http.post('sort', { id: obj.data.id, sort: obj.value }).then(function (res) {
if (res.code === 0) { layer.msg(res.message || '已保存'); }
else { layer.msg(res.message || '保存失败'); table.reload('dataTable', {}, true); }
});
}
});
table.on('tool(dataTable)', function (elem) {
if (elem.event === 'update') { active.dataEdit(elem.data); }
else if (elem.event === 'delete') { active.dataDelete([elem.data.id]); }
});
form.on('submit(tableSearchButton)', function (data) {
table.reload('dataTable', { where: data.field });
});
form.on('switch(statusSwitch)', function (obj) {
var id = this.value, st = obj.elem.checked ? 1 : 0;
http.post('status', { id: id, status: st }).then(function (res) {
if (res.code === 0) { layer.msg(res.message || '已保存'); }
else { layer.msg(res.message || '失败'); obj.elem.checked = !obj.elem.checked; form.render('checkbox'); }
});
});
var dataFromFun = function (data, callback, done) {
var html = laytpl($('#dataFormTpl').html()).render(data || {});
layer.open({
title: data.id ? '编辑广告' : '添加广告',
content: html, anim: 'slideLeft', offset: 'r',
btnAlign: 'l', area: ['440px', '99%'], shade: 0.1, shadeClose: true,
btn: ['确定', '取消'],
success: function (layero, index) {
callback(layero, index);
form.render();
laydate.render({ elem: '#startAt', type: 'datetime' });
laydate.render({ elem: '#endAt', type: 'datetime' });
},
yes: function (index, layero) {
layui.form.on('submit(wxapp-form-submit)', function (elem) {
done(layero, index, elem); layui.off('submit(wxapp-form-submit)'); return false;
});
layero.contents().find('#wxapp-form-submit').trigger('click');
}
});
};
var active = {
dataCreate: function () {
dataFromFun({ slots: slots }, function () { }, function (layero, index, elem) {
var field = elem.field;
http.post('save', field).then(function (res) {
if (res.code === 0) { layer.close(index); table.reload('dataTable', {}, true); }
else { layer.msg(res.message || '失败'); }
});
});
},
dataEdit: function (data) {
http.get('edit?id=' + data.id).then(function (res) {
if (res.code !== 0) { return layer.msg(res.msg || '加载失败'); }
var d = res.data.info || {};
d.slots = res.data.slots || slots;
dataFromFun(d, function () { }, function (layero, index, elem) {
var field = elem.field;
http.put('update', field).then(function (r) {
if (r.code === 0) { layer.close(index); table.reload('dataTable', {}, true); }
else { layer.msg(r.message || '失败'); }
});
});
});
},
dataDelete: function (ids) {
if (!ids.length) { return layer.msg('请选择数据'); }
layer.confirm('确定删除吗?', function (index) {
http.delete('delete', { ids: ids.join(',') }).then(function (res) {
if (res.code === 0) { layer.close(index); table.reload('dataTable', {}, true); }
else { layer.msg(res.message || '失败'); }
});
});
}
};
});
</script>
</body>
</html>
+185
View File
@@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>申请审核</title>
<meta name="renderer" content="webkit">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link href="/assets/layui/css/layui.css" rel="stylesheet">
<link href="/assets/ywxapp/css/ywxapp.css" rel="stylesheet">
<script type="text/javascript">
var config = { root: "/" };
</script>
</head>
<body>
<div class="layui-card">
<div class="layui-form layui-card-header layuiadmin-card-header-auto">
<div class="layui-form-item">
<div class="layui-inline">
<select name="type">
<option value="">全部类型</option>
<option value="1">收录/友链</option>
<option value="2">广告合作</option>
</select>
</div>
<div class="layui-inline">
<select name="status">
<option value="">全部状态</option>
<option value="0">待审核</option>
<option value="1">已通过</option>
<option value="2">已拒绝</option>
</select>
</div>
<div class="layui-inline">
<button class="layui-btn layuiadmin-btn-useradmin" lay-submit lay-filter="tableSearchButton">
<i class="layui-icon layui-icon-search"></i>
</button>
</div>
</div>
</div>
<div class="layui-card-body">
<table class="layui-hide" id="dataTable" lay-filter="dataTable"></table>
</div>
</div>
<script type="text/html" id="dataBar">
<div class="layui-btn-group">
{{# if(d.status == 0){ }}
<a class="layui-btn layui-btn-sm layui-btn-normal" title="通过" lay-event="approve"><i class="layui-icon layui-icon-ok"></i></a>
<a class="layui-btn layui-btn-sm layui-btn-warm" title="拒绝" lay-event="reject"><i class="layui-icon layui-icon-close"></i></a>
{{# } }}
<a class="layui-btn layui-btn-sm layui-btn-primary" title="删除" lay-event="delete"><i class="layui-icon layui-icon-delete"></i></a>
</div>
</script>
<script type="text/html" id="typeTpl">
{{# if(d.type == 1){ }}<span class="layui-badge layui-bg-blue">收录/友链</span>{{# } else { }}<span class="layui-badge layui-bg-orange">广告合作</span>{{# } }}
</script>
<script type="text/html" id="statusTpl">
{{# if(d.status == 0){ }}<span class="layui-badge layui-bg-gray">待审核</span>
{{# } else if(d.status == 1){ }}<span class="layui-badge layui-bg-green">已通过</span>
{{# } else { }}<span class="layui-badge">已拒绝</span>{{# } }}
</script>
<!-- 通过(友链需选分类) -->
<script type="text/html" id="approveFormTpl">
<form class="layui-form" style="padding:20px 20px 0;">
<input type="hidden" name="id" value="{{d.id}}">
{{# if(d.type == 1){ }}
<div class="layui-form-item">
<label class="layui-form-label">收录分类</label>
<div class="layui-input-block">
<select name="cid" lay-verify="required">
<option value="">请选择分类</option>
{{# d.cates.forEach(function(c){ }}
<option value="{{c.id}}">{{c.title}}</option>
{{# }); }}
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">上架方式</label>
<div class="layui-input-block">
<input type="radio" name="online" value="1" title="直接上架" checked>
<input type="radio" name="online" value="0" title="进待审核">
</div>
</div>
{{# } }}
<div class="layui-form-item layui-form-text">
<label class="layui-form-label">审核备注</label>
<div class="layui-input-block">
<textarea name="reply" placeholder="选填,将保留在申请记录中" class="layui-textarea"></textarea>
</div>
</div>
<div class="layui-form-item layui-hide">
<button class="layui-btn" lay-submit="" lay-filter="wxapp-form-submit" id="wxapp-form-submit">提交</button>
</div>
</form>
</script>
<script src="/assets/layui/layui.js"></script>
<script src="/assets/ywxapp/ywxapp.js" module="backend"></script>
<script>
layui.use(['http', 'table', 'form', 'laytpl', 'layer'], function () {
var $ = layui.$, table = layui.table, form = layui.form, laytpl = layui.laytpl, http = layui.http, layer = layui.layer;
var cates = [];
http.get('/haonav/backend/apply/create').then(function (res) {
if (res.code === 0) { cates = res.data || []; }
});
table.render({
elem: '#dataTable',
defaultToolbar: ['filter', 'exports', 'print'],
url: '/haonav/backend/apply/index',
page: true, limit: 20,
parseData: function (res) {
return { code: res.code, msg: res.message || '', count: res.count || 0, data: res.data || [] };
},
cols: [[
{ type: 'checkbox', fixed: 'left' },
{ field: 'id', title: 'ID', width: 70, sort: true },
{ field: 'type', title: '类型', width: 110, templet: '#typeTpl' },
{ field: 'title', title: '名称', minWidth: 120 },
{ field: 'url', title: '网址', minWidth: 160, templet: function (d) { return d.url ? '<a href="' + d.url + '" target="_blank" rel="nofollow" style="color:#01AAED;">' + d.url + '</a>' : '-'; } },
{ field: 'contact', title: '联系方式', width: 140 },
{ field: 'slot', title: '意向广告位', width: 110, templet: function (d) { var m = { 'home_top': '首页顶部', 'home_bottom': '首页底部', 'detail_inline': '详情页内联', 'sidebar': '侧边栏' }; return m[d.slot] || (d.slot || '-'); } },
{ field: 'description', title: '说明', minWidth: 140 },
{ field: 'status', title: '状态', width: 90, align: 'center', templet: '#statusTpl' },
{ field: 'reply', title: '审核备注', minWidth: 110, templet: function (d) { return d.reply || '-'; } },
{ field: 'ip', title: 'IP', width: 130 },
{ field: 'create_at', title: '提交时间', width: 160, align: 'center', templet: function (d) { return d.create_at || '-'; } },
{ title: '操作', width: 150, align: 'center', toolbar: '#dataBar', fixed: 'right' }
]]
});
form.on('submit(tableSearchButton)', function (data) {
table.reload('dataTable', { where: data.field });
});
table.on('tool(dataTable)', function (elem) {
var d = elem.data;
if (elem.event === 'approve') {
d.cates = cates;
var html = laytpl($('#approveFormTpl').html()).render(d);
layer.open({
title: '通过申请 #' + d.id,
content: html, anim: 'slideLeft', offset: 'r',
btnAlign: 'l', area: ['420px', '99%'], shade: 0.1, shadeClose: true,
btn: ['确定', '取消'],
success: function () { form.render(); },
yes: function (index, layero) {
layui.form.on('submit(wxapp-form-submit)', function (el) {
http.post('approve', el.field).then(function (res) {
if (res.code === 0) { layer.msg(res.message || '已通过'); layer.close(index); table.reload('dataTable', {}, true); }
else { layer.msg(res.message || '失败'); }
});
layui.off('submit(wxapp-form-submit)'); return false;
});
layero.contents().find('#wxapp-form-submit').trigger('click');
}
});
} else if (elem.event === 'reject') {
layer.prompt({ formType: 2, title: '拒绝原因(将保留在记录中,可留空)' }, function (val, index) {
http.post('reject', { id: d.id, reply: val || '' }).then(function (res) {
if (res.code === 0) { layer.msg(res.message || '已拒绝'); layer.close(index); table.reload('dataTable', {}, true); }
else { layer.msg(res.message || '失败'); }
});
});
} else if (elem.event === 'delete') {
layer.confirm('确定删除该申请吗?', function (index) {
http.delete('delete', { ids: String(d.id) }).then(function (res) {
if (res.code === 0) { layer.close(index); table.reload('dataTable', {}, true); }
else { layer.msg(res.message || '失败'); }
});
});
}
});
});
</script>
</body>
</html>
@@ -0,0 +1,420 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>{$title | default='YwxApp'}</title>
<meta name="renderer" content="webkit">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link href="/assets/layui/css/layui.css" rel="stylesheet">
<link href="/assets/ywxapp/css/ywxapp.css" rel="stylesheet">
<!-- HTML5 shim, for IE6-8 support of HTML5 elements. All other JS at the end of file. -->
<!--[if lt IE 9]>
<script src="__CDN__/assets/js/html5shiv.js"></script>
<script src="__CDN__/assets/js/respond.min.js"></script>
<![endif]-->
<script type="text/javascript">
var config = {
root: "/",
};
var require = {
};
</script>
</head>
<body>
<div class="layui-card">
<div class="layui-form layui-card-header layuiadmin-card-header-auto">
<div class="layui-form-item">
<div class="layui-inline">
<input type="text" name="title" placeholder="请输入标题" autocomplete="off" class="layui-input">
</div>
<div class="layui-inline">
<button class="layui-btn layuiadmin-btn-useradmin" lay-submit lay-filter="tableSearchButton">
<i class="layui-icon layui-icon-search layuiadmin-button-btn"></i>
</button>
</div>
</div>
</div>
<div class="layui-card-body">
<table class="layui-hide" id="dataTable" lay-filter="dataTable"></table>
</div>
</div>
<script type="text/html" id="dataBarTpl">
<div class="layui-btn-group">
<a class="layui-btn layui-btn-sm layui-btn-primary" title="编辑节点" lay-event="update"><i class="layui-icon layui-icon-edit"></i> </a>
<a class="layui-btn layui-btn-sm layui-btn-primary" title="添加子节点" lay-event="create"><i class="layui-icon layui-icon-add-1"></i> </a>
<a class="layui-btn layui-btn-sm layui-btn-primary" title="删除节点" lay-event="delete"><i class="layui-icon layui-icon-delete"></i> </a>
</div>
</script>
<!-- 添加/编辑菜单表单模板 -->
<script type="text/html" id="dataFormTpl">
<form class="layui-form layui-form-pane" lay-filter="wxapp-form" id="wxapp-form">
<input type="hidden" name="id" value="{{= d.id || '' }}">
<input type="hidden" name="pid" value="{{= d.pid || 0 }}">
<div class="layui-form-item">
<label class="layui-form-label">标题</label>
<div class="layui-input-block">
<input type="text" name="title" required lay-verify="required" placeholder="请输入标题" value="{{ d.title || '' }}" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">图标</label>
<div class="layui-input-block">
<input type="text" name="icon" placeholder="请选择图标" value="{{ d.icon ? d.icon.replace('layui-icon ', '') : '' }}" class="layui-input">
</div>
</div>
<div class="layui-form-item ">
<label class="layui-form-label">摘要</label>
<div class="layui-input-block">
<input name="summary" placeholder="请输入摘要" class="layui-input" value="{{ d.summary || '' }}" />
</div>
</div>
<div class="layui-form-item layui-form-text">
<label class="layui-form-label">关键词</label>
<div class="layui-input-block">
<textarea name="keywords" placeholder="请输入关键词" class="layui-textarea">{{d.keywords||''}}</textarea>
</div>
</div>
<div class="layui-form-item layui-form-text">
<label class="layui-form-label">描述</label>
<div class="layui-input-block">
<textarea name="description" placeholder="请输入描述信息" class="layui-textarea">{{d.description||''}}</textarea>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">排序</label>
<div class="layui-input-block">
<input type="number" name="sort" value="{{ d.sort || 0 }}" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">状态</label>
<div class="layui-input-block">
<input type="checkbox" name="status" lay-skin="switch" lay-text="启用|禁用" {{ d.status != 0 ? 'checked' : (!d.id ? 'checked' : '') }}>
</div>
</div>
<div class="layui-form-item layui-hide">
<button class="layui-btn" lay-submit="" lay-filter="wxapp-form-submit" id="wxapp-form-submit">提交</button>
</div>
</form>
</script>
<!-- 状态开关 -->
<script type="text/html" id="statusTpl">
<input type="checkbox" name="status" value="{{d.id}}" lay-skin="switch" lay-text="启用|禁用" lay-filter="statusSwitch" {{ d.status == 1 ? 'checked' : '' }}>
</script>
<script type="text/html" id="dataRecybinTpl">
<div class="layui-card">
<div class="layui-card-header"> </div>
<div class="layui-card-body">
<table class="layui-hide" id="dataRecybinTable" lay-filter="dataRecybinTable"></table>
</div>
</div>
</script>
</script>
<script src="/assets/layui/layui.js"></script>
<script src="/assets/ywxapp/ywxapp.js" module="backend"></script>
<script>
layui.use(['layer', 'http'], function () {
var $ = layui.$;
var treeTable = layui.treeTable;
var form = layui.form;
var layer = layui.layer;
var laytpl = layui.laytpl;
var http = layui.http;
console.log('layui.treeTable:', layui.setter.layerArea()); // 调试输出
treeTable.render({
defaultToolbar: [{
title: '创建资源', layEvent: 'dataCreate', icon: 'layui-icon-add-1'
}, {
title: '删除资源', layEvent: 'dataDelete', icon: 'layui-icon-delete'
}, {
title: '资源回收站', layEvent: 'dataRecybin', icon: 'layui-icon-home'
}, 'filter', 'exports', 'print'
],
elem: '#dataTable',
url: 'category/index',
parseData: function (res) { // 预处理返回数据
console.log('原始数据:', res); // 调试输入
return {
code: res.code === 0 ? 0 : 1, // 0 标识成功
data: res.data || [], // 数据数组
msg: res.message || '' // 错误信息
};
},
tree: {
customName: {
children: "children",
isParent: "is_parent",
name: "title",
id: "id",
pid: "pid",
icon: "icon"
},
data: { isSimpleData: true, rootPid: 0 },
view: {},
async: {},
callback: {}
},
height: 'full-100',
toolbar: '#tableBarTpl',
cols: [[
{ type: 'checkbox', fixed: 'left' },
{ field: 'id', title: 'ID', width: 80, sort: true, fixed: 'left' },
{ field: 'title', title: '名称', width: 180, fixed: 'left' },
{ field: 'icon', title: '图标', width: 80 },
{ field: 'sort', title: '排序', width: 80, sort: true },
{ field: 'status', title: '状态', width: 96, align: 'center', templet: '#statusTpl' },
{ fixed: "right", title: "操作", align: "center", toolbar: "#dataBarTpl", fixed: 'right' }
]],
page: true,
done: function (res, curr, count, origin) { }
});
treeTable.on('toolbar(dataTable)', function (obj) {
var options = obj.config;
switch (obj.event) {
case 'dataCreate':
active.dataCreate({ pid: 0, type: 1 });
break;
case 'dataDelete':
var checkStatus = treeTable.checkStatus('dataTable'), checkData = checkStatus.data;
let ids = checkData.map((item, index, array) => { return item.id; });
active.dataDelete(ids);
break;
case 'dataRecybin':
active.dataRecybin();
break;
};
});
treeTable.on('tool(dataTable)', function (elem) {
var data = elem.data;
switch (elem.event) {
case 'update':
active.dataEdit(data);
break;
case 'create':
active.dataCreate({ pid: data.id });
break;
case 'delete':
active.dataDelete([data.id]);
break;
default:
break;
}
});
form.on('submit(tableSearchButton)', function (data) {
var field = data.field;
treeTable.reload('dataTable', {
where: field
});
});
// 状态开关
form.on('switch(statusSwitch)', function (obj) {
var id = this.value;
var status = obj.elem.checked ? 1 : 0;
layer.confirm('确定要' + (status ? '启用' : '禁用') + '该数据吗?', function (index) {
layui.http.put('update', { id: id, status: status }).then(function (res) {
if (res.code == 0) {
layer.msg(res.msg, { icon: 1 });
table.reload('dataTable', {}, true);
} else {
layer.msg(res.msg, { icon: 2 });
obj.elem.checked = !obj.elem.checked;
form.render('checkbox');
}
});
layer.close(index);
}, function () {
obj.elem.checked = !obj.elem.checked;
form.render('checkbox');
});
});
var dataFromFun = function (data, callback, done) {
var formHtml = laytpl($('#dataFormTpl').html()).render(data || {});
layer.open({
title: data.id ? '编辑数据' : (data.pid ? '添加子数据' : '添加根数据'),
content: formHtml,
anim: "slideLeft",
offset: "r",
btnAlign: "l",
area: layui.setter.layerArea(),
shade: 0.1,
shadeClose: true,
btn: ['确定', '取消'],
success: function (layero, index) {
callback(layero, index);
form.render();
},
yes: function (index, layero) {
window.layui.form.on('submit(wxapp-form-submit)', function (elem) {
done(layero, index, elem);
layui.off('submit(wxapp-form)', 'from');
return false;
});
layero.contents().find("#wxapp-form-submit").trigger('click');
}
});
};
//事件
var active = {
dataCreate: function (data = {}) {
dataFromFun(data,
function (layero, index) { },
function (layero, index, elem) {
var field = elem.field;
field.icon = 'layui-icon ' + field.icon;
field.status = field.status ? 1 : 0;
http.post('save', field)
.then((res) => {
if (res.code === 0) {
layer.close(index);
treeTable.reload('dataTable', {}, true);
} else {
layer.msg(res.msg || '操作失败');
}
});
}
);
},
dataEdit: function (data = {}) {
dataFromFun(data,
function (layero, index) { },
function (layero, index, elem) {
var field = elem.field;
field.icon = 'layui-icon ' + field.icon;
field.status = field.status ? 1 : 0;
http.put('update', field)
.then((res) => {
if (res.code === 0) {
layer.close(index);
treeTable.reload('dataTable', {}, true);
} else {
layer.msg(res.msg || '操作失败');
}
});
}
);
},
dataDelete: function (ids, force = 0) {
if (ids.length === 0) {
return layer.msg('请选择数据');
}
layer.prompt({
formType: 1
, title: '敏感操作,请验证口令'
}, function (value, index) {
layer.close(index);
layer.confirm('确定删除吗?', function (index) {
http.delete('delete', { ids: ids.join(','), force: force })
.then((res) => {
if (res.code === 0) {
layer.close(index);
treeTable.reload('dataTable', {}, true);
} else {
layer.msg(res.msg || '操作失败');
}
});
layer.msg('已删除');
treeTable.reload('dataTable', {}, true);
});
});
},
dataRecybin: function (data = {}) {
var formHtml = laytpl($('#dataRecybinTpl').html()).render(data || {});
layer.open({
title: '资源回收站',
content: formHtml,
anim: "slideLeft",
offset: "r",
area: ['60%', '99%'],
shade: 0.1,
shadeClose: true,
success: function (layero, index) {
layui.table.render({
elem: '#dataRecybinTable',
url: 'recyclebin',
height: 'full-100',
defaultToolbar: [{
title: '批量删除数据',
layEvent: 'dataDelete',
icon: 'layui-icon-delete',
onClick: function (obj) {
var checkStatus = treeTable.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
let ids = checkData.map((item, index, array) => { return item.id; });
console.log(ids);
}
}, 'filter', 'exports', 'print'],
cols: [[
{ type: 'checkbox', fixed: 'left' },
{ field: 'id', title: 'ID', width: 80, sort: true, fixed: 'left' },
{ field: 'title', title: '标题', width: 180, fixed: 'left' },
{
fixed: "right", title: "操作", width: 120, align: "center", templet: function (d) {
return `<div class="layui-btn-group">
<a class="layui-btn layui-btn-sm" title="恢复数据" lay-event="restore" > <i class="layui-icon layui-icon-edit"></i> </a >
<a class="layui-btn layui-btn-sm" title="删除数据" lay-event="delete"><i class="layui-icon layui-icon-delete"></i> </a>
</div >`;
}
}
]],
page: true,
done: function (res, curr, count, origin) {
layui.table.on('tool(dataRecybinTable)', function (elem) {
var data = elem.data;
switch (elem.event) {
case 'restore':
active.dataRestore([data.id]);
break;
case 'delete':
active.dataDelete([data.id], 1);
break;
}
});
layui.table.on('toolbar(dataRecybinTable)', function (elem) {
switch (elem.event) {
case 'dataDelete':
var checkStatus = table.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
let idss = checkData.map((item, index, array) => { return item.id; });
active.dataDelete(idss, 1);
break;
case 'dataRestore':
var checkStatus = table.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
let ids = checkData.map((item, index, array) => { return item.id; });
active.dataRestore(ids);
break;
};
});
}
});
}
});
},
dataRestore: function (ids) {
if (ids.length === 0) {
return layer.msg('请选择数据');
}
http.put('restore', { ids: ids.join(',') })
.then((res) => {
if (res.code === 0) {
layer.msg('恢复成功');
table.reload('dataTable', {}, true);
} else {
layer.msg(res.msg || '恢复失败');
}
});
}
};
});
</script>
</body>
</html>
@@ -0,0 +1,351 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>{$title | default='YwxApp'}</title>
<meta name="renderer" content="webkit">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link href="/assets/layui/css/layui.css" rel="stylesheet">
<link href="/assets/ywxapp/css/ywxapp.css" rel="stylesheet">
<!-- HTML5 shim, for IE6-8 support of HTML5 elements. All other JS at the end of file. -->
<!--[if lt IE 9]>
<script src="__CDN__/assets/js/html5shiv.js"></script>
<script src="__CDN__/assets/js/respond.min.js"></script>
<![endif]-->
<script type="text/javascript">
var config = {
root: "/",
};
var require = {
};
</script>
<style>
body {
padding: 20px;
background-color: #f2f2f2;
}
.form-container {
background: white;
padding: 20px;
border-radius: 5px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.layui-tab-content {
padding: 20px 0;
}
.form-item-tip {
color: #999;
font-size: 12px;
margin-top: 5px;
}
.form-group-title {
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
font-weight: bold;
color: #333;
}
</style>
</head>
<body>
<div class="form-container">
<div id="formView"></div>
</div>
<!-- 表单模板 -->
<script type="text/html" id="formTpl">
<div class="layui-tab layui-tab-brief" lay-filter="formTab">
<ul class="layui-tab-title">
{{# layui.each(d.groups, function(index, group){ }}
<li class="{{# if(index === 0){ }}layui-this{{# } }}">{{ group }}</li>
{{# }); }}
</ul>
<div class="layui-tab-content">
{{# layui.each(d.groups, function(groupIndex, group){ }}
<div class="layui-tab-item {{# if(groupIndex === 0){ }}layui-show{{# } }}">
<form class="layui-form" lay-filter="form-{{ group }}">
<div class="form-group-title">{{ group }} 设置</div>
{{# layui.each(d.data[group], function(fieldIndex, field){ }}
<div class="layui-form-item">
<label class="layui-form-label">{{ field.title }}</label>
<div class="layui-input-block">
{{# if(field.type === 'string' || field.type === 'text'){ }}
<!-- 文本输入框 -->
<input type="text"
name="{{ field.name }}"
value="{{ field.value || '' }}"
placeholder="{{ field.tip || '请输入' + field.title }}"
class="layui-input"
{{# if(field.rule === 'required'){ }}lay-verify="required"{{# } }}>
{{# } else if(field.type === 'textarea'){ }}
<!-- 文本域 -->
<textarea name="{{ field.name }}"
placeholder="{{ field.tip || '请输入' + field.title }}"
class="layui-textarea"
{{# if(field.rule === 'required'){ }}lay-verify="required"{{# } }}>{{ field.value || '' }}</textarea>
{{# } else if(field.type === 'select'){ }}
<!-- 下拉选择框 -->
<select name="{{ field.name }}"
{{# if(field.rule === 'required'){ }}lay-verify="required"{{# } }}>
<option value="">请选择{{ field.title }}</option>
{{#
var options = field.content ? field.content.split(',') : [];
layui.each(options, function(optIndex, option){
}}
<option value="{{ option }}" {{# if(field.value == option){ }}selected{{# } }}>{{ option }}</option>
{{# }); }}
</select>
{{# } else if(field.type === 'checkbox'){ }}
<!-- 复选框 -->
{{#
var checkboxes = field.content ? field.content.split(',') : [];
layui.each(checkboxes, function(cbIndex, cbValue){
}}
<input type="checkbox"
name="{{ field.name }}"
title="{{ cbValue }}"
value="{{ cbValue }}"
{{# if(field.value && field.value.indexOf(cbValue) !== -1){ }}checked{{# } }}
lay-skin="primary">
{{# }); }}
{{# } else if(field.type === 'radio'){ }}
<!-- 单选框 -->
{{#
var radios = field.content ? field.content.split(',') : [];
layui.each(radios, function(rdIndex, rdValue){
}}
<input type="radio"
name="{{ field.name }}"
title="{{ rdValue }}"
value="{{ rdValue }}"
{{# if(field.value == rdValue){ }}checked{{# } }}>
{{# }); }}
{{# } else if(field.type === 'number'){ }}
<!-- 数字输入框 -->
<input type="number"
name="{{ field.name }}"
value="{{ field.value || '' }}"
placeholder="{{ field.tip || '请输入' + field.title }}"
class="layui-input"
{{# if(field.rule === 'required'){ }}lay-verify="required|number"{{# } }}>
{{# } else if(field.type === 'date'){ }}
<!-- 日期选择器 -->
<input type="text"
name="{{ field.name }}"
value="{{ field.value || '' }}"
placeholder="{{ field.tip || '请选择日期' }}"
class="layui-input"
id="date-{{ field.id }}">
{{# } }}
{{# if(field.tip){ }}
<div class="form-item-tip">{{ field.tip }}</div>
{{# } }}
</div>
</div>
{{# }); }}
<div class="layui-form-item">
<div class="layui-input-block">
<button type="button" class="layui-btn" lay-submit lay-filter="submit-{{ group }}">保存设置</button>
<button type="reset" class="layui-btn layui-btn-primary">重置</button>
</div>
</div>
</form>
</div>
{{# }); }}
</div>
</div>
</script>
<script src="/assets/layui/layui.js"></script>
<script src="/assets/ywxapp/ywxapp.js" module="backend"></script>
<script>//
var formData = [
{
"id": 1,
"name": "site_name",
"group": "basic",
"title": "站点名称",
"tip": "请填写站点名称",
"type": "string",
"value": "YFCMF-TP6",
"content": "",
"rule": "required",
"extend": "",
"setting": ""
},
{
"id": 2,
"name": "beian",
"group": "basic",
"title": "备案号",
"tip": "",
"type": "string",
"value": "",
"content": "",
"rule": "",
"extend": "",
"setting": ""
},
{
"id": 3,
"name": "site_description",
"group": "basic",
"title": "站点描述",
"tip": "请填写站点描述",
"type": "textarea",
"value": "这是一个基于TP6的CMS系统",
"content": "",
"rule": "",
"extend": "",
"setting": ""
},
{
"id": 4,
"name": "site_status",
"group": "advanced",
"title": "站点状态",
"tip": "选择站点是否开启",
"type": "select",
"value": "1",
"content": "开启,关闭,维护中",
"rule": "required",
"extend": "",
"setting": ""
},
{
"id": 5,
"name": "allow_comment",
"group": "advanced",
"title": "允许评论",
"tip": "是否允许访客评论",
"type": "radio",
"value": "1",
"content": "是,否",
"rule": "",
"extend": "",
"setting": ""
},
{
"id": 6,
"name": "features",
"group": "advanced",
"title": "功能模块",
"tip": "选择启用的功能模块",
"type": "checkbox",
"value": "文章,评论",
"content": "文章,评论,用户,统计",
"rule": "",
"extend": "",
"setting": ""
},
{
"id": 7,
"name": "max_upload_size",
"group": "upload",
"title": "最大上传大小",
"tip": "单位:MB",
"type": "number",
"value": "10",
"content": "",
"rule": "required",
"extend": "",
"setting": ""
},
{
"id": 8,
"name": "upload_date",
"group": "upload",
"title": "上传日期限制",
"tip": "选择可上传文件的日期范围",
"type": "date",
"value": "2024-01-01",
"content": "",
"rule": "",
"extend": "",
"setting": ""
}
];
// 处理数据:按group分组
function processData(data) {
var groups = {};
var groupNames = [];
// 收集所有分组
data.forEach(function (item) {
if (!groups[item.group]) {
groups[item.group] = [];
groupNames.push(item.group);
}
groups[item.group].push(item);
});
return {
groups: groupNames,
data: groups
};
}
// 初始化Layui
layui.use(['laytpl', 'form', 'element', 'laydate', 'http'], function (exports) {
var laytpl = layui.laytpl;
var form = layui.form;
var element = layui.element;
var laydate = layui.laydate;
var http = layui.http;
http.get('index').then(respon => {
let respona = processData(respon.data && respon.data.length ? respon.data : formData);
var getTpl = document.getElementById('formTpl').innerHTML;
var view = document.getElementById('formView');
laytpl(getTpl).render(respona, function (html) {
view.innerHTML = html;
// 重新渲染表单元素
form.render();
element.render('tab');
// 初始化日期选择器
formData.forEach(function (item) {
if (item.type === 'date') {
laydate.render({
elem: '#date-' + item.id,
format: 'yyyy-MM-dd'
});
}
});
// 监听表单提交
form.on('submit', function (data) {
console.log('表单提交数据:', data.field);
http.post('update', data.field).then(respon => {
console.log(respon)
});
layer.msg('表单提交成功!已输出到控制台');
return false;
});
});
})
// 处理数据
//var processedData = processData(formData);
// 渲染模板
// exports('configure', {});
});
</script>
</body>
</html>
@@ -0,0 +1,155 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>{$title | default='数据概览'}</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link href="/assets/layui/css/layui.css" rel="stylesheet">
<link href="/assets/ywxapp/css/ywxapp.css" rel="stylesheet">
<style>
body { padding: 16px; background: #f5f6fa; }
.stat-cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 16px; }
.stat-card { background: #fff; border-radius: 8px; padding: 16px; box-shadow: 0 1px 6px rgba(0,0,0,.06); }
.stat-card .num { font-size: 26px; font-weight: 700; color: #1E9FFF; }
.stat-card .lbl { color: #666; font-size: 13px; margin-top: 4px; }
.panel { background: #fff; border-radius: 8px; padding: 16px; margin-bottom: 16px; box-shadow: 0 1px 6px rgba(0,0,0,.06); }
.panel h3 { font-size: 15px; margin-bottom: 12px; border-left: 3px solid #1E9FFF; padding-left: 8px; }
.bar-row { display: flex; align-items: center; margin-bottom: 8px; }
.bar-row .name { width: 120px; color: #333; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bar-row .bar { flex: 1; background: #eef1f6; border-radius: 4px; height: 16px; margin: 0 8px; overflow: hidden; }
.bar-row .bar > i { display: block; height: 100%; background: linear-gradient(90deg,#1E9FFF,#5FB878); }
.bar-row .val { width: 60px; text-align: right; color: #999; font-size: 12px; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 900px) { .stat-cards { grid-template-columns: repeat(2,1fr); } .grid2 { grid-template-columns: 1fr; } }
.heat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px,1fr)); gap: 8px; }
.heat-cell { border-radius: 6px; padding: 10px 12px; color: #333; min-height: 54px; box-shadow: 0 1px 3px rgba(0,0,0,.06); line-height: 1.5; }
.heat-cell b { font-size: 18px; }
.heat-table { border-collapse: collapse; width: 100%; }
.heat-table th { font-size: 11px; color: #999; font-weight: normal; padding: 2px 1px; }
.heat-table td { height: 16px; border-radius: 2px; }
.hh { width: 14px; }
.dh { text-align: right; padding-right: 6px; white-space: nowrap; }
</style>
</head>
<body>
<div class="stat-cards" id="statCards"></div>
<div class="grid2">
<div class="panel">
<h3>Top 分类(按链接数)</h3>
<div id="topCategories"></div>
</div>
<div class="panel">
<h3>热门点击 Top10</h3>
<table class="layui-table" lay-size="sm">
<thead><tr><th>网站</th><th style="width:90px">点击</th></tr></thead>
<tbody id="topClicks"></tbody>
</table>
</div>
</div>
<div class="panel">
<h3>点击热力图 · 星期×小时 <span id="timeMeta" style="font-weight:normal;color:#999;font-size:12px;"></span></h3>
<div id="timeHeat"></div>
</div>
<div class="panel">
<h3>分类点击热度</h3>
<div id="categoryHeat" class="heat-grid"></div>
</div>
<div class="panel">
<h3>死链概览 <span id="deadMeta" style="font-weight:normal;color:#999;font-size:12px;"></span></h3>
<table class="layui-table" lay-size="sm">
<thead><tr><th>网站</th><th style="width:280px">网址</th><th style="width:90px">状态码</th><th style="width:140px">最近检测</th></tr></thead>
<tbody id="deadList"></tbody>
</table>
</div>
<script src="/assets/layui/layui.js"></script>
<script src="/assets/ywxapp/ywxapp.js" module="backend"></script>
<script>
function esc(s){ return String(s==null?'':s).replace(/[&<>"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];}); }
// 点击密度 -> 颜色(浅米黄=低,深红=高)
function heatColor(ratio){
ratio = Math.max(0, Math.min(1, ratio));
var r = 255, g = Math.round(245 - 200*ratio), b = Math.round(230 - 210*ratio);
return 'rgb('+r+','+g+','+b+')';
}
layui.use(['http'], function () {
var http = layui.http;
http.get('index').then(function (res) {
var d = (res && res.data) || {};
renderCards(d.summary || {});
renderCategories(d.topCategories || []);
renderClicks(d.topClicks || []);
renderDead(d.dead || {});
if (d.heatmap) {
renderTimeHeat(d.heatmap.time || [], d.heatmap.timeRange || '', d.heatmap.timeTotal || 0);
renderCategoryHeat(d.heatmap.category || []);
}
});
});
function renderCards(s){
var cards = [
['链接总数', s.links], ['已上架', s.online], ['待审核', s.pending], ['已禁用', s.disabled],
['分类数', s.categories], ['热门', s.hot], ['推荐', s.recommend], ['总点击', s.clicks]
];
document.getElementById('statCards').innerHTML = cards.map(function(c){
return '<div class="stat-card"><div class="num">'+(c[1]||0)+'</div><div class="lbl">'+c[0]+'</div></div>';
}).join('');
}
function renderCategories(list){
var max = 1; list.forEach(function(c){ if(c.count>max) max=c.count; });
document.getElementById('topCategories').innerHTML = list.map(function(c){
var pct = Math.round(c.count/max*100);
return '<div class="bar-row"><span class="name">'+(c.icon||'')+' '+esc(c.title)+'</span>'+
'<span class="bar"><i style="width:'+pct+'%"></i></span><span class="val">'+c.count+'</span></div>';
}).join('') || '<p style="color:#999">暂无数据</p>';
}
function renderClicks(list){
document.getElementById('topClicks').innerHTML = list.map(function(it){
return '<tr><td><a href="'+esc(it.url)+'" target="_blank">'+esc(it.title)+'</a></td><td>'+(it.click_count||0)+'</td></tr>';
}).join('') || '<tr><td colspan="2" style="color:#999">暂无数据</td></tr>';
}
function renderTimeHeat(heat, meta, total){
var days = ['周日','周一','周二','周三','周四','周五','周六'];
var max = 1;
for (var d = 0; d < 7; d++) { for (var h = 0; h < 24; h++) { var v = (heat[d] && heat[d][h]) || 0; if (v > max) max = v; } }
document.getElementById('timeMeta').textContent = '' + meta + ',共 ' + total + ' 次点击)';
if (!heat.length) { document.getElementById('timeHeat').innerHTML = '<p style="color:#999">暂无点击数据(需先有访客点击跳转)</p>'; return; }
var html = '<table class="heat-table"><thead><tr><th></th>';
for (var h = 0; h < 24; h++) { html += '<th class="hh">' + (h % 24) + '</th>'; }
html += '</tr></thead><tbody>';
for (var d = 0; d < 7; d++) {
html += '<tr><th class="dh">' + days[d] + '</th>';
for (var h = 0; h < 24; h++) {
var v = (heat[d] && heat[d][h]) || 0;
var ratio = v / max;
html += '<td style="background:' + heatColor(ratio) + '" title="' + days[d] + ' ' + h + ':00 点击 ' + v + ' 次"></td>';
}
html += '</tr>';
}
html += '</tbody></table>';
document.getElementById('timeHeat').innerHTML = html;
}
function renderCategoryHeat(list){
if (!list || !list.length) { document.getElementById('categoryHeat').innerHTML = '<p style="color:#999">暂无数据</p>'; return; }
var max = 1; list.forEach(function(c){ if (c.clicks > max) max = c.clicks; });
document.getElementById('categoryHeat').innerHTML = list.map(function(c){
var ratio = c.clicks / max;
var txt = ratio > 0.55 ? '#fff' : '#333';
return '<div class="heat-cell" style="background:' + heatColor(ratio) + ';color:' + txt + '" title="' + esc(c.title) + '' + (c.clicks || 0) + ' 次">' +
(c.icon ? c.icon + ' ' : '') + esc(c.title) + '<br><b>' + (c.clicks || 0) + '</b></div>';
}).join('');
}
function renderDead(d){
document.getElementById('deadMeta').textContent = d.last_check_at ? ('(共 '+ (d.count||0) +' 条,最近检测 '+d.last_check_at+'') : '(尚未执行死链检测)';
var list = d.list || [];
document.getElementById('deadList').innerHTML = list.map(function(it){
var t = it.last_check_at ? new Date(it.last_check_at*1000).toLocaleString() : '-';
return '<tr><td>'+esc(it.title)+'</td><td style="word-break:break-all">'+esc(it.url)+'</td><td>'+(it.status_code===0?'无法访问':it.status_code)+'</td><td>'+t+'</td></tr>';
}).join('') || '<tr><td colspan="4" style="color:#999">无死链或尚未检测</td></tr>';
}
</script>
</body>
</html>
+508
View File
@@ -0,0 +1,508 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>{$title | default='网址管理'}</title>
<meta name="renderer" content="webkit">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link href="/assets/layui/css/layui.css" rel="stylesheet">
<link href="/assets/ywxapp/css/ywxapp.css" rel="stylesheet">
<script type="text/javascript">
var config = { root: "/" };
</script>
</head>
<body>
<div class="layui-card">
<div class="layui-form layui-card-header layuiadmin-card-header-auto">
<div class="layui-form-item">
<div class="layui-inline">
<input type="text" name="title" placeholder="请输入标题" autocomplete="off" class="layui-input">
</div>
<div class="layui-inline">
<input type="text" name="url" placeholder="请输入URL" autocomplete="off" class="layui-input">
</div>
<div class="layui-inline">
<input type="text" name="keywords" placeholder="请输入关键词" autocomplete="off" class="layui-input">
</div>
<div class="layui-inline">
<input type="text" name="description" placeholder="请输入描述" autocomplete="off" class="layui-input">
</div>
<div class="layui-inline">
<select name="status" lay-filter="statusFilter">
<option value="">全部状态</option>
<option value="1">启用</option>
<option value="0">禁用</option>
<option value="2">待审核</option>
<option value="dead">死链(检测异常)</option>
</select>
</div>
<div class="layui-inline">
<button class="layui-btn layuiadmin-btn-useradmin" lay-submit lay-filter="tableSearchButton">
<i class="layui-icon layui-icon-search layuiadmin-button-btn"></i>
</button>
</div>
</div>
</div>
<div class="layui-card-body">
<table class="layui-hide" id="dataTable" lay-filter="dataTable"></table>
</div>
</div>
<!-- 顶部工具栏 -->
<script type="text/html" id="tableBar">
<button class="layui-btn layui-btn-sm" lay-event="checkLinks">检测死链</button>
<button class="layui-btn layui-btn-sm" lay-event="refreshFavicon">刷新图标</button>
<button class="layui-btn layui-btn-sm" lay-event="import">导入书签</button>
<button class="layui-btn layui-btn-sm" lay-event="export">导出书签</button>
<button class="layui-btn layui-btn-sm layui-btn-normal" lay-event="batchEnable">批量启用</button>
<button class="layui-btn layui-btn-sm layui-btn-warm" lay-event="batchDisable">批量禁用</button>
<button class="layui-btn layui-btn-sm" lay-event="recoverdead">恢复死链</button>
<button class="layui-btn layui-btn-sm layui-btn-primary" lay-event="batchMove">批量移动分类</button>
</script>
<script type="text/html" id="dataBar">
<div class="layui-btn-group">
<a class="layui-btn layui-btn-sm layui-btn-primary" title="编辑" lay-event="update"><i class="layui-icon layui-icon-edit"></i></a>
{{# if(!d.is_super) { }}
<a class="layui-btn layui-btn-sm layui-btn-primary" title="删除" lay-event="delete"><i class="layui-icon layui-icon-delete"></i></a>
{{# } }}
<a class="layui-btn layui-btn-sm layui-btn-normal" title="通过" lay-event="approve" {{# if(d.status != 2){ }}style="display:none"{{# } }}><i class="layui-icon layui-icon-ok"></i></a>
<a class="layui-btn layui-btn-sm layui-btn-danger" title="拒绝" lay-event="reject" {{# if(d.status != 2){ }}style="display:none"{{# } }}><i class="layui-icon layui-icon-close"></i></a>
</div>
</script>
<script type="text/html" id="dataRecybinTpl">
<div class="layui-card">
<div class="layui-card-header"></div>
<div class="layui-card-body">
<table class="layui-hide" id="dataRecybinTable" lay-filter="dataRecybinTable"></table>
</div>
</div>
</script>
<script type="text/html" id="dataRecybinBarTpl">
<div class="layui-btn-group">
<a class="layui-btn layui-btn-sm layui-btn-primary" title="恢复数据" lay-event="update"><i class="layui-icon layui-icon-edit"></i></a>
<a class="layui-btn layui-btn-sm layui-btn-primary" title="删除节点" lay-event="delete"><i class="layui-icon layui-icon-delete"></i></a>
</div>
</script>
<script type="text/html" id="statusTpl">
<input type="checkbox" name="status" value="{{d.id}}" lay-skin="switch" lay-text="启用|禁用" lay-filter="statusSwitch" {{ d.status == 1 ? 'checked' : '' }}>
</script>
<script type="text/html" id="hotspotTpl">
<input type="checkbox" name="is_hot" value="{{d.id}}" lay-skin="switch" lay-text="启用|禁用" lay-filter="hotspotSwitch" {{ d.is_hot == 1 ? 'checked' : '' }}>
</script>
<!-- 导入弹层 -->
<script type="text/html" id="importTpl">
<form class="layui-form" style="padding:20px;">
<div class="layui-form-item">
<label class="layui-form-label">目标分类</label>
<div class="layui-input-block">
<select name="cid" id="importCid">
<option value="">默认(首个分类)</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">书签文件</label>
<div class="layui-input-block">
<input type="file" name="file" id="importFile" accept=".html,.htm,.json" class="layui-input">
<div style="color:#999;font-size:12px;margin-top:6px;">支持浏览器导出的 Netscape 书签 HTML 或 JSON 数组</div>
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button type="button" class="layui-btn" id="doImport">开始导入</button>
</div>
</div>
</form>
</script>
<!-- 添加/编辑表单 -->
<script type="text/html" id="dataFormTpl">
<form class="layui-form" style="padding: 20px 20px 0;">
<input type="hidden" name="id" value="{{d.id||''}}">
<div class="layui-form-item">
<label class="layui-form-label">标题</label>
<div class="layui-input-block">
<input type="text" name="title" required lay-verify="required|account" placeholder="请输入标题" autocomplete="off" class="layui-input" value="{{d.title||''}}">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">分类</label>
<div class="layui-input-block">
<select name="cid" lay-verify="required">
<option value="">请选择分类</option>
{{# d.cates.forEach(function(item) { }}
<option value="{{item.id}}" {{ d.cid == item.id ? 'selected' : '' }}>{{item.title}}</option>
{{# }); }}
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">链接</label>
<div class="layui-input-block">
<input type="text" name="url" lay-verify="url" placeholder="请输入链接" autocomplete="off" class="layui-input" value="{{d.url||''}}">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">返利PID</label>
<div class="layui-input-block">
<input type="text" name="pid" placeholder="选填,如淘宝/京东联盟推广位PID,点击跳转自动拼接返利参数" autocomplete="off" class="layui-input" value="{{d.pid||''}}">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">图标</label>
<div class="layui-input-block">
<input type="text" name="icon" placeholder="请输入图标链接" autocomplete="off" class="layui-input" value="{{d.icon||''}}">
</div>
</div>
<div class="layui-form-item layui-form-text">
<label class="layui-form-label">关键词</label>
<div class="layui-input-block">
<textarea name="keywords" placeholder="请输入关键词" class="layui-textarea">{{d.keywords||''}}</textarea>
</div>
</div>
<div class="layui-form-item layui-form-text">
<label class="layui-form-label">描述</label>
<div class="layui-input-block">
<textarea name="description" placeholder="请输入描述信息" class="layui-textarea">{{d.description||''}}</textarea>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">状态</label>
<div class="layui-input-block">
<input type="radio" name="status" value="1" title="启用" {{ d.status==undefined || d.status==1 ? 'checked' : '' }}>
<input type="radio" name="status" value="0" title="禁用" {{ d.status==0 ? 'checked' : '' }}>
<input type="radio" name="status" value="2" title="待审核" {{ d.status==2 ? 'checked' : '' }}>
</div>
</div>
<div class="layui-form-item layui-hide">
<button class="layui-btn" lay-submit="" lay-filter="wxapp-form-submit" id="wxapp-form-submit">提交</button>
</div>
</form>
</script>
<script src="/assets/layui/layui.js"></script>
<script src="/assets/ywxapp/ywxapp.js" module="backend"></script>
<script>
layui.use(['http', 'util'], function () {
var $ = layui.$, table = layui.table, form = layui.form, laytpl = layui.laytpl, http = layui.http, layer = layui.layer, util = layui.util;
var cates = [];
http.get('/haonav/backend/links/create').then((res) => {
if (res.code === 0) { cates = res.data || []; } else { layer.msg(res.msg || '数据加载失败'); }
});
var dataTable = table.render({
elem: '#dataTable',
defaultToolbar: [{
title: '创建资源', layEvent: 'dataCreate', icon: 'layui-icon-add-1'
}, {
title: '删除资源', layEvent: 'dataDelete', icon: 'layui-icon-delete'
}, {
title: '资源回收站', layEvent: 'dataRecybin', icon: 'layui-icon-home'
}, 'filter', 'exports', 'print'
]
, url: "/haonav/backend/links/index"
, page: true,
limit: 20,
limits: [10, 15, 20, 30],
parseData: function (res) {
return { "code": res.code, "msg": res.message || '', "count": res.count || 0, "data": res.data || [] };
},
toolbar: '#tableBar'
, cols: [[
{ type: 'checkbox', fixed: 'left' }
, { field: 'id', title: 'ID', width: 72, sort: true, fixed: 'left' }
, { field: 'title', title: '名称', width: 130, fixed: 'left' }
, { field: 'cate', title: '分类', width: 100, sort: true }
, { field: 'url', title: '链接', minWidth: 160, templet: function (d) { return '<a href="' + (d.url || '') + '" target="_blank" style="color:#01AAED;">' + (d.url || '') + '</a>'; } }
, { field: 'pid', title: '返利PID', width: 130, align: 'center', templet: function (d) { return d.pid ? '<span style="color:#e67e22;">' + d.pid + '</span>' : '-'; } }
, { field: 'icon', title: '图标', width: 90, align: 'center', templet: function (d) { return d.icon ? '<img src="' + d.icon + '" style="width:24px;height:24px;">' : '-'; } }
, { field: 'status_code', title: '检测码', width: 90, align: 'center', templet: function (d) { return d.status_code === null || d.status_code === '' ? '-' : (d.status_code >= 200 && d.status_code < 400 ? '<span style="color:#2ecc71">' + d.status_code + '</span>' : '<span style="color:#e74c3c">' + d.status_code + '</span>'); } }
, { field: 'fail_count', title: '连败', width: 70, align: 'center', templet: function (d) { return (d.fail_count && d.fail_count > 0) ? '<span style="color:#e67e22;">' + d.fail_count + '</span>' : '0'; } }
, { field: 'dead_at', title: '死链时间', width: 115, align: 'center', templet: function (d) { return (d.dead_at && d.dead_at > 0) ? '<span style="color:#e74c3c;">' + util.toDateString(d.dead_at * 1000, 'yyyy-MM-dd') + '</span>' : '-'; } }
, { field: 'keywords', title: '关键词', width: 120, align: 'center' }
, { field: 'description', title: '描述', minWidth: 160 }
, { field: 'status', title: '状态', width: 90, align: 'center', templet: '#statusTpl', unresize: true }
, { field: 'is_hot', title: '热点', width: 90, align: 'center', templet: '#hotspotTpl', unresize: true }
, { field: 'click_count', title: '点击', width: 90, sort: true }
, { field: 'sort', title: '排序', width: 80, sort: true, edit: 'text' }
, { field: 'create_at', title: '创建时间', width: 160, align: 'center', sort: true }
, { title: '操作', width: 150, align: 'left', toolbar: '#dataBar', fixed: 'right', unresize: true }
]],
done: function () { }
});
table.on('toolbar(dataTable)', function (obj) {
var options = obj.config;
switch (obj.event) {
case 'dataCreate': active.dataCreate({ pid: 0, type: 1 }); break;
case 'dataDelete':
var cs = table.checkStatus('dataTable'), cd = cs.data;
active.dataDelete(cd.map((i) => i.id));
break;
case 'dataRecybin': active.dataRecybin(); break;
case 'checkLinks':
layer.confirm('将逐一检测全部链接可达性(可能耗时较长),确定开始?', function (index) {
layer.close(index);
var load = layer.load(2);
http.post('checkLinks', {}).then(function (res) {
layer.close(load);
if (res.code == 0) {
var msg = '检测完成:共 ' + res.data.total + ' 条,正常 ' + res.data.ok + ' 条,异常 ' + res.data.dead + ' 条';
if (res.data.offlined) { msg += ',已自动下线 ' + res.data.offlined + ' 条'; }
if (res.data.recovered) { msg += ',已自动恢复 ' + res.data.recovered + ' 条'; }
layer.alert(msg);
table.reload('dataTable', {}, true);
} else { layer.msg(res.msg || '检测失败'); }
}).catch(function () { layer.close(load); layer.msg('请求失败'); });
});
break;
case 'refreshFavicon':
var cs = table.checkStatus('dataTable'), cd = cs.data;
if (cd.length === 0) { return layer.msg('请先勾选要刷新的链接'); }
http.post('refreshFavicon', { ids: cd.map((i) => i.id).join(',') }).then(function (res) {
if (res.code == 0) { layer.msg(res.msg || '已刷新'); table.reload('dataTable', {}, true); }
else { layer.msg(res.msg || '失败'); }
});
break;
case 'import': active.dataImport(); break;
case 'export': window.open('export'); break;
case 'batchEnable': active.batchOp('enable', '确定批量启用选中链接?'); break;
case 'batchDisable': active.batchOp('disable', '确定批量禁用选中链接?'); break;
case 'batchMove': active.batchMove(); break;
case 'recoverdead': active.batchOp('recoverdead', '确定恢复选中死链(重新启用并清空失败计数)?'); break;
};
});
// 排序列行内编辑:失焦即保存
table.on('edit(dataTable)', function (obj) {
if (obj.field === 'sort') {
http.post('batch', { op: 'sort', ids: String(obj.data.id), sort: obj.value }).then(function (res) {
if (res.code === 0) { layer.msg(res.message || '排序已保存'); }
else { layer.msg(res.message || res.msg || '保存失败'); table.reload('dataTable', {}, true); }
});
}
});
table.on('tool(dataTable)', function (elem) {
var data = elem.data;
switch (elem.event) {
case 'update': active.dataEdit(data); break;
case 'create': active.dataCreate({ pid: data.id }); break;
case 'delete': active.dataDelete([data.id]); break;
case 'approve':
http.post('approve', { id: data.id }).then(function (res) {
if (res.code == 0) { layer.msg('已通过'); table.reload('dataTable', {}, true); }
else { layer.msg(res.msg || '操作失败'); }
});
break;
case 'reject':
layer.confirm('拒绝后该投稿将被禁用,确定?', function (index) {
layer.close(index);
http.post('reject', { id: data.id }).then(function (res) {
if (res.code == 0) { layer.msg('已拒绝'); table.reload('dataTable', {}, true); }
else { layer.msg(res.msg || '操作失败'); }
});
});
break;
default: break;
}
});
form.on('submit(tableSearchButton)', function (data) {
table.reload('dataTable', { where: data.field });
});
form.on('switch(statusSwitch)', function (obj) {
var id = this.value, status = obj.elem.checked ? 1 : 0;
layer.confirm('确定要' + (status ? '启用' : '禁用') + '该数据吗?', function (index) {
layui.http.put('update', { id: id, status: status }).then(function (res) {
if (res.code == 0) { layer.msg(res.msg, { icon: 1 }); table.reload('dataTable', {}, true); }
else { layer.msg(res.msg, { icon: 2 }); obj.elem.checked = !obj.elem.checked; form.render('checkbox'); }
});
layer.close(index);
}, function () {
obj.elem.checked = !obj.elem.checked; form.render('checkbox');
});
});
form.on('switch(hotspotSwitch)', function (obj) {
var id = this.value, is_hot = obj.elem.checked ? 1 : 0;
layer.confirm('确定要' + (is_hot ? '推荐' : '不推荐') + '该数据吗?', function (index) {
layui.http.put('update', { id: id, is_hot: is_hot }).then(function (res) {
if (res.code == 0) { layer.msg(res.msg, { icon: 1 }); table.reload('dataTable', {}, true); }
else { layer.msg(res.msg, { icon: 2 }); obj.elem.checked = !obj.elem.checked; form.render('checkbox'); }
});
layer.close(index);
}, function () {
obj.elem.checked = !obj.elem.checked; form.render('checkbox');
});
});
var dataFromFun = function (data, callback, done) {
var formHtml = laytpl($('#dataFormTpl').html()).render(data || {});
layer.open({
title: data.id ? '编辑数据' : '添加数据',
content: formHtml, anim: "slideLeft", offset: "r",
btnAlign: "l", area: [window.innerWidth > 1200 ? '30%' : (window.innerWidth > 768 ? '50%' : '99%'), '99%'],
shade: 0.1, shadeClose: true,
btn: ['确定', '取消'],
success: function (layero, index) { callback(layero, index); form.render(); },
yes: function (index, layero) {
window.layui.form.on('submit(wxapp-form-submit)', function (elem) {
done(layero, index, elem); layui.off('submit(wxapp-form)', 'from'); return false;
});
layero.contents().find("#wxapp-form-submit").trigger('click');
}
});
};
var active = {
dataCreate: function (data = {}) {
data.cates = cates;
dataFromFun(data, function () { }, function (layero, index, elem) {
var field = elem.field; field.status = field.status ? 1 : 0;
http.post('save', field).then((res) => {
if (res.code === 0) { layer.close(index); table.reload('dataTable', {}, true); }
else { layer.msg(res.msg || '操作失败'); }
});
});
},
dataEdit: function (data = {}) {
data.cates = cates;
dataFromFun(data, function () { }, function (layero, index, elem) {
var field = elem.field; field.status = field.status ? 1 : 0;
http.put('update', field).then((res) => {
if (res.code === 0) { layer.close(index); table.reload('dataTable', {}, true); }
else { layer.msg(res.msg || '操作失败'); }
});
});
},
dataImport: function () {
var html = laytpl($('#importTpl').html()).render({});
layer.open({
title: '导入书签', content: html, area: ['480px', '320px'],
success: function (layero) {
var sel = layero.find('#importCid');
cates.forEach(function (c) { sel.append('<option value="' + c.id + '">' + c.title + '</option>'); });
form.render('select');
layero.find('#doImport').on('click', function () {
var fileInput = layero.find('#importFile')[0];
if (!fileInput.files.length) { return layer.msg('请选择书签文件'); }
var fd = new FormData();
fd.append('file', fileInput.files[0]);
fd.append('cid', layero.find('#importCid').val());
layer.msg('导入中...', { icon: 16, time: 0 });
fetch('import', { method: 'POST', body: fd }).then(r => r.json()).then(function (res) {
layer.closeAll();
if (res.code == 0) { layer.msg(res.msg || '导入成功'); table.reload('dataTable', {}, true); }
else { layer.msg(res.msg || '导入失败'); }
}).catch(function () { layer.closeAll(); layer.msg('导入失败'); });
});
}
});
},
dataDelete: function (ids, force = 0) {
if (ids.length === 0) { return layer.msg('请选择数据'); }
layer.prompt({ formType: 1, title: '敏感操作,请验证口令' }, function (value, index) {
layer.close(index);
layer.confirm('确定删除吗?', function (index) {
http.delete('delete', { ids: ids.join(','), force: force }).then((res) => {
if (res.code === 0) { layer.close(index); table.reload('dataTable', {}, true); }
else { layer.msg(res.msg || '操作失败'); }
});
layer.msg('已删除'); table.reload('dataTable', {}, true);
});
});
},
dataRecybin: function (data = {}) {
var formHtml = laytpl($('#dataRecybinTpl').html()).render(data || {});
layer.open({
title: '资源回收站', content: formHtml, anim: "slideLeft", offset: "r",
area: ['60%', '99%'], shade: 0.1, shadeClose: true,
success: function (layero, index) {
layui.table.render({
elem: '#dataRecybinTable', url: 'recyclebin', height: 'full-100',
defaultToolbar: [{
title: '批量删除数据', layEvent: 'dataDelete', icon: 'layui-icon-delete',
onClick: function (obj) {
var cs = table.checkStatus('dataRecybinTable'), cd = cs.data;
active.dataDelete(cd.map((i) => i.id), 1);
}
}, 'filter', 'exports', 'print'],
cols: [[
{ type: 'checkbox', fixed: 'left' },
{ field: 'id', title: 'ID', width: 80, sort: true, fixed: 'left' },
{ field: 'title', title: 'title', width: 180, fixed: 'left' },
{
fixed: "right", title: "操作", width: 120, align: "center", templet: function (d) {
return `<div class="layui-btn-group">
<a class="layui-btn layui-btn-sm" title="恢复数据" lay-event="restore" > <i class="layui-icon layui-icon-edit"></i> </a >
<a class="layui-btn layui-btn-sm" title="删除数据" lay-event="delete"><i class="layui-icon layui-icon-delete"></i> </a>
</div >`;
}
}
]],
page: true,
done: function () {
layui.table.on('tool(dataRecybinTable)', function (elem) {
var data = elem.data;
if (elem.event === 'restore') { active.dataRestore([data.id]); }
else if (elem.event === 'delete') { active.dataDelete([data.id], 1); }
});
}
});
}
});
},
batchOp: function (op, confirmText, extra) {
var cd = table.checkStatus('dataTable').data;
if (!cd.length) { return layer.msg('请先勾选要操作的链接'); }
var ids = cd.map(function (i) { return i.id; }).join(',');
layer.confirm(confirmText || '确定执行批量操作?', function (index) {
layer.close(index);
var params = Object.assign({ op: op, ids: ids }, extra || {});
http.post('batch', params).then(function (res) {
if (res.code === 0) { layer.msg(res.message || '操作成功'); table.reload('dataTable', {}, true); }
else { layer.msg(res.message || res.msg || '操作失败'); }
});
});
},
batchMove: function () {
var cd = table.checkStatus('dataTable').data;
if (!cd.length) { return layer.msg('请先勾选要移动的链接'); }
var opts = cates.map(function (c) { return '<option value="' + c.id + '">' + c.title + '</option>'; }).join('');
layer.open({
title: '批量移动分类(已选 ' + cd.length + ' 条)',
area: ['420px', '220px'],
content: '<div class="layui-form" style="padding:24px;"><div class="layui-form-item"><label class="layui-form-label">目标分类</label><div class="layui-input-block"><select id="batchMoveCid" lay-filter="batchMoveCid"><option value="">请选择分类</option>' + opts + '</select></div></div></div>',
btn: ['确定移动', '取消'],
success: function () { form.render('select'); },
yes: function (index) {
var cid = $('#batchMoveCid').val();
if (!cid) { return layer.msg('请选择目标分类'); }
var ids = cd.map(function (i) { return i.id; }).join(',');
http.post('batch', { op: 'move', ids: ids, cid: cid }).then(function (res) {
if (res.code === 0) { layer.close(index); layer.msg(res.message || '已移动'); table.reload('dataTable', {}, true); }
else { layer.msg(res.message || res.msg || '移动失败'); }
});
}
});
},
dataRestore: function (ids) {
if (ids.length === 0) { return layer.msg('请选择数据'); }
http.put('restore', { ids: ids.join(',') }).then((res) => {
if (res.code === 0) { layer.msg('恢复成功'); table.reload('dataTable', {}, true); }
else { layer.msg(res.msg || '恢复失败'); }
});
}
};
});
</script>
</body>
</html>
@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{$shareTitle|default='收藏夹分享'} - {$seo.title|default='网址导航'}</title>
<meta name="keywords" content="{$seo.keywords|default=''}">
<meta name="description" content="{$seo.description|default=''}">
<meta name="robots" content="noindex,nofollow">
<link rel="stylesheet" href="/static/haonav/css/nav.css">
<style>
.share-wrap { max-width: 960px; margin: 30px auto; padding: 0 16px; }
.share-head { background: var(--nav-card); color: var(--nav-card-text); border-radius: 18px; padding: 30px; text-align: center; box-shadow: 0 8px 30px rgba(0,0,0,.15); }
.share-head h1 { font-size: 26px; margin: 0 0 10px; }
.share-head .meta { color: var(--nav-muted); font-size: 13px; }
.share-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 14px; margin-top: 22px; }
.share-card { display: flex; align-items: center; gap: 10px; background: var(--nav-card); color: var(--nav-card-text); border-radius: 12px; padding: 14px; text-decoration: none; box-shadow: 0 4px 14px rgba(0,0,0,.08); transition: transform .15s, box-shadow .15s; }
.share-card:hover { transform: translateY(-3px); box-shadow: 0 8px 22px rgba(0,0,0,.16); }
.share-card img { width: 34px; height: 34px; border-radius: 8px; object-fit: cover; background: var(--nav-tag-bg); flex-shrink: 0; }
.share-card .t { font-size: 14px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.share-empty, .share-invalid { background: var(--nav-card); color: var(--nav-card-text); border-radius: 18px; padding: 60px 30px; text-align: center; box-shadow: 0 8px 30px rgba(0,0,0,.15); margin-top: 22px; }
.share-invalid .big { font-size: 48px; margin-bottom: 14px; }
.share-actions { text-align: center; margin-top: 24px; }
.btn { padding: 12px 28px; border: none; border-radius: 22px; font-size: 15px; font-weight: 600; cursor: pointer; text-decoration: none; display: inline-block; }
.btn-primary { background: var(--nav-primary); color: #fff; }
.btn-primary:hover { filter: brightness(1.08); }
.btn-secondary { background: transparent; color: var(--nav-primary); border: 2px solid var(--nav-primary); margin-left: 10px; }
#cloneTip { font-size: 13px; color: var(--nav-muted); margin-top: 12px; }
</style>
</head>
<body>
<div class="navbar">
<div class="navbar-content">
<a href="/haonav/index.html" class="logo">🚀 网址导航</a>
<div class="nav-actions">
<div class="nav-links"><a href="/haonav/index.html">首页</a><a href="/haonav/rank.html">排行榜</a><a href="/haonav/submit.html">投稿</a></div>
<button class="icon-btn" id="themeToggle" title="切换暗黑模式">🌙</button>
</div>
</div>
</div>
<div class="share-wrap">
{if $valid}
<div class="share-head">
<h1>⭐ {$shareTitle}</h1>
<p class="meta">好友分享的收藏夹 · 共 {$count} 个网站</p>
</div>
{if $count > 0}
<div class="share-grid" id="shareGrid">
{volist name="list" id="it"}
<a class="share-card" href="{$it.url}" target="_blank" rel="noopener nofollow"
data-title="{$it.title}" data-url="{$it.url}" data-icon="{$it.icon}">
<img src="{$it.icon|default='/static/haonav/img/default.png'}" alt="" onerror="this.src='/static/haonav/img/default.png'">
<span class="t">{$it.title}</span>
</a>
{/volist}
</div>
<div class="share-actions">
<button class="btn btn-primary" id="cloneBtn">📥 一键收藏到我的导航</button>
<a class="btn btn-secondary" href="/haonav/index.html">去我的导航</a>
<p id="cloneTip"></p>
</div>
{else/}
<div class="share-empty">这个收藏夹还是空的~</div>
{/if}
{else/}
<div class="share-invalid">
<div class="big">🔒</div>
<h2>{$shareTitle}</h2>
<p style="color:var(--nav-muted);margin-top:10px;">链接可能已被关闭分享或不存在。</p>
<div class="share-actions"><a class="btn btn-primary" href="/haonav/index.html">返回首页</a></div>
</div>
{/if}
</div>
<script src="/static/haonav/js/nav.js"></script>
<script>
// 一键把分享的收藏夹合并到本机「我的导航」(localStorage),回首页后 nav.js 会自动上云
(function () {
var btn = document.getElementById('cloneBtn');
if (!btn) { return; }
btn.addEventListener('click', function () {
var FAV_KEY = 'haonav_fav';
var cur = [];
try { cur = JSON.parse(localStorage.getItem(FAV_KEY)) || []; } catch (e) { cur = []; }
var seen = {};
cur.forEach(function (x) { seen[x.url] = 1; });
var added = 0;
Array.prototype.forEach.call(document.querySelectorAll('.share-card'), function (el) {
var url = el.getAttribute('data-url');
if (!url || seen[url]) { return; }
seen[url] = 1;
cur.push({ id: 0, title: el.getAttribute('data-title'), url: url, icon: el.getAttribute('data-icon') });
added++;
});
localStorage.setItem(FAV_KEY, JSON.stringify(cur));
var tip = document.getElementById('cloneTip');
tip.textContent = added > 0 ? ('已添加 ' + added + ' 个到本机,去首页即可同步~') : '这些网站都已在你的收藏中';
});
})();
</script>
</body>
</html>
+127
View File
@@ -0,0 +1,127 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>申请收录 / 广告合作 - 网址导航</title>
<link rel="stylesheet" href="/static/haonav/css/nav.css">
<style>
.apply-tabs{display:flex;gap:10px;margin-bottom:18px;}
.apply-tabs button{flex:1;padding:10px;border:1px solid var(--nav-border,#e5e5e5);background:var(--nav-card-bg,#fff);color:var(--nav-card-text,#333);border-radius:10px;cursor:pointer;font-size:15px;}
.apply-tabs button.active{background:#667eea;color:#fff;border-color:#667eea;}
.hp-field{position:absolute;left:-9999px;opacity:0;height:0;overflow:hidden;}
</style>
</head>
<body>
<div class="navbar">
<div class="navbar-content">
<a href="/haonav/index.html" class="logo">🚀 网址导航</a>
<form class="search-box" id="searchForm">
<select id="searchEngine">
<option value="baidu" {$config.default_engine == 'baidu' ? 'selected' : ''}>百度</option>
<option value="bing" {$config.default_engine == 'bing' ? 'selected' : ''}>必应</option>
<option value="google" {$config.default_engine == 'google' ? 'selected' : ''}>Google</option>
<option value="sogou" {$config.default_engine == 'sogou' ? 'selected' : ''}>搜狗</option>
<option value="site" {$config.default_engine == 'site' ? 'selected' : ''}>站内</option>
</select>
<input type="text" id="searchInput" autocomplete="off" placeholder="搜索网站 / 关键词">
<button type="submit">🔍 搜索</button>
</form>
<div class="nav-actions">
<div class="nav-links">
<a href="/haonav/index.html">首页</a>
<a href="/haonav/rank.html">排行榜</a>
<a href="/haonav/submit.html">投稿</a>
<a href="/haonav/apply.html" class="active">合作申请</a>
</div>
<button class="icon-btn" id="themeToggle" title="切换暗黑模式">🌙</button>
<div class="user-area" id="userArea">
<button class="nav-btn" id="btnLogin">登录</button>
<button class="nav-btn nav-btn-primary" id="btnRegister">注册</button>
</div>
</div>
</div>
</div>
<div class="main-content">
<div class="submit-box">
<h2>🤝 申请收录 / 广告合作</h2>
<div class="apply-tabs">
<button type="button" class="active" data-type="1">友链 / 收录申请</button>
<button type="button" data-type="2">广告合作</button>
</div>
<form id="applyForm">
<input type="hidden" name="type" value="1">
<!-- 蜜罐字段:请勿填写 -->
<div class="hp-field" aria-hidden="true">
<input type="text" name="website" tabindex="-1" autocomplete="off">
</div>
<div class="form-row">
<label>网站 / 品牌名称 *</label>
<input type="text" name="title" required maxlength="100" placeholder="例如:某某工具站">
</div>
<div class="form-row">
<label>网址 *</label>
<input type="url" name="url" required placeholder="https://...">
</div>
<div class="form-row" id="slotRow" style="display:none;">
<label>意向广告位</label>
<select name="slot">
<option value="">不确定 / 由站长推荐</option>
{foreach $slots as $sk=>$sv}<option value="{$sk}">{$sv}</option>{/foreach}
</select>
</div>
<div class="form-row">
<label>说明</label>
<textarea name="description" maxlength="500" placeholder="友链:一句话介绍您的网站;广告:投放需求、预算与周期等"></textarea>
</div>
<div class="form-row">
<label>联系方式 *</label>
<input type="text" name="contact" required maxlength="100" placeholder="邮箱 / QQ / 微信,审核结果将通过它联系您">
</div>
<button type="submit" class="btn-primary">提交申请</button>
<div class="form-msg" id="applyMsg"></div>
</form>
</div>
</div>
<div class="footer">
<p>© 2026 网址导航 - 让上网更简单</p>
</div>
<script src="/static/haonav/js/nav.js"></script>
<script src="/static/haonav/js/auth.js"></script>
<script>
(function () {
var form = document.getElementById('applyForm');
var msg = document.getElementById('applyMsg');
var typeInput = form.querySelector('input[name="type"]');
var slotRow = document.getElementById('slotRow');
document.querySelectorAll('.apply-tabs button').forEach(function (btn) {
btn.addEventListener('click', function () {
document.querySelectorAll('.apply-tabs button').forEach(function (b) { b.classList.remove('active'); });
btn.classList.add('active');
typeInput.value = btn.dataset.type;
slotRow.style.display = btn.dataset.type === '2' ? '' : 'none';
});
});
form.addEventListener('submit', function (e) {
e.preventDefault();
var fd = new FormData(form);
msg.textContent = '提交中...';
fetch('/haonav/apply.html', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: fd
}).then(function (r) { return r.json(); }).then(function (res) {
msg.textContent = res.message || res.msg || '';
if (res.code === 0) { form.reset(); typeInput.value = document.querySelector('.apply-tabs button.active').dataset.type; }
}).catch(function () { msg.textContent = '网络异常,请稍后再试'; });
});
})();
</script>
</body>
</html>
@@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{$info.title|default='网址分类'} - {$seo.title|default='网址导航'}</title>
<meta name="keywords" content="{$info.keywords | default='网址导航,分类'}">
<meta name="description" content="{$info.description | default='网址导航分类'}">
{notempty name="info"}<link rel="canonical" href="{$siteUrl|default=''}/haonav/category/{$info.id}.html">{/notempty}
<link rel="stylesheet" href="/static/haonav/css/nav.css">
<style>
.website-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; }
.website-item {
padding: 12px 15px; background: var(--nav-card); border-radius: 10px; display: flex; align-items: center; gap: 12px;
cursor: pointer; transition: all .3s; text-decoration: none; color: var(--nav-card-text); position: relative;
box-shadow: 0 2px 10px rgba(0,0,0,.08);
}
.website-item:hover { background: var(--nav-primary); color: #fff; transform: translateY(-2px); }
.website-item:hover .website-item-info h3, .website-item:hover .website-item-info p { color: #fff; }
.website-item-icon { width: 44px; height: 44px; border-radius: 8px; display: flex; align-items: center; justify-content: center; background: var(--nav-tag-bg); font-size: 16px; overflow: hidden; flex-shrink: 0; }
.website-item-icon img { width: 100%; height: 100%; object-fit: cover; }
.website-item-info { min-width: 0; }
.website-item-info h3 { font-size: 15px; margin-bottom: 3px; transition: color .3s; }
.website-item-info p { font-size: 12px; color: var(--nav-muted); transition: color .3s; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.website-item .qr-btn { position: absolute; right: 10px; top: 8px; opacity: 0; cursor: pointer; font-size: 13px; color: var(--nav-muted); }
.website-item:hover .qr-btn { opacity: 1; color: #fff; }
</style>
</head>
<body>
<div class="navbar">
<div class="navbar-content">
<a href="/haonav/index.html" class="logo">🚀 网址导航</a>
<form class="search-box" id="searchForm">
<select id="searchEngine">
<option value="baidu">百度</option>
<option value="bing">必应</option>
<option value="google">Google</option>
<option value="sogou">搜狗</option>
<option value="site" selected>站内</option>
</select>
<input type="text" id="searchInput" autocomplete="off" placeholder="搜索网站 / 关键词">
<button type="submit">🔍 搜索</button>
</form>
<div class="nav-actions">
<div class="nav-links">
<a href="/haonav/index.html">首页</a>
<a href="/haonav/rank.html">排行榜</a>
<a href="/haonav/submit.html">投稿</a>
</div>
<button class="icon-btn" id="themeToggle" title="切换暗黑模式">🌙</button>
<div class="user-area" id="userArea">
<button class="nav-btn" id="btnLogin">登录</button>
<button class="nav-btn nav-btn-primary" id="btnRegister">注册</button>
</div>
</div>
</div>
</div>
<div class="main-content">
<div class="section-title">
<h2>📋 {$info.title}</h2>
</div>
<div class="website-list">
{volist name="info.links" id="link"}
<a href="/haonav/site/{$link.id}.html" class="website-item">
<span class="website-item-icon"><img src="{$link.show_icon}" loading="lazy" alt="{$link.title}"></span>
<span class="website-item-info">
<h3>{$link.title}</h3>
<p>{$link.description|default='暂无描述'}</p>
</span>
<span class="qr-btn js-qr" data-url="{$link.url}" title="二维码"></span>
</a>
{/volist}
</div>
<!-- 二级子分类 -->
{volist name="info.children" id="sub"}
<div class="sub-category" style="margin-top:24px;">
<div class="sub-title"><span>{$sub.icon} {$sub.title}</span></div>
<div class="website-list">
{volist name="sub.links" id="link"}
<a href="/haonav/site/{$link.id}.html" class="website-item">
<span class="website-item-icon"><img src="{$link.show_icon}" loading="lazy" alt="{$link.title}"></span>
<span class="website-item-info">
<h3>{$link.title}</h3>
<p>{$link.description|default='暂无描述'}</p>
</span>
<span class="qr-btn js-qr" data-url="{$link.url}" title="二维码"></span>
</a>
{/volist}
</div>
</div>
{/volist}
</div>
<div class="footer">
<p>© 2026 网址导航 - 让上网更简单</p>
</div>
<div class="qr-mask" id="qrMask">
<div class="qr-box">
<img id="qrImg" src="" alt="二维码">
<p id="qrText"></p>
<span class="close">关闭</span>
</div>
</div>
<!-- 登录 / 注册 弹层由全局组件 auth.js 自动注入 -->
<script src="/static/haonav/js/nav.js"></script>
<script src="/static/haonav/js/auth.js"></script>
</body>
</html>
@@ -0,0 +1,143 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{$info.title|default='网址详情'} - {$seo.title|default='网址导航'}</title>
{notempty name="info"}
<meta name="keywords" content="{$info.keywords|default=''}">
<meta name="description" content="{$info.description|default=''}">
<link rel="canonical" href="{$siteUrl|default=''}/haonav/site/{$info.id}.html">
{/notempty}
{notempty name="jsonld"}<script type="application/ld+json">{$jsonld|raw}</script>{/notempty}
<link rel="stylesheet" href="/static/haonav/css/nav.css">
<style>
.detail-container {
background: var(--nav-card); color: var(--nav-card-text); border-radius: 20px; padding: 50px;
max-width: 600px; width: 100%; text-align: center; box-shadow: 0 10px 40px rgba(0,0,0,.3);
animation: fadeIn .5s ease; margin: 30px auto;
}
@keyframes fadeIn { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
.website-icon-large { width: 100px; height: 100px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 24px; background: var(--nav-tag-bg); font-size: 44px; overflow: hidden; box-shadow: 0 5px 20px rgba(102,126,234,.4); }
.website-icon-large img { width: 100%; height: 100%; object-fit: cover; }
.website-name-large { font-size: 30px; font-weight: bold; margin-bottom: 14px; }
.website-desc-large { font-size: 15px; color: var(--nav-muted); margin-bottom: 26px; line-height: 1.6; }
.countdown-box { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 22px; border-radius: 15px; margin: 26px 0; color: #fff; }
.countdown-number { font-size: 56px; font-weight: bold; margin: 0; animation: pulse 1s infinite; }
@keyframes pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.1); } }
.action-buttons { display: flex; gap: 14px; justify-content: center; margin-top: 26px; flex-wrap: wrap; }
.btn { padding: 14px 32px; border: none; border-radius: 25px; font-size: 15px; font-weight: 600; cursor: pointer; text-decoration: none; display: inline-block; }
.btn-primary { background: var(--nav-primary); color: #fff; }
.btn-primary:hover { filter: brightness(1.08); transform: translateY(-2px); }
.btn-secondary { background: transparent; color: var(--nav-primary); border: 2px solid var(--nav-primary); }
.info-text { font-size: 13px; color: var(--nav-muted); margin-top: 18px; line-height: 1.6; }
</style>
</head>
<body>
<div class="navbar">
<div class="navbar-content">
<a href="/haonav/index.html" class="logo">🚀 网址导航</a>
<div class="nav-actions">
<div class="nav-links"><a href="/haonav/index.html">首页</a><a href="/haonav/rank.html">排行榜</a><a href="/haonav/submit.html">投稿</a></div>
<button class="icon-btn" id="themeToggle" title="切换暗黑模式">🌙</button>
<div class="user-area" id="userArea">
<button class="nav-btn" id="btnLogin">登录</button>
<button class="nav-btn nav-btn-primary" id="btnRegister">注册</button>
</div>
</div>
</div>
</div>
<div class="detail-container">
<img src="{$info.show_icon}" class="website-icon-large" id="websiteIcon" alt="{$info.title}">
<h1 class="website-name-large" id="websiteName">{$info.title}</h1>
<div class="rating-box" id="ratingBox" data-id="{$info.id}">
<div class="stars" id="rateStars">
<span class="star" data-score="1"></span>
<span class="star" data-score="2"></span>
<span class="star" data-score="3"></span>
<span class="star" data-score="4"></span>
<span class="star" data-score="5"></span>
</div>
<div class="rating-meta">
<span class="rating-score" id="ratingScore">{$info.rating|default='0.0'}</span>
<span class="rating-count" id="ratingCount">{$info.rating_count|default='0'} 人评分</span>
</div>
</div>
<p class="rating-tip" id="ratingTip">点击星星即可评分(需登录)</p>
<p class="website-desc-large" id="websiteDesc">{$info.description | default='暂无描述'}</p>
<!-- 广告位:详情页内联 -->
{notempty name="ads.detail_inline"}
<div class="ad-box ad-detail-inline">
<span class="ad-label">广告</span>
{volist name="ads.detail_inline" id="ad"}
{if $ad.type == 1}
<a class="ad-item" href="/haonav/ad/click?id={$ad.id}" target="_blank" rel="nofollow">
<img src="{$ad.image}" alt="{$ad.title}" loading="lazy">
</a>
{else/}
{$ad.code|raw}
{/if}
{/volist}
</div>
{/notempty}
<img src="/haonav/snapshot.html?id={$info.id}" alt="网站预览" loading="lazy"
style="width:100%;border-radius:12px;margin:0 0 22px;display:none;box-shadow:0 4px 16px rgba(0,0,0,.15);"
onload="if(this.naturalWidth>120){this.style.display='block';}">
<div class="countdown-box">
<div class="countdown-title">⏳ 正在为您跳转到目标网站</div>
<p class="countdown-number" id="countdown">3</p>
</div>
<div class="action-buttons">
<a href="{$info.outbound_url | default='/'}" class="btn btn-primary" id="skipBtn">立即跳转</a>
<a href="index.html" class="btn btn-secondary">返回首页</a>
<button class="btn btn-secondary js-qr" data-url="{$info.url}" style="cursor:pointer;">分享二维码</button>
</div>
<p class="info-text">⚠️ 温馨提示:如果不想等待,可点击「立即跳转」。如果跳转失败,请检查网络或直接访问目标网址。</p>
</div>
<div class="qr-mask" id="qrMask">
<div class="qr-box">
<img id="qrImg" src="" alt="二维码">
<p id="qrText"></p>
<span class="close">关闭</span>
</div>
</div>
<!-- 登录 / 注册 弹层由全局组件 auth.js 自动注入 -->
<script src="/static/haonav/js/nav.js"></script>
<script src="/static/haonav/js/auth.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
var url = document.getElementById('skipBtn').href;
if (!url) { return; }
var countdown = 3;
var el = document.getElementById('countdown');
var timer = setInterval(function () {
countdown--;
el.textContent = countdown;
if (countdown <= 0) {
clearInterval(timer);
window.location.href = url;
}
}, 1000);
document.getElementById('skipBtn').addEventListener('click', function (e) {
e.preventDefault();
clearInterval(timer);
window.location.href = url;
});
});
</script>
</body>
</html>
+277
View File
@@ -0,0 +1,277 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{$seo.title|default='网址导航 - 首页'}</title>
<meta name="keywords" content="{$seo.keywords|default='网址导航,常用网址,上网导航'}">
<meta name="description" content="{$seo.description|default='简洁实用的网址导航,支持分类管理、搜索聚合、我的导航与暗黑模式'}">
<link rel="canonical" href="{$siteUrl|default=''}/haonav/index.html">
{notempty name="jsonld"}<script type="application/ld+json">{$jsonld|raw}</script>{/notempty}
<link rel="stylesheet" href="/static/haonav/css/nav.css">
<link rel="manifest" href="/static/haonav/manifest.json">
<meta name="theme-color" content="#667eea">
<style>
.widget-row{display:flex;gap:16px;margin-bottom:18px;flex-wrap:wrap;}
.weather-widget{background:var(--nav-card-bg,#fff);color:var(--nav-card-text,#333);border-radius:12px;padding:14px 18px;display:flex;align-items:center;gap:12px;box-shadow:0 2px 10px rgba(0,0,0,.06);min-width:220px;flex-wrap:wrap;}
.weather-widget .w-city{font-weight:700;font-size:16px;}
.weather-widget .w-temp{color:#f76b1c;font-weight:700;}
.weather-widget .w-tips{color:var(--nav-muted,#999);font-size:12px;width:100%;}
.hotsearch-widget{flex:1;min-width:280px;background:var(--nav-card-bg,#fff);border-radius:12px;padding:12px 16px;box-shadow:0 2px 10px rgba(0,0,0,.06);}
.hotsearch-widget .hs-head{display:flex;justify-content:space-between;align-items:center;font-weight:700;color:var(--nav-card-text,#333);margin-bottom:8px;}
.hotsearch-widget .hs-tabs a{font-weight:400;font-size:13px;color:var(--nav-muted,#999);cursor:pointer;margin-left:10px;}
.hotsearch-widget .hs-tabs a.active{color:#1E9FFF;font-weight:700;}
.hotsearch-widget .hs-list{list-style:none;margin:0;padding:0;columns:2;column-gap:24px;}
.hotsearch-widget .hs-list li{display:flex;align-items:center;gap:8px;padding:4px 0;font-size:14px;break-inside:avoid;}
.hotsearch-widget .hs-list li .rk{width:18px;text-align:center;color:#bbb;font-weight:700;font-style:normal;}
.hotsearch-widget .hs-list li:nth-child(1) .rk,.hotsearch-widget .hs-list li:nth-child(2) .rk,.hotsearch-widget .hs-list li:nth-child(3) .rk{color:#f76b1c;}
.hotsearch-widget .hs-list li a{color:var(--nav-card-text,#333);text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.hotsearch-widget .hs-list li a:hover{color:#1E9FFF;}
@media (max-width:640px){.hotsearch-widget .hs-list{columns:1;}}
</style>
</head>
<body>
<!-- 顶部导航栏 -->
<div class="navbar">
<div class="navbar-content">
<a href="/haonav/index.html" class="logo">🚀 网址导航</a>
<form class="search-box" id="searchForm">
<select id="searchEngine">
<option value="baidu" {$config.default_engine == 'baidu' ? 'selected' : ''}>百度</option>
<option value="bing" {$config.default_engine == 'bing' ? 'selected' : ''}>必应</option>
<option value="google" {$config.default_engine == 'google' ? 'selected' : ''}>Google</option>
<option value="sogou" {$config.default_engine == 'sogou' ? 'selected' : ''}>搜狗</option>
<option value="site" {$config.default_engine == 'site' ? 'selected' : ''}>站内</option>
</select>
<input type="text" id="searchInput" autocomplete="off" placeholder="搜索网站 / 关键词,或按 / 聚焦">
<button type="submit">🔍 搜索</button>
</form>
<div class="nav-actions">
<div class="nav-links">
<a href="/haonav/index.html" class="active">首页</a>
<a href="/haonav/rank.html">排行榜</a>
<a href="/haonav/submit.html">投稿</a>
<a href="/haonav/search.html">全站搜索</a>
</div>
<button class="icon-btn" id="themeToggle" title="切换暗黑模式">🌙</button>
<button class="icon-btn" id="pwaInstallBtn" title="安装为桌面应用" style="display:none;">📲</button>
<button class="icon-btn" id="setHomeBtn" title="设为首页 / 收藏本站">📌</button>
<select id="bgPicker" title="切换背景" style="border:none;border-radius:20px;padding:6px 8px;background:var(--nav-tag-bg);color:var(--nav-card-text);cursor:pointer;">
<option value="default">默认背景</option>
<option value="linear-gradient(135deg,#11998e 0%,#38ef7d 100%)">清新绿</option>
<option value="linear-gradient(135deg,#2193b0 0%,#6dd5ed 100%)">天空蓝</option>
<option value="linear-gradient(135deg,#ee9ca7 0%,#ffdde1 100%)">樱花粉</option>
<option value="linear-gradient(135deg,#232526 0%,#414345 100%)">暗夜灰</option>
</select>
<!-- 会员区:未登录显示登录/注册,已登录显示头像下拉 -->
<div class="user-area" id="userArea">
<button class="nav-btn" id="btnLogin">登录</button>
<button class="nav-btn nav-btn-primary" id="btnRegister">注册</button>
</div>
</div>
</div>
</div>
<div class="main-content">
<!-- 广告位:首页顶部 -->
{notempty name="ads.home_top"}
<div class="ad-box ad-home-top">
<span class="ad-label">广告</span>
{volist name="ads.home_top" id="ad"}
{if $ad.type == 1}
<a class="ad-item" href="/haonav/ad/click?id={$ad.id}" target="_blank" rel="nofollow">
<img src="{$ad.image}" alt="{$ad.title}" loading="lazy">
</a>
{else/}
{$ad.code|raw}
{/if}
{/volist}
</div>
{/notempty}
<!-- 顶部小组件:天气 + 实时热搜 -->
<div class="widget-row">
<div class="weather-widget" id="weatherWidget" style="display:none;">
<span class="w-city" id="wCity"></span>
<span class="w-type" id="wType"></span>
<span class="w-temp" id="wTemp"></span>
<span class="w-tips" id="wTips"></span>
</div>
<div class="hotsearch-widget" id="hotSearchWidget" style="display:none;">
<div class="hs-head"><span>🔥 实时热搜</span> <span class="hs-tabs" id="hsTabs">
<a data-src="baidu" class="active">百度</a>
<a data-src="weibo">微博</a>
<a data-src="zhihu">知乎</a>
</span></div>
<ol class="hs-list" id="hotSearchList"></ol>
</div>
</div>
<!-- 我的导航(收藏) -->
<div class="mynav">
<div class="mynav-head">
<h3>⭐ 我的导航 <span class="tip" id="myNavTip">(点击卡片上的 ★ 收藏,可拖拽排序)</span></h3>
<button class="nav-btn" id="favShareBtn" title="生成分享链接,把收藏夹分享给好友">🔗 分享收藏夹</button>
</div>
<div class="mynav-list" id="myNavList"></div>
<div class="mynav-empty" id="myNavEmpty">还没有收藏,点击任意网址卡片右上角的 ★ 添加到这里~</div>
</div>
<!-- 热门推荐 -->
<div class="hot-recommend">
<h3>🔥 热门推荐</h3>
<div class="hot-grid">
{volist name='hotspot' id='vo'}
<div class="hot-item">
<a href="/haonav/site/{$vo.id}.html" target="_blank" style="display:flex;align-items:center;gap:10px;text-decoration:none;color:inherit;flex:1;min-width:0;">
<span class="hot-icon"><img src="{$vo.show_icon}" loading="lazy" alt="{$vo.title}"></span>
<span class="hot-info">
<h4>{$vo.title}</h4>
<p>{$vo.description |default='暂无描述'}</p>
{if $vo.rating_count > 0}<span class="website-rating">⭐ {$vo.rating} ({$vo.rating_count})</span>{/if}
</span>
</a>
<span class="qr-btn js-qr" data-url="{$vo.url}" title="二维码"></span>
</div>
{/volist}
</div>
</div>
{volist name="data" id="vo" key="k"}
<!-- 分类区块 -->
<div class="category-block" id="cat-{$k}">
<div class="section-title">
<h2>📋 <a href="/haonav/category/{$vo.id}.html" target="_blank"> {$vo.title} </a></h2>
</div>
<div class="website-grid">
{volist name="vo.links" id="site"}
<div class="website-card" data-sid="{$site.id}">
<span class="fav-btn js-fav" data-id="{$site.id}" data-title="{$site.title}" data-url="{$site.url}" data-icon="{$site.show_icon}" title="收藏"></span>
<span class="qr-btn js-qr" data-url="{$site.url}" title="二维码"></span>
<a href="/haonav/site/{$site.id}.html" target="_blank" title="{$site.title}" style="text-decoration:none;color:inherit;display:block;">
<div class="website-icon"><img src="{$site.show_icon}" loading="lazy" alt="{$site.title}"></div>
<div class="website-name">{$site.title}</div>
{if $site.rating_count > 0}<div class="website-rating">⭐ {$site.rating}<span class="rc">({$site.rating_count})</span></div>{/if}
</a>
</div>
{/volist}
</div>
<!-- 二级子分类 -->
{volist name="vo.children" id="sub"}
<div class="sub-category">
<div class="sub-title"><span>{$sub.icon} {$sub.title}</span></div>
<div class="website-grid">
{volist name="sub.links" id="site"}
<div class="website-card" data-sid="{$site.id}">
<span class="fav-btn js-fav" data-id="{$site.id}" data-title="{$site.title}" data-url="{$site.url}" data-icon="{$site.show_icon}" title="收藏"></span>
<span class="qr-btn js-qr" data-url="{$site.url}" title="二维码"></span>
<a href="/haonav/site/{$site.id}.html" target="_blank" title="{$site.title}" style="text-decoration:none;color:inherit;display:block;">
<div class="website-icon"><img src="{$site.show_icon}" loading="lazy" alt="{$site.title}"></div>
<div class="website-name">{$site.title}</div>
{if $site.rating_count > 0}<div class="website-rating">⭐ {$site.rating}<span class="rc">({$site.rating_count})</span></div>{/if}
</a>
</div>
{/volist}
</div>
</div>
{/volist}
</div>
{/volist}
<!-- 友情链接 / 推荐 -->
<div class="hot-recommend">
<div class="section-title"><h2>🤝 友情链接</h2></div>
<div class="hot-grid">
{volist name='links' id='vo'}
<div class="hot-item">
<a href="{$vo.url}" target="_blank" style="display:flex;align-items:center;gap:10px;text-decoration:none;color:inherit;flex:1;min-width:0;">
<span class="hot-icon"><img src="{$vo.show_icon}" loading="lazy" alt="{$vo.title}"></span>
<span class="hot-info">
<h4>{$vo.title}</h4>
<p>{$vo.description |default='暂无描述'}</p>
{if $vo.rating_count > 0}<span class="website-rating">⭐ {$vo.rating} ({$vo.rating_count})</span>{/if}
</span>
</a>
<span class="qr-btn js-qr" data-url="{$vo.url}" title="二维码"></span>
</div>
{/volist}
</div>
</div>
<!-- 广告位:首页底部 -->
{notempty name="ads.home_bottom"}
<div class="ad-box ad-home-bottom">
<span class="ad-label">广告</span>
{volist name="ads.home_bottom" id="ad"}
{if $ad.type == 1}
<a class="ad-item" href="/haonav/ad/click?id={$ad.id}" target="_blank" rel="nofollow">
<img src="{$ad.image}" alt="{$ad.title}" loading="lazy">
</a>
{else/}
{$ad.code|raw}
{/if}
{/volist}
</div>
{/notempty}
</div>
<!-- 底部 -->
<div class="footer">
<p>© 2026 网址导航 - 让上网更简单 | <a href="/haonav/apply.html">申请收录</a> | <a href="/haonav/apply.html">广告合作</a> | <a href="#" rel="nofollow">关于我们</a></p>
<p><a target="_blank" rel="nofollow" href="http://beian.miit.gov.cn/">{$siteConf.beian |default='蜀ICP备2022005627号-3'}</a></p>
</div>
<!-- 二维码弹层 -->
<div class="qr-mask" id="qrMask">
<div class="qr-box">
<img id="qrImg" src="" alt="二维码">
<p id="qrText"></p>
<span class="close">关闭</span>
</div>
</div>
<!-- 收藏夹分享弹层 -->
<div class="qr-mask" id="favShareMask">
<div class="qr-box" style="max-width:460px;text-align:left;">
<h3 style="margin:0 0 14px;">🔗 分享我的收藏夹</h3>
<label style="display:flex;align-items:center;gap:8px;font-size:14px;margin-bottom:12px;cursor:pointer;">
<input type="checkbox" id="favShareToggle"> 开启分享(关闭后链接立即失效)
</label>
<input type="text" id="favShareTitle" placeholder="分享页标题,如:我的常用网址" maxlength="100"
style="width:100%;padding:9px 12px;border:1px solid var(--nav-border,#ddd);border-radius:8px;font-size:14px;margin-bottom:12px;box-sizing:border-box;">
<div id="favShareLinkBox" style="display:none;">
<div style="display:flex;gap:8px;">
<input type="text" id="favShareLink" readonly
style="flex:1;padding:9px 12px;border:1px solid var(--nav-border,#ddd);border-radius:8px;font-size:13px;box-sizing:border-box;">
<button class="nav-btn nav-btn-primary" id="favShareCopy">复制</button>
</div>
<p style="font-size:12px;color:var(--nav-muted);margin:8px 0 0;">被访问 <b id="favShareViews">0</b> 次 · <a href="#" id="favShareReset">重置链接</a>(旧链接失效)</p>
</div>
<p id="favShareMsg" style="font-size:13px;color:var(--nav-muted);margin:12px 0 0;"></p>
<div style="text-align:right;margin-top:16px;"><span class="close" style="cursor:pointer;color:var(--nav-primary);">关闭</span></div>
</div>
</div>
<!-- 登录 / 注册 弹层由全局组件 auth.js 自动注入,无需在此书写 -->
<!-- 设为首页 / 收藏本站 提示弹层 -->
<div class="qr-mask" id="homeTipMask">
<div class="qr-box" style="max-width:440px;text-align:left;">
<h3 style="margin:0 0 12px;">📌 把导航带在身边</h3>
<p style="margin:0 0 8px;font-size:14px;"><b>收藏本站:</b><kbd>Ctrl</kbd> + <kbd>D</kbd>Mac<kbd></kbd> + <kbd>D</kbd>)加入书签。</p>
<p style="margin:0 0 8px;font-size:14px;"><b>设为浏览器首页:</b></p>
<ol style="margin:0 0 8px;padding-left:20px;font-size:13px;line-height:1.8;color:var(--nav-muted,#666);">
<li>Chrome / Edge:设置 → 启动时 → 打开特定网页,填入本站地址</li>
<li>Firefox:设置 → 主页 → 自定义网址,填入本站地址</li>
</ol>
<p style="margin:0 0 4px;font-size:13px;color:var(--nav-muted,#666);">本站地址:<span id="homeTipUrl" style="user-select:all;"></span></p>
<span class="close">关闭</span>
</div>
</div>
<script src="/static/haonav/js/nav.js"></script>
<script src="/static/haonav/js/auth.js"></script>
</body>
</html>
@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>热门排行榜 - 网址导航</title>
<link rel="stylesheet" href="/static/haonav/css/nav.css">
</head>
<body>
<div class="navbar">
<div class="navbar-content">
<a href="/haonav/index.html" class="logo">🚀 网址导航</a>
<form class="search-box" id="searchForm">
<select id="searchEngine">
<option value="baidu" {$config.default_engine == 'baidu' ? 'selected' : ''}>百度</option>
<option value="bing" {$config.default_engine == 'bing' ? 'selected' : ''}>必应</option>
<option value="google" {$config.default_engine == 'google' ? 'selected' : ''}>Google</option>
<option value="sogou" {$config.default_engine == 'sogou' ? 'selected' : ''}>搜狗</option>
<option value="site" {$config.default_engine == 'site' ? 'selected' : ''}>站内</option>
</select>
<input type="text" id="searchInput" autocomplete="off" placeholder="搜索网站 / 关键词">
<button type="submit">🔍 搜索</button>
</form>
<div class="nav-actions">
<div class="nav-links">
<a href="/haonav/index.html">首页</a>
<a href="/haonav/rank.html" class="active">排行榜</a>
<a href="/haonav/submit.html">投稿</a>
</div>
<button class="icon-btn" id="themeToggle" title="切换暗黑模式">🌙</button>
<div class="user-area" id="userArea">
<button class="nav-btn" id="btnLogin">登录</button>
<button class="nav-btn nav-btn-primary" id="btnRegister">注册</button>
</div>
</div>
</div>
</div>
<div class="main-content">
<div class="section-title"><h2>🏆 热门排行榜(按点击量)</h2></div>
<div class="rank-list" style="margin-top:14px;">
{volist name='list' id='vo' key='k'}
<a class="rank-item" href="/haonav/site/{$vo.id}.html" target="_blank">
<span class="rank-no">{$k}</span>
<span class="rank-icon"><img src="{$vo.show_icon}" loading="lazy" alt=""></span>
<span class="rank-meta">
<h4>{$vo.title} {if $vo.status_code > 0 && $vo.status_code >= 400}<span class="dead-badge">疑似失效</span>{/if}</h4>
<p>{$vo.description|default='暂无描述'}</p>
{if $vo.rating_count > 0}<span class="rank-rating">⭐ {$vo.rating} ({$vo.rating_count})</span>{/if}
</span>
<span class="rank-clicks">{$vo.click_count} 次</span>
</a>
{/volist}
</div>
</div>
<div class="footer">
<p>© 2026 网址导航 - 让上网更简单</p>
</div>
<div class="qr-mask" id="qrMask">
<div class="qr-box">
<img id="qrImg" src="" alt="二维码">
<p id="qrText"></p>
<span class="close">关闭</span>
</div>
</div>
<!-- 登录 / 注册 弹层由全局组件 auth.js 自动注入 -->
<script src="/static/haonav/js/nav.js"></script>
<script src="/static/haonav/js/auth.js"></script>
</body>
</html>
@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>站内搜索 - 网址导航</title>
<link rel="stylesheet" href="/static/haonav/css/nav.css">
</head>
<body>
<div class="navbar">
<div class="navbar-content">
<a href="/haonav/index.html" class="logo">🚀 网址导航</a>
<form class="search-box" id="searchForm">
<select id="searchEngine">
<option value="site" selected>站内</option>
<option value="baidu">百度</option>
<option value="bing">必应</option>
<option value="google">Google</option>
<option value="sogou">搜狗</option>
</select>
<input type="text" id="searchInput" autocomplete="off" value="{$q}" placeholder="搜索网站 / 关键词">
<button type="submit">🔍 搜索</button>
</form>
<div class="nav-actions">
<div class="nav-links">
<a href="/haonav/index.html">首页</a>
<a href="/haonav/rank.html">排行榜</a>
<a href="/haonav/submit.html">投稿</a>
</div>
<button class="icon-btn" id="themeToggle" title="切换暗黑模式">🌙</button>
<div class="user-area" id="userArea">
<button class="nav-btn" id="btnLogin">登录</button>
<button class="nav-btn nav-btn-primary" id="btnRegister">注册</button>
</div>
</div>
</div>
</div>
<div class="main-content">
<div class="section-title"><h2>🔍 站内搜索 {if $q}<span style="color:var(--nav-primary)">{$q}</span>{/if}</h2></div>
<div class="rank-list" id="searchResults" style="margin-top:14px;"></div>
</div>
<div class="footer">
<p>© 2026 网址导航 - 让上网更简单</p>
</div>
<div class="qr-mask" id="qrMask">
<div class="qr-box">
<img id="qrImg" src="" alt="二维码">
<p id="qrText"></p>
<span class="close">关闭</span>
</div>
</div>
<!-- 登录 / 注册 弹层由全局组件 auth.js 自动注入 -->
<script src="/static/haonav/js/nav.js"></script>
<script src="/static/haonav/js/auth.js"></script>
</body>
</html>
@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>网址投稿 - 网址导航</title>
<link rel="stylesheet" href="/static/haonav/css/nav.css">
</head>
<body>
<div class="navbar">
<div class="navbar-content">
<a href="/haonav/index.html" class="logo">🚀 网址导航</a>
<form class="search-box" id="searchForm">
<select id="searchEngine">
<option value="baidu" {$config.default_engine == 'baidu' ? 'selected' : ''}>百度</option>
<option value="bing" {$config.default_engine == 'bing' ? 'selected' : ''}>必应</option>
<option value="google" {$config.default_engine == 'google' ? 'selected' : ''}>Google</option>
<option value="sogou" {$config.default_engine == 'sogou' ? 'selected' : ''}>搜狗</option>
<option value="site" {$config.default_engine == 'site' ? 'selected' : ''}>站内</option>
</select>
<input type="text" id="searchInput" autocomplete="off" placeholder="搜索网站 / 关键词">
<button type="submit">🔍 搜索</button>
</form>
<div class="nav-actions">
<div class="nav-links">
<a href="/haonav/index.html">首页</a>
<a href="/haonav/rank.html">排行榜</a>
<a href="/haonav/submit.html" class="active">投稿</a>
</div>
<button class="icon-btn" id="themeToggle" title="切换暗黑模式">🌙</button>
<div class="user-area" id="userArea">
<button class="nav-btn" id="btnLogin">登录</button>
<button class="nav-btn nav-btn-primary" id="btnRegister">注册</button>
</div>
</div>
</div>
</div>
<div class="main-content">
<div class="submit-box">
<h2>📝 提交网址</h2>
<form id="submitForm">
<div class="form-row">
<label>网站名称 *</label>
<input type="text" name="title" required placeholder="例如:百度">
</div>
<div class="form-row">
<label>网址 *</label>
<input type="url" name="url" required placeholder="https://...">
</div>
<div class="form-row">
<label>所属分类 *</label>
<select name="cid" required>
<option value="">请选择分类</option>
{volist name='cates' id='c'}<option value="{$c.id}">{$c.title}</option>{/volist}
</select>
</div>
<div class="form-row">
<label>描述</label>
<textarea name="description" placeholder="一句话介绍这个网站"></textarea>
</div>
<div class="form-row">
<label>关键词</label>
<input type="text" name="keywords" placeholder="用逗号分隔,便于搜索">
</div>
<button type="submit" class="btn-primary">提交审核</button>
<div class="form-msg" id="submitMsg"></div>
</form>
</div>
</div>
<div class="footer">
<p>© 2026 网址导航 - 让上网更简单</p>
</div>
<!-- 登录 / 注册 弹层由全局组件 auth.js 自动注入 -->
<script src="/static/haonav/js/nav.js"></script>
<script src="/static/haonav/js/auth.js"></script>
</body>
</html>