diff --git a/AI调整开发文档.md b/AI调整开发文档.md deleted file mode 100644 index 60ded9a..0000000 --- a/AI调整开发文档.md +++ /dev/null @@ -1,546 +0,0 @@ -# 网站首页修订版 Plan(v2) - -> 本文件在 `plan.txt` 基础上修订:补全缺失说明、澄清歧义、统一各处术语。 -> 凡原 plan 未说明而本文给出默认值的,均已标注 **【决策】**,可自行调整。 -> 本版以"可直接指导编码实现"为标准编写,页面清单、数据模型、布局规格均已明确。 - ---- - -## 1. 修订要点总览(原 plan 问题 → 本版处理) - -| # | 原 plan 问题 | 本版处理 | -| --- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -| 1 | 页面体系与跳转关系未定义 | §2 站点地图,明确首页为最前置页,三大知识库各有独立导航区,工具库跳转功能区 | -| 2 | 首页百分比布局自相矛盾(60% 主内容 / 20% 展示栏 / 40% 导航 / 剩余 40% 里"中部主内容") | §4 重构为自洽的分区模型,保留 20% 展示栏 + 40% 板块区意图 | -| 3 | 技术栈表述笼统 | §3 确定 PHP + SQLite(PDO) + 原生 HTML/CSS/JS,可加 layui | -| 4 | 导航区"留白 10% / 上半部 20%"表述含糊 | §5 给出明确高度计算规则 | -| 5 | "按钮上分蹦出来一个语言栏"(疑似笔误) | §5 定义为悬浮气泡 tooltip 显示备注 | -| 6 | 导航块数据来源、后台归属、超量排布未定义 | §5.3 + §7 数据模型 + §6.4 分栏规则 | -| 7 | 功能区首页布局缺失、顶部组件关系不清 | §6 定义功能区首页网格布局 + 三类顶部组件分工 | -| 8 | 后台"顺序排布 / 内容设计"对象空泛 | §7.3 拆解为可操作的具体配置项 | -| 9 | 登录会话 / 初始管理员 / 越权访问缺失 | §7.1、§7.2 补齐 | -| 10 | quota.txt、avlist.js 格式未约定 | §10 明确格式 | -| 11 | 备案号、版权文本来源未说明 | §7.3.2 后台可配置 + 默认占位 | -| 12 | 移动端适配规则不全 | §9 响应式断点表 | -| 13 | 目录结构缺失 | §8 目录规划 | -| 14 | "上次更新时间"无定义 | §4.5 定义数据来源与刷新逻辑 | -| 15 | 密码"base64+md5"安全局限 | §7.1 注明局限(按需求实现) | -| 16 | 板块 hover 展开交互描述绕口 | §4.6 重写为实现级规格 | - ---- - -## 2. 站点地图与页面跳转关系 - -站点逻辑结构(已确认): - -``` -首页区(index) - ├─ 科普库 → 科普导航页 - ├─ 红队库 → 红队导航页 - ├─ 蓝队库 → 蓝队导航页 - └─ 工具库 → 功能区 - 功能区首页 - ├─ 编码/加解密页 - ├─ 随机密码生成页 - ├─ 二维码生成器 - └─ 杀软识别页 -后台区(/admin,需登录) -``` - -页面清单: - -| 区域 | 页面 | 文件(建议) | 说明 | -| -------- | ------------------------ | --------------------- | ----------------------------------------------------------------------------- | -| 首页区 | 首页 | `index.php` | 最前置页面 | -| 导航区 | 科普库导航页 | `nav.php?cat=popular` | 科普 / 红队 / 蓝队 三个导航页共用同一模板,按分类参数取数,等价于三个独立页面 | -| 导航区 | 红队库导航页 | `nav.php?cat=red` | 同上 | -| 导航区 | 蓝队库导航页 | `nav.php?cat=blue` | 同上 | -| 功能区 | 功能区首页 | `func/index.php` | 工具入口列表 | -| 功能区 | 编码/加解密 | `func/codec.php` | | -| 功能区 | 随机密码生成 | `func/password.php` | | -| 功能区 | 二维码生成器 | `func/qrcode.php` | | -| 功能区 | 杀软识别 | `func/av.php` | | -| 后台区 | 登录页 | `admin/login.php` | | -| 后台区 | 导航内容管理(增删改查) | `admin/nav.php` | 含排序 | -| 后台区 | 功能区管理(工具入口) | `admin/tools.php` | 工具增删改/排序/启停,见 §6.2 | -| 后台区 | 顺序排布 / 内容设计 | `admin/content.php` | 见 §7.3 | -| 数据接口 | JSON 输出 | `api/nav.php` 等 | 供页面 JS 取导航数据 | - -> **【决策】文件全部采用 `.php`**:便于统一 include 顶部栏/页脚、做导航数据渲染、后台鉴权; -> 纯前端静态页若要读 sqlite 也必须走后端接口,统一为 PHP 最省事。 - ---- - -## 3. 技术栈与运行环境 - -- 后端:**PHP ≥ 7.4 + PDO_SQLITE 扩展**,数据库 `data/homepage.db`(SQLite3 文件)。 -- 前端:原生 HTML5 + CSS3 + 原生 JS;允许引入 layui 或轻量库(见 §6 工具页选型)。 -- 编码类算法:优先原生 Web Crypto(SHA);UTF-8 编解码用 `TextEncoder`/`TextDecoder`;如需统一兼容 IE 类场景才引入 `crypto-js`。**【决策:现代浏览器优先,不兼容老 IE】** -- 二维码:引入 `qrcodejs`(单文件 JS 库)。 -- 部署形态:最终放置于服务器(Apache/Nginx + PHP),文档根目录即本工程目录。 - -> 实现约束:编码/加解密、密码生成等均为纯前端 JS 计算,不产生请求;仅导航数据与后台写库走 PHP。 - ---- - -## 4. 首页区规格(index.php) - -### 4.1 整体分区(自洽版布局) - -页面允许纵向滚动(**不用 JS 死锁 100vh**,避免小屏挤压)。各区块高度按"视口高度比例(vh)+ 自然内容高度"混用,规格如下: - -| 区块 | 顺序 | 高度规格 | 内容 | -| ------------- | ---- | ----------------------------------- | ----------------- | -| A. 顶部展示栏 | 1 | 约 20vh(自适应,最小不低于 120px) | 见 §4.2 | -| B. 名言条 | 2 | 内容自适应(约占 10% 视口) | 随机名言,见 §4.3 | -| C. 四大板块区 | 3 | 约 40vh 起,内容超高可自然延伸 | 见 §4.4 / §4.6 | -| D. 页脚 | 4 | 内容自适应 | 见 §4.7 | - -> 原 plan"主内容占 60%(20% 展示栏 + 40% 板块)+ 剩余 40% 内 10% 名言 + 下部备案"存在加总冲突, -> 本版将"20% 展示栏 + 40% 板块 + 10% 名言"作为主要视口内规划(合计约 70%,留有余量),备案等落入页脚区块,不再要求凑满 100vh。 - -### 4.2 顶部展示栏(首页版) - -- 整体居中布局,内部水平分布: - - **左侧**:站点 Logo + 站点名称/副标题。 - - **右侧**:当前时间(实时刷新,格式 `YYYY-MM-DD HH:mm:ss`)+ 上次更新时间(见 §4.5)。 - - **下方居中**:天气区块:``(在移动端可改为宽度 100% 居中,iframe 原样保留尺寸缩放)。 -- 展示栏背景与整体风格统一(§4.8)。 - -### 4.3 名言条(B 区块) - -- 内容源:`data/quota.txt`,每次**刷新页面随机取一条**(也可用 JS 刷新按钮换一条)。 -- 文件格式见 §10.1。 -- 名言为空/文件缺失时显示默认文案,页面不报错。 - -### 4.4 四大板块区(C 区块) - -- 四个板块横排均分,按后台 categories 顺序渲染: - **科普库 → 科普导航页**、**红队库 → 红队导航页**、**蓝队库 → 蓝队导航页**、**工具库 → 功能区首页**。 -- 板块区上方不再显示“四大板块”大标题,改为**快捷站点胶囊条**(数据表 `quick_links`,后台“内容与顺序”维护): - - 用于放置本域名下其它网站,点击 `target="_blank"` 新标签页打开且保留本页; - - 无配置时不显示该条,板块区保持居中填满布局。 -- 每个板块卡片内容:图标 + 名称 + 一句话简介(文案后台可配,见 §7.3.2)。 -- 点击行为:科普/红/蓝打开对应导航页;工具库打开功能区首页。 -- 基础悬停:变色/放大反馈;高级展开说明见 §4.6。 - -### 4.5 "上次更新时间"定义(【决策】) - -含义 = **导航库内容最近一次被后台修改的时间**。 - -- 后台任何写库操作成功后,同步写入 `settings` 表键 `last_updated`(格式时间戳)。 -- 页面通过 PHP 端直接输出该值;无记录时显示"暂无更新记录"。 -- 不跟随"站点上线时间",只代表内容数据新鲜度。 - -### 4.6 板块 hover 展开说明(高级要求,实现级规格) - -目的:鼠标悬停某板块时,弹出**只读说明层**,覆盖在板块区上方,用于解释该库是什么;该层**不可点击、不参与布局**(position: absolute 叠层)。 - -规则: - -1. 悬停任一板块 → 生成该板块说明浮层,覆盖其上方并向外**延展**,延展优先方向: - - 处于最左侧板块:向右延展;处于最右侧板块:向左延展; - - 处于中间板块时:浮层**移位显示到距其最近的边缘空白侧**(第 2 块→吸附左侧边缘展开,第 3 块→吸附右侧边缘展开)。 -2. 所有板块的**实际位置在展开过程中不移动**(视觉上"旁侧浮现"由浮层绝对定位实现,不做网格挤压)。 -3. 鼠标移出四大板块整体区域 → 浮层消失。 -4. 鼠标从一块移到另一块 → 浮层内容与吸附位置即时切换为该板块。 -5. 浮层 z-index 高于板块卡片;浮层 pointer-events 不拦截,防止遮挡后无法移动鼠标。 - -> 说明文案来源:categories.description,可在后台"内容设计"中编辑。**【决策:文案每块 1~3 句】** - -### 4.7 页脚(D 区块) - -内容:ICP 备案号(`备案号`占位,后台可配)、公安备案号(后台可配,含图标)、版权声明(默认 `© 2026 <站点名> 版权所有`,后台可配)、站点名/其他自定义文本(后台可配,见 §7.3.2)。底部同时放一个低调的"后台管理"入口链接(指向 `admin/login.php`)。 - -### 4.8 统一视觉规范(全局) - -| 项 | 约定 | 备注 | -| ------------ | --------------------------------------------- | ---------------------- | -| 主色 | 深空蓝 `#1f3a5f` + 强调蓝 `#2563eb` | 各页统一 | -| 背景 | 浅灰 `#f5f7fa`,卡片白底 | | -| 字体 | 系统字体栈 + 中文回退(`Microsoft YaHei` 等) | 由 CSS 变量统一 | -| 圆角/阴影 | 卡片统一 8px 圆角、轻投影 | | -| 全局公共样式 | 独立 `assets/css/common.css` | 各页引入,保证风格一致 | -| favicon/标题 | 站点名作为 `` 统一后缀 | 后台可改站点名 | - ---- - -## 5. 导航区规格(nav.php,科普/红队/蓝队共用) - -### 5.1 页面纵向结构 - -自顶向下依次为: - -| 区块 | 规则 | 备注 | -| ---------- | ---------------------------------------------------- | ------------------------------------------------ | -| 顶部留白 | 约 **10vh**(不小于 60px) | 纯背景留白,不做内容 | -| 搜索栏 | 位于留白之下,高度 **20vh**(下限 72px、上限 160px) | 即原 plan"上半部 20% + 最上方留白 10%"的明确算法 | -| 导航内容区 | 占据剩余空间,内容超高时**整页纵向滚动** | 见 5.3 | - -### 5.2 搜索栏规格(高级要求) - -- 结构(水平一行,居中,定宽容器内):**引擎切换按钮(左)|输入框(中)|搜索按钮(右)**。 -- 引擎切换:点击循环切换或下拉选择,引擎列表默认:`bing / baidu / github / google`(**【决策】可通过后端接口/常量维护,便于增删**)。 -- 搜索 URL 模板(选中引擎后拼 `关键词` 后 `target="_blank"` 新开页): - - bing: `https://www.bing.com/search?q={kw}` - - baidu: `https://www.baidu.com/s?wd={kw}` - - github: `https://github.com/search?q={kw}` - - google: `https://www.google.com/search?q={kw}` -- 输入框**有内容**时,最右侧出现"清除(×)"按钮;点击清空并聚焦。 -- 触发方式:点击搜索按钮或**回车**。 -- 关键词需 `encodeURIComponent` 处理。 - -### 5.3 导航内容区(主体) - -- 桌面(≥1200px): - - 页面左右两侧各留白 **15%**; - - 中间 70% 区域划分为**三大列**(列宽相等、列间有间距); - - 大列**无标题**,仅是布局容器。 -- 导航块(最小管理单元): - - 每个块 = **1 个块标题(加粗居左)+ 内部 3 小列 × 5 行**(即每块最多 15 个按钮位); - - 块标题、按钮是否填满不限制(3 列 5 行是最大学位,不足则留空)。 - - 块尺寸固定(宽度=所在列宽,高度固定,各按钮行高统一)。 -- 分列规则(【决策】):取当前分类下所有导航块(按 sort 排序),**按序轮转填充三列**(第 1 块→列1,第 2 块→列2,第 3 块→列3,第 4 块→列1……),保证三列块数均衡;列内纵向堆叠,超高则整页滚动。 -- 按钮交互: - - 鼠标移上 → 按钮变色(统一高亮样式); - - 该按钮存在备注信息时 → **浮出悬浮气泡 tooltip** 显示备注,移出消失;无备注仅变色; - - 点击 → `target="_blank"` 新标签页打开链接(加 `rel="noopener noreferrer"`)。 - - 链接规范:允许完整 URL(http/https);后台录入时为空则按钮置灰不可点。 -- 内容来源:按当前 `cat`(popular/red/blue)读取 `nav_blocks`、`nav_items`(§7.4)。 -- 空状态:无任何块时显示占位提示,不报错。 - -### 5.4 移动端 - -见 §9 响应式;核心:中间区域改单列,导航块铺满屏宽,块内 3 小列在 ≥600px 保持 3 列、<600px 折叠为 1 列或 2 列(【决策:<600px 时单小列显示,即一行一个按钮】)。导航块标题列布局保留。 - ---- - -## 6. 功能区规格 - -### 6.1 页面清单与公共头部(功能区统一) - -功能区包含 1 个入口首页 + 4 个工具页。所有功能区页面使用统一的**功能页头部条**(区别于首页展示栏): - -``` -[ ← 返回功能区首页 ] 本功能页标题(居中) [ 首页 ] -``` - -- 最左侧按钮 → 返回 `func/index.php`(功能子页上);在功能区首页本身时该按钮隐藏或置灰。 -- 最右侧按钮 → 返回站点首页 `index.php`。 -- 中间标题 → 当前功能页名称。 -- 功能区首页的头部条中间显示"功能区"。 - -### 6.2 功能区首页(func/index.php) - -- 顶部为风格统一的大展示横幅(复用首页视觉,可含功能区简介)。 -- 主体为工具**卡片网格**:每张卡片 = 图标 + 工具名 + 一句话说明,点击进入对应子页。 -- 工具卡片列表**由 `func_tools` 表驱动,后台“功能区管理”(admin/tools.php)可增删改/排序/启停**: - - 站内工具(如 codec.php):需先在 `func/` 下开发对应页面,再到后台登记入口; - - 外链工具:填写完整 http(s) 地址,作为新窗口卡片直接使用,无需写代码; - - 默认预置:编码/加解密、随机密码生成、二维码生成器、杀软识别四张卡片。 - -### 6.3 编码/加解密页(func/codec.php) - -- **布局**:左侧算法/方向选择区 + 上下(或左右)两个**自由编辑**文本区(输入区、输出区)。 - - 两个文本区都允许用户自由编辑;输出区不设只读。 - - 两个文本区均支持 **右下角拖拽竖向放大(resize: vertical),横向宽度不变**。 - - 提供"交换 / 回填"便捷操作:将输出内容一键放回输入、或将输入/输出一键互换(【决策】)。 -- **支持算法与方向**: - -| 算法 | 编码方向 | 解码方向 | 实现要求 | -| --------------------- | -------------------- | -------------------- | --------------------------------------------------------------------------- | -| Base64 | 编码 | 解码 | 支持中文等 Unicode:先 UTF-8 再 Base64 | -| Base32 | 编码 | 解码 | 同上 | -| MD5 | — | 不可逆 | 输出 32 位十六进制小写 | -| SHA-1/224/256/384/512 | — | 不可逆 | 输出十六进制小写 | -| URL | `encodeURIComponent` | `decodeURIComponent` | 整段处理 | -| Unicode | 中→`\uXXXX` 序列 | `\uXXXX`→文本 | 需支持中文;emoji/增补字符按 UTF-16 代理对编码成两个 `\uXXXX`,解码正确还原 | - -- 交互模型:选算法 + 选方向(编码/解码/加密哈希等按需)→ 点击"执行" → 结果写入输出区;错误(非法输入等)以页面内提示展示,不弹窗。 -- 执行按钮文案随方向变化(如"编码""解码""加密")。 - -### 6.4 随机密码生成页(func/password.php) - -- 字符集选项(多选/开关): - - 大写字母 `A-Z`、小写字母 `a-z`、数字 `0-9`、特殊符号(建议默认 `!@#$%^&*()-_=+[]{};:,.<>?`); - - **自定义字符集输入框**:勾选后启用,用户自定义任意字符(去重);勾选"自定义"时以自定义为准,忽略其余开关。 -- 长度:滑块或数字输入,范围 **4~64**,默认 16。 -- 生成:一次生成 **多组(默认 5 条)**【决策】,逐条显示在可编辑文本区/列表,每条后带"复制"按钮。 -- 结果文本区同样支持竖向拖拽放大。 -- 至少选一个字符集且长度合法才可生成,否则提示。 - -### 6.5 二维码生成器(func/qrcode.php) - -- 输入框 + "生成"按钮;使用 `qrcodejs` 渲染。 -- **放大预览**:提供尺寸滑块(如 128~512px)或点击图片弹出大图预览(大图可在新层中查看);**下载**:提供下载按钮,导出为 PNG(canvas 转 dataURL 或库的 toDataURL)。 -- 边界处理:内容为空禁止生成;内容过长超出二维码容量时提示(库报错或预检测)。 -- 空白页签、无输入时不展示二维码区。 - -### 6.6 杀软识别页(func/av.php) - -- 输入:粘贴 `tasklist` 命令输出文本的 textarea(支持竖向放大,横向不可变)。 -- 匹配库:`data/avlist.js`(§10.2)。 -- 解析规则: - - 按行读取,忽略表头行(含"映像名称/Image Name/PID/===")、空行; - - 每行取**首个 token** 为进程名(空格/制表分隔); - - 与 `avList` 键匹配,**大小写不敏感**;key 若不带扩展名,比较时双方都去掉扩展名后再比一次(【决策】兼容两类写法)。 -- 输出:匹配结果**表格**,列:`进程名 | 匹配结果 | 产品/说明`;未匹配的行结果列显示"未识别",说明列留空。 -- 附加统计:总进程数、匹配数、未识别数。 -- 前端直接 `<script src="../data/avlist.js">` 引入全局 `avList` 对象即可,无需后端。 - ---- - -## 7. 后台区规格(/admin) - -### 7.1 登录与会话 - -- 登录页:用户名 + 密码;错误统一提示"用户名或密码错误"。 -- 密码存储算法:**按需求:`md5(base64(明文密码))`**。 - - ⚠️ 注明局限:该方案是弱单向哈希(无盐),仅用于低安全要求的个人站;不用于高价值系统。若后期可接受,建议换 `password_hash()`(bcrypt)升级,本版实现严格按需求算法。 -- 会话管理:PHP `session`,登录成功后写入 `$_SESSION['admin']`;后台所有页面(除 login 外)在顶部做鉴权: - - 未登录访问后台页 → 302 跳转 `login.php`; - - 退出登录 → `logout.php`(销毁 session 并跳登录页); - - 会话空闲超时(**【决策】30 分钟**)后失效,需重新登录。 -- 初始管理员:首次启动数据库不存在用户时,由 `install` 逻辑自动创建默认账号(**【决策】默认账号 `admin` / 密码 `admin123`,首次登录后引导修改**)。 -- CSRF 防护:后台写操作表单带一次性 token(session 存),提交校验(低成本基础防护)。 -- 登录尝试不做复杂限流,仅简单延时提示(个人站可接受)。 - -### 7.2 后台整体布局 - -- 后台顶部导航条:后台名称 + 快捷入口(导航内容管理 / 顺序排布与内容设计 / 返回前台首页)+ 当前管理员 + 退出。 -- 后台页面自身不引入首页天气/名言等前台组件,保持简洁(风格色板仍与前台统一)。 -- 所有表单保存成功后给反馈(保存成功条/提示),失败显示原因(如 SQL 错误信息可折叠展示,便于排查)。 - -### 7.3 后台功能拆解(原 plan 空泛项的落地) - -原 plan 只写了"1.顺序排布 2.内容设计 3.导航内容增删改查",本版将其拆成可操作模块: - -#### 7.3.1 导航内容管理(admin/nav.php) - -- 顶部先选择操作分类:**科普库 / 红队库 / 蓝队库**(工具库对应功能区,不在导航内容范围内)。 -- 块级操作(针对 nav_blocks):新增块、删除块、编辑块标题、块上移/下移(调整在页面中的先后); -- 按钮位操作(针对 nav_items):在每个块内新增/编辑/删除按钮,字段: - - 文字(必填)、URL(必填)、备注(可空,空则不显示 tooltip)、序号; - - 按钮在块内"3 小列 × 5 行"中的位置可由排序决定(**【决策】按 sort 顺序自动排列,不足位留空,不做拖拽定位到格**); -- 前端交互:用可展开的块列表展示,每个块内按钮表格行式管理,保存按钮批量提交(一次提交当前分类全部变更)。 -- 删除块/按钮需二次确认。 - -#### 7.3.2 内容设计(admin/content.php) - -统一为一个"站点内容与设置"页,按分组编辑并保存到 `settings` 表: - -| 分组 | 可编辑项 | 存储 | 用途 | -| -------- | ------------------------------------------------------------ | ---------------------------------- | ------------------------------- | -| 站点信息 | 站点名、副标题 | settings | 首页展示栏、`<title>` 后缀 | -| 板块文案 | 四大板块各自的图标选择/文案/一句话简介 | settings 或 categories.description | 首页四大板块卡片与 hover 说明层 | -| 底部信息 | ICP 备案号、公安备案号、版权行、自定义底部文本 | settings | 首页/全站页脚 | -| 名言库 | 名言**新增 / 删除 / 清空**(文本框逐行维护,写回 quota.txt) | data/quota.txt | 首页名言条 | -| 其他 | 上次更新时间说明(只读展示) | — | — | - -- 底部自定义文本支持纯文本与单条链接(安全起见只允许 http(s) 开头的 `<a>` 形态,**不做富文本/HTML 注入**)。 - -#### 7.3.3 顺序排布(admin/content.php 或独立区) - -明确排序对象与层级(自外向内): - -1. **分类(四大板块)顺序** → 决定首页四大板块横排顺序; -2. **块顺序**(每个分类内)→ 决定导航页块的前后; -3. **块内按钮顺序** → 决定按钮在块内 3×5 格中的先后。 - -排序交互统一提供"上移/下移 + 序号输入"两种方式(拖拽列为可选项)。 - -### 7.4 数据模型(SQLite DDL) - -```sql --- 管理员表 -CREATE TABLE users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL -- md5(base64(明文)) -); - --- 键值设置表(站点名、备案号、版权、板块文案、last_updated 等) -CREATE TABLE settings ( - key TEXT PRIMARY KEY, - value TEXT -); - --- 四大板块分类(popular/red/blue/tool) -CREATE TABLE categories ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - key TEXT UNIQUE NOT NULL, -- popular/red/blue/tool - name TEXT NOT NULL, -- 科普库/红队库/蓝队库/工具库 - description TEXT DEFAULT '', -- 首页卡片一句话简介 + hover 说明文案 - sort INTEGER DEFAULT 0 -); - --- 导航块(仅 popular/red/blue 三类使用) -CREATE TABLE nav_blocks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - cat_id INTEGER NOT NULL REFERENCES categories(id), - title TEXT NOT NULL, - sort INTEGER DEFAULT 0 -); - --- 块内按钮位(每块最多 3×5=15,由 sort 顺序占位) -CREATE TABLE nav_items ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - block_id INTEGER NOT NULL REFERENCES nav_blocks(id), - label TEXT NOT NULL, - url TEXT NOT NULL, - note TEXT DEFAULT '', -- 备注;空则按钮 hover 不弹气泡 - sort INTEGER DEFAULT 0 -); - --- 功能区工具入口(后台“功能区管理”维护) -CREATE TABLE func_tools ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - icon TEXT DEFAULT '', -- 字符/emoji 图标 - description TEXT DEFAULT '', -- 一句话说明 - url TEXT NOT NULL DEFAULT '', -- 站内相对地址 或 外链 http(s) - is_external INTEGER NOT NULL DEFAULT 0,-- 1=外链(新窗口) - enabled INTEGER NOT NULL DEFAULT 1,-- 1=前台展示 - sort INTEGER NOT NULL DEFAULT 0 -); - --- 首页快捷站点(本域名其它网站,target=_blank;后台“内容与顺序”维护) -CREATE TABLE quick_links ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - url TEXT NOT NULL DEFAULT '', -- 完整 http(s) 地址 - icon TEXT DEFAULT '', -- 字符/emoji 图标 - enabled INTEGER NOT NULL DEFAULT 1, - sort INTEGER NOT NULL DEFAULT 0 -); -``` - -> 名言不建表(保持 quota.txt 为数据源);`categories` 四行 + `settings` 基础键在首次运行时自动初始化(install 脚本)。 - ---- - -## 8. 目录结构规划 - -``` -HomePage/ # 网站根(部署时即站点根) -├── index.php # 首页区 -├── nav.php # 导航区模板(?cat=popular|red|blue) -├── func/ -│ ├── index.php # 功能区首页 -│ ├── codec.php # 编码/加解密 -│ ├── password.php # 随机密码 -│ ├── qrcode.php # 二维码 -│ └── av.php # 杀软识别 -├── admin/ -│ ├── login.php # 后台登录 -│ ├── logout.php -│ ├── nav.php # 导航内容管理 + 排序 -│ ├── tools.php # 功能区工具入口管理 -│ ├── content.php # 内容设计 / 站点设置 -│ └── _guard.php # 鉴权 include(require 到各后台页顶部) -├── api/ -│ └── nav.php # 输出导航 JSON(前台导航页/首页读取用) -├── includes/ -│ ├── db.php # PDO + SQLite 连接与 install 初始化 -│ ├── auth.php # 登录/校验/session 工具 -│ └── settings.php # settings 读写封装 -├── assets/ -│ ├── css/common.css # 全局样式(统一风格、响应式断点) -│ ├── js/common.js # 通用组件(tooltip、竖向 resize 类等) -│ ├── js/codec.js # 编码算法(base64/base32/url/unicode) -│ ├── js/sha.js # SHA 系列 -│ ├── js/qrcode.min.js # qrcodejs -│ └── img/ # logo/图标 -├── data/ -│ ├── homepage.db # SQLite(运行时自动创建) -│ ├── quota.txt # 名人名言(行式,后台可编辑) -│ └── avlist.js # 杀软进程映射表 -└── plan.txt # 原始 plan(保留存档) -``` - ---- - -## 9. 响应式断点规划 - -| 断点 | 首页 | 导航区 | 功能区 | -| ------------------ | ------------------------------------- | --------------------------------------------------------------------------- | ----------------------------------- | -| ≥1200px(桌面) | 四板块横排;天气 iframe 500×40 原尺寸 | 左右各留白 15%,中间三列 | 工具卡片多列;编码页输入/输出可并排 | -| 768–1199px(平板) | 四板块两行两列或自适应横排 | 左右留白收窄至 5%,仍三列(列宽收紧) | 卡片两列 | -| <768px(手机) | 板块单列或 2×2 网格;名言条自然换行 | **单列**:中间区全宽,导航块一列、块内小列 1 列显示(原要求);搜索栏自适应 | 卡片单列 | - -补充规则: - -- 顶部展示栏/搜索栏/功能页头部条在移动端自动收缩高度与内边距,天气 iframe 宽度等比缩放居中。 -- 编码/二维码等页面在 <768px 时输入输出区改为上下堆叠。 -- 底部备案等信息移动端居中换行。 - ---- - -## 10. 数据文件格式约定 - -### 10.1 data/quota.txt(名人名言) - -- 编码:UTF-8(无 BOM)。 -- 每行一条名言,**空行或 `#` 开头为注释**(后台编辑时自动忽略)。 -- 首次实现时预置 5~10 条示例。 -- 读取:后端 PHP 读取并随机输出一条(首页渲染),避免名言文件被直接下载的路径泄露问题不在此列(公开文件无碍)。 - -示例: - -``` -# 示例名言 -知彼知己,百战不殆。 -知识就是力量。 -``` - -### 10.2 data/avlist.js(杀软进程映射表) - -- 全局对象写法(沿用原示例结构),首次实现预置若干常见条目: - -```js -var avList = { - "360tray.exe": "360 安全卫士 - 实时保护", - "360safe.exe": "360 安全卫士", - "QQPCRTP.exe": "QQ电脑管家", - "kxescore.exe": "金山毒霸", - "ekrn.exe": "ESET NOD32", - "MsMpEng.exe": "Windows Defender", -}; -``` - -- 约定:value 统一为 `产品名 - 组件描述` 风格;key 含扩展名;匹配时忽略大小写。 -- 该文件由实现时创建,**不进后台管理**(如需可扩展)。 - ---- - -## 11. 部署与初始化说明 - -1. 环境要求:PHP ≥ 7.4(启用 PDO_SQLITE)、Apache/Nginx;无需额外安装数据库服务。 -2. 将工程目录整体上传至服务器站点根目录;`data/` 目录需 PHP 可写(用于创建/写入 `homepage.db` 与 `quota.txt`)。 -3. 首次访问任意页面触发自动初始化:建库建表、写入 `categories` 四行、默认 settings、默认管理员 `admin / admin123`、生成 quota.txt/avlist.js 示例(若不存在)。 -4. 首登后台后请修改默认密码(后台导航条提供修改密码入口)。 -5. 天气 iframe 依赖外网(`i.tianqi.com`),离线环境下该区块显示占位不阻塞页面。 - ---- - -## 12. 实施顺序与验收要点(供编码阶段使用) - -| 阶段 | 交付物 | 验收标准 | -| ----------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| 1. 框架 | 目录结构、common.css/common.js、includes/db.php、install 初始化、数据示例 | 首次运行自动建库建表成功;首页可展示默认数据 | -| 2. 首页 | index.php:展示栏/名言条/四板块 hover 展开/页脚 | 四板块正确跳转四大目标;hover 展开规则与 §4.6 一致;时间刷新、名言随机;布局符合 §4 | -| 3. 导航区 | nav.php 三分类 + 搜索栏 + tooltip + 分列 | bing/baidu/github 搜索新开页;回车/清除按钮可用;导航块 hover 变色/tooltip 正确;三列轮转正确;空态正常 | -| 4. 功能区 | func 五个页面 | 中文字符 Base64/Base32/Unicode 编解码正确;SHA/MD5 输出正确;密码可自定义生成;二维码生成/放大/下载;杀软识别匹配与统计正确;textarea 竖向可拉伸 | -| 5. 后台 | 登录/导航管理/内容设计/排序 | 默认账号可登录;未登录访问被拦截;分类/块/按钮 CRUD 与排序即时反映到前台;备案/名言等设置生效;last_updated 更新并展示到首页 | -| 6. 联调打磨 | 响应式与细节 | 三档断点符合 §9;外链新开页;风格一致;无 500/JS 报错 | - ---- - -## 13. 仍建议你确认的开放点(不影响启动开发) - -1. 功能区头部条是否也带天气 iframe —— 本版默认**仅首页展示栏带天气**,功能页不带(避免重复加载)。 -2. 首页名言是否允许点击换一条 —— 默认刷新页面换一条。 -3. 导航页大列块超多时是否允许整页纵向滚动 —— 默认允许。 -4. 二维码是否提供"滚轮缩放" —— 默认提供尺寸滑块 + 点击放大预览两种。 - -> 若以上默认与你设想不同,修改对应【决策】标注即可,其余规格不受影响。 diff --git a/admin/_guard.php b/admin/_guard.php index 52308d2..65fce23 100644 --- a/admin/_guard.php +++ b/admin/_guard.php @@ -21,6 +21,7 @@ function admin_topbar(string $active = ''): void 'nav' => ['nav.php', '导航内容管理'], 'tools' => ['tools.php', '功能区管理'], 'articles' => ['articles.php', '文章管理'], + 'exttools' => ['exttools.php', '外部工具'], 'content' => ['content.php', '内容与顺序'], ]; foreach ($links as $k => $lk) { diff --git a/admin/articles.php b/admin/articles.php index 2856f7e..81691c6 100644 --- a/admin/articles.php +++ b/admin/articles.php @@ -61,6 +61,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $sort = max(0, (int)($_POST['sort'] ?? 0)); $pinned = (isset($_POST['pinned']) && $_POST['pinned'] === '1') ? 1 : 0; $enabled = (isset($_POST['enabled']) && $_POST['enabled'] === '1') ? 1 : 0; + // 分发到库首页(文章区):board_cats 逗号分隔;未勾选=不展示到库页 + $bcKept = []; + foreach ((array)($_POST['board_cats'] ?? []) as $bk) { + $bk = trim((string)$bk); + if (in_array($bk, ['popular', 'red', 'blue'], true) && !in_array($bk, $bcKept, true)) { + $bcKept[] = $bk; + } + } + $boardCats = implode(',', $bcKept); if ($part === '' || $part === '__new__') { $part = '未分类'; } @@ -68,14 +77,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $msgKind = 'err'; $msgText = '文章标题不能为空。'; } elseif ($id > 0) { - $st = $pdo->prepare('UPDATE articles SET part=?, title=?, summary=?, markdown=?, pinned=?, enabled=?, sort=?, updated_at=? WHERE id=?'); - $st->execute([$part, $title, $summary, $md, $pinned, $enabled, $sort, date('Y-m-d H:i:s'), $id]); + $st = $pdo->prepare('UPDATE articles SET part=?, title=?, summary=?, markdown=?, pinned=?, enabled=?, sort=?, board_cats=?, updated_at=? WHERE id=?'); + $st->execute([$part, $title, $summary, $md, $pinned, $enabled, $sort, $boardCats, date('Y-m-d H:i:s'), $id]); $msgKind = 'ok'; $msgText = '文章已保存。'; } else { - $st = $pdo->prepare('INSERT INTO articles (part, title, summary, markdown, pinned, enabled, sort, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)'); + $st = $pdo->prepare('INSERT INTO articles (part, title, summary, markdown, pinned, enabled, sort, board_cats, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)'); $now = date('Y-m-d H:i:s'); - $st->execute([$part, $title, $summary, $md, $pinned, $enabled, $sort, $now, $now]); + $st->execute([$part, $title, $summary, $md, $pinned, $enabled, $sort, $boardCats, $now, $now]); $id = (int)$pdo->lastInsertId(); $msgKind = 'ok'; $msgText = '文章已发布(可继续编辑)。'; @@ -201,7 +210,8 @@ admin_topbar('articles'); 'id' => (int)$row['id'], 'part' => (string)$row['part'], 'title' => (string)$row['title'], 'summary' => (string)$row['summary'], 'md' => (string)$row['markdown'], 'sort' => (int)$row['sort'], 'pinned' => (int)$row['pinned'], 'enabled' => (int)$row['enabled'], - ] : ['id' => 0, 'part' => '未分类', 'title' => '', 'summary' => '', 'md' => '', 'sort' => 0, 'pinned' => 0, 'enabled' => 1]; + 'board_cats' => trim((string)($row['board_cats'] ?? '')), + ] : ['id' => 0, 'part' => '未分类', 'title' => '', 'summary' => '', 'md' => '', 'sort' => 0, 'pinned' => 0, 'enabled' => 1, 'board_cats' => '']; // 分区下拉选项:未分类 + 全部已有分区;编辑中的自定义分区自动补充,保证能回显选中 $curPart = trim((string)$cur['part']); if ($curPart === '') { $curPart = '未分类'; } @@ -246,6 +256,13 @@ admin_topbar('articles'); <label class="seg-check"><input type="checkbox" name="pinned" value="1"<?= $cur['pinned'] ? ' checked' : '' ?>> 置顶(首页文章区默认最前)</label> <label class="seg-check"><input type="checkbox" name="enabled" value="1"<?= $cur['enabled'] ? ' checked' : '' ?>> 前台可见</label> </div> + <?php $artBc = array_map('trim', explode(',', (string)$cur['board_cats'])); ?> + <label class="fl">分发到库首页「文章区」(不勾选=不在科普 / 红队 / 蓝队库首页展示)</label> + <div class="field-row" style="gap:18px;margin:6px 0"> + <label class="seg-check"><input type="checkbox" name="board_cats[]" value="popular"<?= in_array('popular', $artBc, true) ? ' checked' : '' ?>>科普库首页</label> + <label class="seg-check"><input type="checkbox" name="board_cats[]" value="red"<?= in_array('red', $artBc, true) ? ' checked' : '' ?>>红队库首页</label> + <label class="seg-check"><input type="checkbox" name="board_cats[]" value="blue"<?= in_array('blue', $artBc, true) ? ' checked' : '' ?>>蓝队库首页</label> + </div> <label class="fl">正文(Markdown)</label> <textarea name="markdown" rows="16" style="font-family:Consolas,Menlo,monospace;font-size:13px"><?= he($cur['md']) ?></textarea> <div class="field-row" style="margin-top:8px;align-items:center"> diff --git a/admin/content.php b/admin/content.php index 9488cde..0183b6b 100644 --- a/admin/content.php +++ b/admin/content.php @@ -51,6 +51,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $msgKind = 'ok'; $msgText = '站点信息已保存。'; break; + case 'tips': + header('Content-Type: application/json; charset=utf-8'); + settings_set('show_st_tips', (isset($_POST['show_tips']) && (string)$_POST['show_tips'] === '1') ? '1' : '0'); + touch_last_updated(); + echo json_encode(['ok' => true, 'msg' => '站长提示开关已保存。'], JSON_UNESCAPED_UNICODE); + exit; case 'boards': $names = (array)($_POST['b_name'] ?? []); $descs = (array)($_POST['b_desc'] ?? []); @@ -265,6 +271,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } } break; + case 'qtoggle': + header('Content-Type: application/json; charset=utf-8'); + $qlId = (int)($_POST['id'] ?? 0); + $qlEn = ((string)($_POST['enabled'] ?? '') === '1') ? 1 : 0; + if ($qlId > 0) { + $pdo->prepare('UPDATE quick_links SET enabled = ? WHERE id = ?')->execute([$qlEn, $qlId]); + touch_last_updated(); + echo json_encode(['ok' => true]); + } else { + echo json_encode(['ok' => false, 'msg' => '无效的记录 ID。'], JSON_UNESCAPED_UNICODE); + } + exit; case 'pwd': $np = (string)($_POST['new_pwd'] ?? ''); $np2 = (string)($_POST['new_pwd2'] ?? ''); @@ -315,6 +333,17 @@ admin_topbar('content'); <?php if ($msgText !== ''): alert_html($msgKind, $msgText); endif; ?> <div class="form-grid"> + <!-- 站长提示总开关 --> + <div class="fieldset-card"> + <div class="fs-title">站长提示(全站辅助说明文案总开关)</div> + <?= csrf_field() ?> + <div class="field-row" style="align-items:center;margin:2px 0"> + <label class="sw" title="点击立即启用/关闭站长提示(即时生效,无需保存)"><input type="checkbox" class="sw-in tips-toggle"<?= ((string)($all['show_st_tips'] ?? '1') === '1') ? ' checked' : '' ?>><span class="sw-b"><span class="sw-on">ON</span><span class="sw-off">OFF</span></span></label> + <span class="tip" style="margin:0">开启时,前台显示“可切换搜索引擎进行搜索…”等辅助旁白</span> + </div> + <p class="tip" style="margin-top:6px">前台类似“可切换搜索引擎进行搜索(4 个可用,后台可维护)”、各分区说明条等辅助旁白,以及文案中带后台入口字样的引导句,均带 <code>st-tip</code> 标记;关闭后这些文案在全部前台页面统一隐藏,站点导航、搜索、内容等核心功能不受影响。各分区/页面在没有数据时展示的空态占位框(如“该库还没有导航内容”)不受本开关影响,会始终显示以避免空页突兀。</p> + </div> + <!-- 站点信息 --> <div class="fieldset-card"> <div class="fs-title">站点信息</div> @@ -504,7 +533,7 @@ admin_topbar('content'); <input type="text" class="l-name" value="<?= he((string)$ql['name']) ?>" placeholder="站点名称(必填)"> <input type="text" class="l-url" value="<?= he((string)$ql['url']) ?>" placeholder="完整网址,如 https://子站.域名.com"> <input type="text" class="l-note" value="<?= he((string)($ql['note'] ?? '')) ?>" placeholder="备注 / 网站介绍(悬停首页按钮显示)"> - <label class="t-on"><input type="checkbox" class="l-enabled"<?= (int)$ql['enabled'] === 1 ? ' checked' : '' ?>> 启用</label> + <label class="sw" title="点击立即启停(即时生效,无需保存)"><input type="checkbox" class="sw-in l-enabled"<?= (int)$ql['enabled'] === 1 ? ' checked' : '' ?>><span class="sw-b"><span class="sw-on">ON</span><span class="sw-off">OFF</span></span></label> <span class="ops"> <button type="button" class="btn btn-sm l-up" title="上移">↑</button> <button type="button" class="btn btn-sm l-down" title="下移">↓</button> @@ -602,7 +631,7 @@ admin_topbar('content'); '<input type="text" class="l-name" value="" placeholder="站点名称(必填)">' + '<input type="text" class="l-url" value="" placeholder="完整网址,如 https://子站.域名.com">' + '<input type="text" class="l-note" value="" placeholder="备注 / 网站介绍(悬停首页按钮显示)">' + - '<label class="t-on"><input type="checkbox" class="l-enabled" checked> 启用</label>' + + '<label class="sw" title="点击立即启停(即时生效,无需保存)"><input type="checkbox" class="sw-in l-enabled" checked><span class="sw-b"><span class="sw-on">ON</span><span class="sw-off">OFF</span></span></label>' + '<span class="ops">' + '<button type="button" class="btn btn-sm l-up" title="上移">↑</button>' + '<button type="button" class="btn btn-sm l-down" title="下移">↓</button>' + @@ -625,6 +654,36 @@ admin_topbar('content'); document.getElementById('qlAdd').addEventListener('click', function () { qlRows.insertAdjacentHTML('beforeend', qRowHTML()); }); + qlRows.addEventListener('change', function (ev) { + var input = ev.target; + if (!input.classList.contains('l-enabled')) return; + var row = input.closest('.l-row'); + var id = row ? parseInt(row.getAttribute('data-id'), 10) : 0; + var csrfEl = document.querySelector('input[name="csrf"]'); + if (!csrfEl) return; + if (!id) { + input.checked = !input.checked; + alert('该行尚未保存过,请先点击「保存快捷站点」生成记录,之后即可直接拨动开关启停。'); + return; + } + var fd = new FormData(); + fd.append('act', 'qtoggle'); + fd.append('csrf', csrfEl.value); + fd.append('id', String(id)); + fd.append('enabled', input.checked ? '1' : '0'); + fetch(window.location.href, { method: 'POST', body: fd, credentials: 'same-origin' }) + .then(function (res) { return res.json().catch(function () { return { ok: false, msg: '服务器返回异常。' }; }); }) + .then(function (j) { + if (!j || j.ok !== true) { + input.checked = !input.checked; + alert('启停失败:' + ((j && j.msg) || '未知错误')); + } + }) + .catch(function () { + input.checked = !input.checked; + alert('网络错误,启停未保存。'); + }); + }); document.getElementById('qlForm').addEventListener('submit', function (ev) { var list = [], bad = false, badMsg = ''; Array.prototype.forEach.call(qlRows.querySelectorAll('.l-row'), function (r, i) { @@ -659,5 +718,32 @@ admin_topbar('content'); }); })(); </script> +<script> +(function () { + 'use strict'; + var tips = document.querySelector('.tips-toggle'); + if (!tips) return; + tips.addEventListener('change', function () { + var csrfEl = document.querySelector('input[name="csrf"]'); + if (!csrfEl) return; + var fd = new FormData(); + fd.append('act', 'tips'); + fd.append('csrf', csrfEl.value); + fd.append('show_tips', tips.checked ? '1' : '0'); + fetch(window.location.href, { method: 'POST', body: fd, credentials: 'same-origin' }) + .then(function (res) { return res.json().catch(function () { return { ok: false, msg: '服务器返回异常。' }; }); }) + .then(function (j) { + if (!j || j.ok !== true) { + tips.checked = !tips.checked; + alert('开关更新失败:' + ((j && j.msg) || '未知错误')); + } + }) + .catch(function () { + tips.checked = !tips.checked; + alert('网络错误,开关更新未保存。'); + }); + }); +})(); +</script> </body> </html> diff --git a/admin/exttools.php b/admin/exttools.php new file mode 100644 index 0000000..225db74 --- /dev/null +++ b/admin/exttools.php @@ -0,0 +1,552 @@ +<?php +/** + * admin/exttools.php —— 外部工具区管理(库首页“外部工具”Tab) + * 功能:分区 / 名称 / 网址 / 简介 / 展示范围(科普、红队、蓝队库首页)增删改、排序、启停; + * 链接多时可按 分区/栏目 + 启停状态 + 展示范围 + 关键词 快速筛选定位,并按分区折叠分组编辑; + * 支持按 SecHub/assets/template.json 格式批量导入({section} 分区标记 + {name/url/description} 工具行)。 + * 前端库首页按 ext_links.cat_keys 过滤展示:留空 = 所有库通用;grp = 分区标题,留空不分组平铺。 + */ +$P = '../'; +require_once __DIR__ . '/_guard.php'; + +$pdo = db(); +$saved = isset($_GET['ok']); +$errorMsg = ''; + +// ---------- 保存(完整重同步) ---------- +if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save') { + if (!csrf_verify()) { + $errorMsg = '安全校验失败,请刷新页面重试。'; + } else { + $raw = (string)($_POST['data'] ?? ''); + $rows = json_decode($raw, true); + if (!is_array($rows)) { + $errorMsg = '提交数据格式错误。'; + } else { + $clean = []; + foreach ($rows as $i => $r) { + if (!is_array($r)) continue; + $name = trim((string)($r['name'] ?? '')); + $grp = trim(preg_replace('/\s+/', ' ', (string)($r['grp'] ?? ''))); + $desc = trim((string)($r['description'] ?? '')); + $url = trim((string)($r['url'] ?? '')); + $cats = []; + if (isset($r['cats']) && is_array($r['cats'])) { + foreach ($r['cats'] as $ck) { + $s = trim((string)$ck); + if (in_array($s, ['popular', 'red', 'blue'], true) && !in_array($s, $cats, true)) { + $cats[] = $s; + } + } + } + if ($name === '' && $url === '') continue; // 空行忽略 + if ($name === '' || $url === '') { + $errorMsg = '第 ' . ((int)$i + 1) . ' 行:名称与网址均不能为空。'; + break; + } + if (!preg_match('#^https?://#i', $url)) { + $errorMsg = '第 ' . ((int)$i + 1) . ' 行:外部工具需以 http(s):// 开头的完整网址。'; + break; + } + $clean[] = [ + 'id' => (int)($r['id'] ?? 0), + 'name' => $name, + 'grp' => $grp, + 'description' => $desc, + 'url' => $url, + 'cats' => implode(',', $cats), + 'enabled' => (((string)($r['enabled'] ?? '1') === '1')) ? 1 : 0, + ]; + } + if ($errorMsg === '') { + try { + $pdo->beginTransaction(); + $oldIds = array_map('intval', array_column($pdo->query('SELECT id FROM ext_links')->fetchAll(), 'id')); + $keep = []; + foreach ($clean as $c) { if ($c['id'] > 0) $keep[] = $c['id']; } + $del = array_values(array_diff($oldIds, $keep)); + foreach ($del as $did) { + $pdo->prepare('DELETE FROM ext_links WHERE id = ?')->execute([$did]); + } + $stUpd = $pdo->prepare('UPDATE ext_links SET name=?, url=?, description=?, grp=?, cat_keys=?, enabled=?, sort=? WHERE id=?'); + $stIns = $pdo->prepare('INSERT INTO ext_links (name, url, description, grp, cat_keys, enabled, sort) VALUES (?,?,?,?,?,?,?)'); + $sort = 0; + foreach ($clean as $c) { + $sort++; + if ($c['id'] > 0 && in_array($c['id'], $oldIds, true)) { + $stUpd->execute([$c['name'], $c['url'], $c['description'], $c['grp'], $c['cats'], $c['enabled'], $sort, $c['id']]); + } else { + $stIns->execute([$c['name'], $c['url'], $c['description'], $c['grp'], $c['cats'], $c['enabled'], $sort]); + } + } + $pdo->commit(); + touch_last_updated(); + header('Location: exttools.php?ok=1'); + exit; + } catch (Throwable $e) { + if ($pdo->inTransaction()) $pdo->rollBack(); + $errorMsg = '保存失败:' . $e->getMessage(); + } + } + } + } +} + +// ---------- 行内即时启停(单条生效,无需整表保存) ---------- +if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'toggle') { + header('Content-Type: application/json; charset=utf-8'); + if (!csrf_verify()) { + echo json_encode(['ok' => false, 'msg' => '安全校验失败,请刷新页面后重试。'], JSON_UNESCAPED_UNICODE); + } else { + $tid = (int)($_POST['id'] ?? 0); + $ten = ((string)($_POST['enabled'] ?? '') === '1') ? 1 : 0; + if ($tid > 0) { + $pdo->prepare('UPDATE ext_links SET enabled = ? WHERE id = ?')->execute([$ten, $tid]); + touch_last_updated(); + echo json_encode(['ok' => true]); + } else { + echo json_encode(['ok' => false, 'msg' => '无效的记录 ID。'], JSON_UNESCAPED_UNICODE); + } + } + exit; +} + +// ---------- 展示(按 sort 排序) ---------- +$tools = $pdo->query('SELECT * FROM ext_links ORDER BY sort ASC, id ASC')->fetchAll(); +// 供“分区”筛选下拉使用(按出现顺序去重) +$grpNames = []; +foreach ($tools as $t) { + $g = trim((string)($t['grp'] ?? '')); + if ($g !== '' && !in_array($g, $grpNames, true)) { + $grpNames[] = $g; + } +} + +layout_head('外部工具区管理'); +admin_topbar('exttools'); +?> +<main class="page-main"> + <div class="wrap page-body"> + <h2 style="margin-bottom:6px">外部工具区管理</h2> + <p class="tip"> + 链接较多时可先筛选再编辑:按 分区/栏目、启停状态、展示范围、关键词 快速过滤(仅影响当前浏览,不影响保存)。 + 行首“分区 / 栏目”即前台分组标题(如“合集 / 导航”),<strong>留空 = 不分组平铺</strong>; + 展示范围勾选库,<strong>全部不勾选 = 所有库通用</strong>。 + </p> + + <?php if ($saved): ?> + <div class="ok-msg">保存成功,库首页外部工具区已同步更新。</div> + <?php endif; ?> + <?php if ($errorMsg !== ''): ?> + <div class="err-msg"><?= he($errorMsg) ?></div> + <?php endif; ?> + + <!-- 筛选工具条 --> + <div class="fieldset-card"> + <div class="ext-filter"> + <select id="fGrp" title="按分区/栏目筛选"> + <option value="">分区:全部</option> + <option value="__none__">未分组</option> + <?php foreach ($grpNames as $gn): ?> + <option value="<?= he($gn) ?>"><?= he($gn) ?></option> + <?php endforeach; ?> + </select> + <select id="fState" title="按启停状态筛选"> + <option value="">状态:全部</option> + <option value="1">启用</option> + <option value="0">停用</option> + </select> + <select id="fLib" title="按展示范围筛选(通用=所有库可见)"> + <option value="">展示范围:不限</option> + <option value="__gen__">仅通用(所有库)</option> + <option value="popular">含科普库</option> + <option value="red">含红队库</option> + <option value="blue">含蓝队库</option> + </select> + <input type="text" id="fKw" placeholder="关键词:匹配名称 / 简介 / 网址 / 分区…"> + <button type="button" class="btn btn-sm" id="fClear">清空筛选</button> + <button type="button" class="btn btn-sm" id="foldAll" title="折叠/展开所有分区">收起全部</button> + <span class="ext-stat" id="extStat"></span> + </div> + </div> + + <!-- 批量导入(模板格式与 SecHub/assets/template.json 一致) --> + <div class="fieldset-card"> + <div class="fs-title">批量导入外部工具(快速导入,自动带分区)</div> + <textarea id="extImportTa" rows="8" spellcheck="false" style="font-family:Consolas,Menlo,monospace;font-size:13px" placeholder='[ { "section": "合集" }, { "name": "工具A", "url": "https://example.com/a", "description": "一句话简介" }, { "name": "工具B", "url": "https://example.com/b" }, { "section": "导航" }, { "name": "站点C", "url": "https://example.com/c" } ]'></textarea> + <p class="tip" style="margin-top:6px"> + 数组内可穿插分区标记 <code>{"section":"栏目名"}</code>(其后的工具归入该分区,直至下一分区标记); + 工具行为 <code>{"name","url","description"}</code>,名称与网址缺失的空行会自动忽略。 + 点击「解析并追加」后将<strong>追加到对应分区下方</strong>,核对后统一点「保存外部工具」生效。 + </p> + <div style="margin-top:8px"><button type="button" class="btn btn-sm btn-primary" id="extImportBtn">解析并追加到列表</button></div> + </div> + + <!-- 分区折叠编辑列表 --> + <div class="fieldset-card"> + <form method="post" id="extForm" autocomplete="off"> + <?= csrf_field() ?> + <input type="hidden" name="action" value="save"> + <textarea name="data" id="extData" hidden></textarea> + + <div id="extRows"> + <?php + $extGroups = []; + foreach ($tools as $t) { + $g = trim((string)($t['grp'] ?? '')); + $extGroups[$g === '' ? "\x00" : $g][] = $t; + } + foreach ($extGroups as $gk => $gItems): + $isNone = ($gk === "\x00"); + $gLabel = $isNone ? '未分组' : $gk; + $gData = $isNone ? '__none__' : $gk; + ?> + <div class="xg" data-g="<?= he($gData) ?>"> + <div class="xg-h" title="点击折叠 / 展开"> + <button type="button" class="xg-arr" aria-hidden="true">▾</button> + <span class="xg-name"><?= he($gLabel) ?></span> + <span class="xg-cnt"><?= count($gItems) ?> 条</span> + <?php if ($isNone): ?> + <span class="xg-hint tip">分区留空的工具在此平铺</span> + <?php endif; ?> + </div> + <div class="xg-b"> + <?php foreach ($gItems as $t): + $cats = array_map('trim', explode(',', trim((string)$t['cat_keys']))); ?> + <div class="t-row" data-id="<?= (int)$t['id'] ?>"> + <input type="text" class="t-grp" value="<?= he((string)($t['grp'] ?? '')) ?>" placeholder="分区 / 栏目(如:合集 / 导航)" title="前台按此分区加标题分组展示;留空则不分组平铺"> + <input type="text" class="t-name" value="<?= he((string)$t['name']) ?>" placeholder="工具名称(必填)"> + <input type="text" class="t-desc" value="<?= he((string)$t['description']) ?>" placeholder="一句话简介"> + <input type="text" class="t-url" value="<?= he((string)$t['url']) ?>" placeholder="完整网址,如 https://example.com"> + <span class="t-cats" title="展示范围(不勾选=所有库通用)"> + <span class="t-cats-t">展示于</span> + <label><input type="checkbox" class="t-catc" data-k="popular"<?= in_array('popular', $cats, true) ? ' checked' : '' ?>>科普</label> + <label><input type="checkbox" class="t-catc" data-k="red"<?= in_array('red', $cats, true) ? ' checked' : '' ?>>红队</label> + <label><input type="checkbox" class="t-catc" data-k="blue"<?= in_array('blue', $cats, true) ? ' checked' : '' ?>>蓝队</label> + </span> + <label class="sw" title="点击立即启停(即时生效,无需保存)"><input type="checkbox" class="sw-in t-enabled"<?= (int)$t['enabled'] === 1 ? ' checked' : '' ?>><span class="sw-b"><span class="sw-on">ON</span><span class="sw-off">OFF</span></span></label> + <span class="ops"> + <button type="button" class="btn btn-sm t-up" title="同分区内上移">↑</button> + <button type="button" class="btn btn-sm t-down" title="同分区内下移">↓</button> + <button type="button" class="btn btn-sm btn-danger t-del" title="删除该工具">✕</button> + </span> + </div> + <?php endforeach; ?> + </div> + </div> + <?php endforeach; ?> + </div> + + <div class="field-row" style="margin:12px 0"> + <button type="button" class="btn btn-sm" id="extAdd">+ 添加工具(未分组)</button> + <button type="submit" class="btn btn-primary">保存外部工具</button> + <span class="tip">筛选只是便于浏览,全部行都会随「保存」提交</span> + </div> + </form> + </div> + </div> +</main> + +<?php layout_theme_fab(); ?> +<script src="../assets/js/common.js"></script> +<script> +(function () { + 'use strict'; + var rows = document.getElementById('extRows'); + + function esc(s) { + return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>'); + } + function rowHTML(grp, name, desc, url) { + grp = grp || ''; name = name || ''; desc = desc || ''; url = url || ''; + return '<div class="t-row" data-id="0">' + + '<input type="text" class="t-grp" value="' + esc(grp) + '" placeholder="分区 / 栏目(如:合集 / 导航)" title="前台按此分区加标题分组展示;留空则不分组直接平铺">' + + '<input type="text" class="t-name" value="' + esc(name) + '" placeholder="工具名称(必填)">' + + '<input type="text" class="t-desc" value="' + esc(desc) + '" placeholder="一句话简介">' + + '<input type="text" class="t-url" value="' + esc(url) + '" placeholder="完整网址,如 https://example.com">' + + '<span class="t-cats" title="展示范围(不勾选=所有库通用)">' + + '<span class="t-cats-t">展示于</span>' + + '<label><input type="checkbox" class="t-catc" data-k="popular">科普</label>' + + '<label><input type="checkbox" class="t-catc" data-k="red">红队</label>' + + '<label><input type="checkbox" class="t-catc" data-k="blue">蓝队</label>' + + '</span>' + + '<label class="sw" title="点击立即启停(即时生效,无需保存)"><input type="checkbox" class="sw-in t-enabled" checked><span class="sw-b"><span class="sw-on">ON</span><span class="sw-off">OFF</span></span></label>' + + '<span class="ops">' + + '<button type="button" class="btn btn-sm t-up" title="同分区内上移">↑</button>' + + '<button type="button" class="btn btn-sm t-down" title="同分区内下移">↓</button>' + + '<button type="button" class="btn btn-sm btn-danger t-del" title="删除该工具">✕</button>' + + '</span></div>'; + } + + function allGroups() { + var out = []; + Array.prototype.forEach.call(rows.children, function (c) { + if (c.classList.contains('xg')) out.push(c); + }); + return out; + } + function findGroup(key) { + var found = null; + Array.prototype.forEach.call(rows.children, function (c) { + if (!found && c.classList.contains('xg') && c.getAttribute('data-g') === key) found = c; + }); + return found; + } + function ensureGroup(key) { + var cur = findGroup(key); + if (cur) return cur; + var label = key === '__none__' ? '未分组' : key; + var xg = document.createElement('div'); + xg.className = 'xg'; + xg.setAttribute('data-g', key); + xg.innerHTML = '<div class="xg-h" title="点击折叠 / 展开">' + + '<button type="button" class="xg-arr" aria-hidden="true">▾</button>' + + '<span class="xg-name">' + esc(label) + '</span>' + + '<span class="xg-cnt"></span>' + + (key === '__none__' ? '<span class="xg-hint tip">分区留空的工具在此平铺</span>' : '') + + '</div><div class="xg-b"></div>'; + rows.appendChild(xg); + return xg; + } + function appendRowTo(grpName, html) { + var key = grpName && grpName.trim() ? grpName.trim() : '__none__'; + var xg = ensureGroup(key); + var tmp = document.createElement('div'); + tmp.innerHTML = html; + xg.querySelector('.xg-b').appendChild(tmp.firstChild); + } + function pruneEmptyGroups() { + var xgs = allGroups(); + for (var i = xgs.length - 1; i >= 0; i--) { + if (!xgs[i].querySelector('.t-row')) xgs[i].remove(); + } + } + function refreshStats() { + var total = rows.querySelectorAll('.t-row').length; + Array.prototype.forEach.call(allGroups(), function (xg) { + var cnt = xg.querySelectorAll('.t-row').length; + var c = xg.querySelector('.xg-cnt'); + if (c) c.textContent = cnt + ' 条'; + }); + applyFilter(); + var stat = document.getElementById('extStat'); + if (stat) stat.textContent = '共 ' + total + ' 条'; + } + + // ---------- 筛选 ---------- + var fGrp = document.getElementById('fGrp'); + var fState = document.getElementById('fState'); + var fLib = document.getElementById('fLib'); + var fKw = document.getElementById('fKw'); + function rowVisible(r) { + var gv = (r.querySelector('.t-grp').value || '').trim(); + if (fGrp.value) { + if (fGrp.value === '__none__') { if (gv !== '') return false; } + else if (gv !== fGrp.value) return false; + } + if (fState.value !== '') { + var on = r.querySelector('.t-enabled').checked ? 1 : 0; + if (String(on) !== fState.value) return false; + } + if (fLib.value) { + var any = false, hit = false; + var cs = r.querySelectorAll('.t-catc'); + for (var i = 0; i < cs.length; i++) { + if (cs[i].checked) { + any = true; + if (cs[i].getAttribute('data-k') === fLib.value) hit = true; + } + } + if (fLib.value === '__gen__') { if (any) return false; } + else if (any && !hit) return false; + } + if (fKw.value) { + var hay = ((r.querySelector('.t-name').value || '') + ' ' + + (r.querySelector('.t-desc').value || '') + ' ' + + (r.querySelector('.t-url').value || '') + ' ' + gv).toLowerCase(); + if (hay.indexOf(fKw.value.toLowerCase()) === -1) return false; + } + return true; + } + function applyFilter() { + var shown = 0, total = 0; + var all = rows.querySelectorAll('.t-row'); + Array.prototype.forEach.call(all, function (r) { + var ok = rowVisible(r); + r.classList.toggle('is-hide', !ok); + if (ok) shown++; + total++; + }); + Array.prototype.forEach.call(allGroups(), function (xg) { + var vis = false; + var rs = xg.querySelectorAll('.t-row'); + for (var i = 0; i < rs.length; i++) { + if (!rs[i].classList.contains('is-hide')) { vis = true; break; } + } + xg.classList.toggle('is-hide', !vis); + }); + var stat = document.getElementById('extStat'); + if (stat) stat.textContent = total ? ('显示 ' + shown + ' / 共 ' + total + ' 条') : '共 0 条'; + } + var filterEls = [fGrp, fState, fLib]; + for (var fi = 0; fi < filterEls.length; fi++) { + if (filterEls[fi]) filterEls[fi].addEventListener('change', applyFilter); + } + if (fKw) fKw.addEventListener('input', applyFilter); + if (document.getElementById('fClear')) { + document.getElementById('fClear').addEventListener('click', function () { + fGrp.value = ''; fState.value = ''; fLib.value = ''; fKw.value = ''; + applyFilter(); + }); + } + if (document.getElementById('foldAll')) { + document.getElementById('foldAll').addEventListener('click', function () { + var xgs = allGroups(); + var anyOpen = false; + for (var i = 0; i < xgs.length; i++) { + if (!xgs[i].classList.contains('is-fold')) { anyOpen = true; break; } + } + for (var j = 0; j < xgs.length; j++) { + xgs[j].classList.toggle('is-fold', anyOpen); + } + var btn = document.getElementById('foldAll'); + btn.textContent = anyOpen ? '展开全部' : '收起全部'; + }); + } + + // ---------- 组头折叠 / 行操作 ---------- + rows.addEventListener('click', function (ev) { + var xgh = ev.target.closest('.xg-h'); + if (xgh) { + xgh.parentNode.classList.toggle('is-fold'); + return; + } + var row = ev.target.closest('.t-row'); + if (!row) return; + if (ev.target.classList.contains('t-del')) { + if (confirm('确认删除该外部工具?')) { + row.remove(); + pruneEmptyGroups(); + refreshStats(); + } + } else if (ev.target.classList.contains('t-up') || ev.target.classList.contains('t-down')) { + var sib = ev.target.classList.contains('t-up') ? row.previousElementSibling : row.nextElementSibling; + if (sib && sib.classList.contains('t-row')) { + if (ev.target.classList.contains('t-up')) row.parentNode.insertBefore(row, sib); + else row.parentNode.insertBefore(sib, row); + } + } + }); + + // 分区/启停/展示范围 等变更 + rows.addEventListener('change', function (ev) { + var t = ev.target; + if (t.classList.contains('t-enabled')) { + var input = t; + var row = input.closest('.t-row'); + var id = row ? parseInt(row.getAttribute('data-id'), 10) : 0; + var csrfEl = document.querySelector('input[name="csrf"]'); + if (!csrfEl) return; + if (!id) { + input.checked = !input.checked; + alert('该行尚未保存过,请先点击「保存外部工具」生成记录,之后即可直接拨动开关启停。'); + return; + } + var fd = new FormData(); + fd.append('action', 'toggle'); + fd.append('csrf', csrfEl.value); + fd.append('id', String(id)); + fd.append('enabled', input.checked ? '1' : '0'); + fetch(window.location.href, { method: 'POST', body: fd, credentials: 'same-origin' }) + .then(function (res) { return res.json().catch(function () { return { ok: false, msg: '服务器返回异常。' }; }); }) + .then(function (j) { + if (!j || j.ok !== true) { + input.checked = !input.checked; + alert('启停失败:' + ((j && j.msg) || '未知错误')); + } + }) + .catch(function () { + input.checked = !input.checked; + alert('网络错误,启停未保存。'); + }); + return; + } + if (t.classList.contains('t-grp')) { + var row2 = t.closest('.t-row'); + var key = t.value.trim() === '' ? '__none__' : t.value.trim(); + var xg = ensureGroup(key); + xg.querySelector('.xg-b').appendChild(row2); + pruneEmptyGroups(); + refreshStats(); + return; + } + if (t.classList.contains('t-catc')) { + applyFilter(); + } + }); + + // ---------- 新增 / 批量导入 ---------- + document.getElementById('extAdd').addEventListener('click', function () { + appendRowTo('', rowHTML('', '', '', '')); + refreshStats(); + }); + + var importBtn = document.getElementById('extImportBtn'); + if (importBtn) { + importBtn.addEventListener('click', function () { + var ta = document.getElementById('extImportTa'); + var txt = (ta ? ta.value : '').trim(); + if (!txt) { alert('请先在上方粘贴 JSON 数据(可参考 SecHub/assets/template.json)。'); return; } + var arr; + try { arr = JSON.parse(txt); } catch (e) { alert('JSON 解析失败:' + e.message); return; } + if (!Array.isArray(arr)) { alert('JSON 顶层需为数组(与 SecHub/assets/template.json 相同)。'); return; } + var cur = '', added = 0, bad = 0; + Array.prototype.forEach.call(arr, function (it) { + if (!it || typeof it !== 'object') return; + if (it.section !== undefined) { cur = String(it.section).trim(); return; } + var nm = String(it.name || '').trim(); + var ur = String(it.url || '').trim(); + var de = String(it.description || '').trim(); + if (!nm && !ur) return; + if (!nm || !ur) { bad++; return; } + appendRowTo(cur, rowHTML(cur, nm, de, ur)); + added++; + }); + refreshStats(); + var msg = '解析完成:新增 ' + added + ' 行,已归入对应分区。'; + if (bad > 0) msg += '另有 ' + bad + ' 行因缺少名称或网址被跳过。'; + msg += '\n请核对分区与内容后点击「保存外部工具」生效。'; + alert(msg); + }); + } + + // ---------- 保存:收集与前端校验 ---------- + document.getElementById('extForm').addEventListener('submit', function (ev) { + var list = [], bad = false, badMsg = ''; + Array.prototype.forEach.call(rows.querySelectorAll('.t-row'), function (r, i) { + if (bad) return; + var grp = r.querySelector('.t-grp').value.trim(); + var name = r.querySelector('.t-name').value.trim(); + var desc = r.querySelector('.t-desc').value.trim(); + var url = r.querySelector('.t-url').value.trim(); + var enabled = r.querySelector('.t-enabled').checked ? '1' : '0'; + var cats = []; + var catInputs = r.querySelectorAll('.t-catc'); + for (var cc = 0; cc < catInputs.length; cc++) { + if (catInputs[cc].checked) cats.push(catInputs[cc].getAttribute('data-k')); + } + if (!name && !url) return; + if (!name || !url) { bad = true; badMsg = '第 ' + (i + 1) + ' 行:名称与网址均不能为空。'; return; } + if (!/^https?:\/\//i.test(url)) { bad = true; badMsg = '第 ' + (i + 1) + ' 行:需填写 http(s):// 开头的完整网址。'; return; } + list.push({ id: parseInt(r.getAttribute('data-id'), 10) || 0, grp: grp, name: name, description: desc, url: url, enabled: enabled, cats: cats }); + }); + if (bad) { ev.preventDefault(); alert(badMsg); return; } + document.getElementById('extData').value = JSON.stringify(list); + }); + + // 初始状态 + refreshStats(); +})(); +</script> +</body> +</html> diff --git a/admin/tools.php b/admin/tools.php index 66ea426..8d79a40 100644 --- a/admin/tools.php +++ b/admin/tools.php @@ -31,6 +31,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save' $desc = trim((string)($r['description'] ?? '')); $ext = ((string)($r['ext'] ?? '0') === '1') ? 1 : 0; if ($name === '' && $url === '') continue; // 空行忽略 + // 库首页“内部功能区”展示范围(空=所有库通用) + $cats = []; + if (isset($r['cats']) && is_array($r['cats'])) { + foreach ($r['cats'] as $ck) { + $s = trim((string)$ck); + if (in_array($s, ['popular', 'red', 'blue'], true) && !in_array($s, $cats, true)) { + $cats[] = $s; + } + } + } + $boardCats = implode(',', $cats); if ($name === '' || $url === '') { $errorMsg = '第 ' . ((int)$i + 1) . ' 行:名称与地址均不能为空。'; break; @@ -58,6 +69,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save' 'url' => $url, 'ext' => $ext, 'enabled' => (((string)($r['enabled'] ?? '1') === '1')) ? 1 : 0, + 'board_cats' => $boardCats, ]; } if ($errorMsg === '') { @@ -75,15 +87,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save' foreach ($del as $did) { $pdo->prepare('DELETE FROM func_tools WHERE id = ?')->execute([$did]); } - $stUpd = $pdo->prepare('UPDATE func_tools SET name=?, icon=?, description=?, url=?, is_external=?, enabled=?, sort=? WHERE id=?'); - $stIns = $pdo->prepare('INSERT INTO func_tools (name, icon, description, url, is_external, enabled, sort) VALUES (?,?,?,?,?,?,?)'); + $stUpd = $pdo->prepare('UPDATE func_tools SET name=?, icon=?, description=?, url=?, is_external=?, enabled=?, board_cats=?, sort=? WHERE id=?'); + $stIns = $pdo->prepare('INSERT INTO func_tools (name, icon, description, url, is_external, enabled, board_cats, sort) VALUES (?,?,?,?,?,?,?,?)'); $sort = 0; foreach ($clean as $c) { $sort++; if ($c['id'] > 0 && in_array($c['id'], $oldIds, true)) { - $stUpd->execute([$c['name'], $c['icon'], $c['description'], $c['url'], $c['ext'], $c['enabled'], $sort, $c['id']]); + $stUpd->execute([$c['name'], $c['icon'], $c['description'], $c['url'], $c['ext'], $c['enabled'], $c['board_cats'], $sort, $c['id']]); } else { - $stIns->execute([$c['name'], $c['icon'], $c['description'], $c['url'], $c['ext'], $c['enabled'], $sort]); + $stIns->execute([$c['name'], $c['icon'], $c['description'], $c['url'], $c['ext'], $c['enabled'], $c['board_cats'], $sort]); } } $pdo->commit(); @@ -102,6 +114,25 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save' } } +// ---------- 行内即时启停(单条生效,无需整表保存) ---------- +if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'toggle') { + header('Content-Type: application/json; charset=utf-8'); + if (!csrf_verify()) { + echo json_encode(['ok' => false, 'msg' => '安全校验失败,请刷新页面后重试。'], JSON_UNESCAPED_UNICODE); + } else { + $tid = (int)($_POST['id'] ?? 0); + $ten = ((string)($_POST['enabled'] ?? '') === '1') ? 1 : 0; + if ($tid > 0) { + $pdo->prepare('UPDATE func_tools SET enabled = ? WHERE id = ?')->execute([$ten, $tid]); + touch_last_updated(); + echo json_encode(['ok' => true]); + } else { + echo json_encode(['ok' => false, 'msg' => '无效的记录 ID。'], JSON_UNESCAPED_UNICODE); + } + } + exit; +} + // ---------- 展示现有工具 ---------- $tools = $pdo->query('SELECT * FROM func_tools ORDER BY sort ASC, id ASC')->fetchAll(); @@ -145,7 +176,14 @@ admin_topbar('tools'); <option value="0"<?= (int)$t['is_external'] === 0 ? ' selected' : '' ?>>站内页面</option> <option value="1"<?= (int)$t['is_external'] === 1 ? ' selected' : '' ?>>外部链接</option> </select> - <label class="t-on"><input type="checkbox" class="t-enabled"<?= (int)$t['enabled'] === 1 ? ' checked' : '' ?>> 启用</label> + <?php $bcT = array_map('trim', explode(',', trim((string)($t['board_cats'] ?? '')))); ?> + <span class="t-cats" title="在哪些库首页“内部功能区”展示(不勾选=所有库通用)"> + <span class="t-cats-t">库首页</span> + <label><input type="checkbox" class="t-catc" data-k="popular"<?= in_array('popular', $bcT, true) ? ' checked' : '' ?>>科普</label> + <label><input type="checkbox" class="t-catc" data-k="red"<?= in_array('red', $bcT, true) ? ' checked' : '' ?>>红队</label> + <label><input type="checkbox" class="t-catc" data-k="blue"<?= in_array('blue', $bcT, true) ? ' checked' : '' ?>>蓝队</label> + </span> + <label class="sw" title="点击立即启停(即时生效,无需保存)"><input type="checkbox" class="sw-in t-enabled"<?= (int)$t['enabled'] === 1 ? ' checked' : '' ?>><span class="sw-b"><span class="sw-on">ON</span><span class="sw-off">OFF</span></span></label> <span class="ops"> <button type="button" class="btn btn-sm t-up" title="上移">↑</button> <button type="button" class="btn btn-sm t-down" title="下移">↓</button> @@ -187,13 +225,48 @@ admin_topbar('tools'); '<input type="text" class="t-desc" value="" placeholder="一句话说明">' + '<input type="text" class="t-url" value="" placeholder="站内如 codec.php / 外链 https://…">' + '<select class="t-ext"><option value="0">站内页面</option><option value="1">外部链接</option></select>' + - '<label class="t-on"><input type="checkbox" class="t-enabled" checked> 启用</label>' + + '<span class="t-cats" title="在哪些库首页“内部功能区”展示(不勾选=所有库通用)">' + + '<span class="t-cats-t">库首页</span>' + + '<label><input type="checkbox" class="t-catc" data-k="popular">科普</label>' + + '<label><input type="checkbox" class="t-catc" data-k="red">红队</label>' + + '<label><input type="checkbox" class="t-catc" data-k="blue">蓝队</label>' + + '</span>' + + '<label class="sw" title="点击立即启停(即时生效,无需保存)"><input type="checkbox" class="sw-in t-enabled" checked><span class="sw-b"><span class="sw-on">ON</span><span class="sw-off">OFF</span></span></label>' + '<span class="ops">' + '<button type="button" class="btn btn-sm t-up" title="上移">↑</button>' + '<button type="button" class="btn btn-sm t-down" title="下移">↓</button>' + '<button type="button" class="btn btn-sm btn-danger t-del" title="删除该工具">✕</button>' + '</span></div>'; } + function toggleEnabled(input) { + var row = input.closest('.t-row'); + var id = row ? parseInt(row.getAttribute('data-id'), 10) : 0; + var csrfEl = document.querySelector('input[name="csrf"]'); + if (!csrfEl) return; + if (!id) { + input.checked = !input.checked; + alert('该行尚未保存过,请先点击「保存功能区」生成记录,之后即可直接拨动开关启停。'); + return; + } + var fd = new FormData(); + fd.append('action', 'toggle'); + fd.append('csrf', csrfEl.value); + fd.append('id', String(id)); + fd.append('enabled', input.checked ? '1' : '0'); + fetch(window.location.href, { method: 'POST', body: fd, credentials: 'same-origin' }) + .then(function (res) { return res.json().catch(function () { return { ok: false, msg: '服务器返回异常。' }; }); }) + .then(function (j) { + if (!j || j.ok !== true) { + input.checked = !input.checked; + alert('启停失败:' + ((j && j.msg) || '未知错误')); + } + }) + .catch(function () { + input.checked = !input.checked; + alert('网络错误,启停未保存。'); + }); + } + function placeholderHint(select, urlInput) { urlInput.placeholder = select.value === '1' ? '外部完整网址,如 https://example.com' @@ -216,6 +289,8 @@ admin_topbar('tools'); rows.addEventListener('change', function (ev) { if (ev.target.classList.contains('t-ext')) { placeholderHint(ev.target, ev.target.parentNode.querySelector('.t-url')); + } else if (ev.target.classList.contains('t-enabled')) { + toggleEnabled(ev.target); } }); rows.querySelectorAll('.t-ext').forEach(function (sel) { @@ -236,6 +311,11 @@ admin_topbar('tools'); var url = r.querySelector('.t-url').value.trim(); var ext = r.querySelector('.t-ext').value; var enabled = r.querySelector('.t-enabled').checked ? '1' : '0'; + var cats = []; + var catInputs = r.querySelectorAll('.t-catc'); + for (var cc = 0; cc < catInputs.length; cc++) { + if (catInputs[cc].checked) cats.push(catInputs[cc].getAttribute('data-k')); + } if (!name && !url) return; if (!name || !url) { bad = true; badMsg = '第 ' + (i + 1) + ' 行:名称与地址均不能为空。'; return; } if (ext === '1' && !/^https?:\/\//i.test(url)) { bad = true; badMsg = '第 ' + (i + 1) + ' 行:外部链接需以 http(s):// 开头。'; return; } @@ -244,7 +324,7 @@ admin_topbar('tools'); list.push({ id: parseInt(r.getAttribute('data-id'), 10) || 0, icon: icon, name: name, description: desc, url: url, - ext: ext, enabled: enabled + ext: ext, enabled: enabled, cats: cats }); }); if (bad) { diff --git a/article/index.php b/article/index.php index 614a9a5..bed70aa 100644 --- a/article/index.php +++ b/article/index.php @@ -44,6 +44,8 @@ foreach ($partOrder as $part) { $rows[] = ['part' => $part, 'articles' => $items]; } $total = count($all); +// 从库首页“文章区”卡片进入阅读时(?from=nav),顶部返回按钮语义切换 +$fromNav = (isset($_GET['from']) && (string)$_GET['from'] === 'nav'); // 当前阅读文章 $cur = null; @@ -67,9 +69,16 @@ layout_head($cur ? (string)$cur['title'] : '文章库'); <!-- 顶部栏:返回首页 / 当页名(列表为文章库、阅读时显示文章标题)/ 分区·段落开关 --> <header class="art-topbar"> <div class="art-topbar-nav"> - <a class="btn btn-sm" href="../index.php" title="返回站点首页">← 首页</a> - <?php if ($cur): ?> + <?php if ($fromNav): ?> <a class="btn btn-sm" href="index.php" title="返回文章区首页(文章列表)">← 文章区首页</a> + <?php if ($cur): ?> + <a class="btn btn-sm" href="javascript:history.back()" title="返回上一页(回到来源页面)">← 返回上一页</a> + <?php endif; ?> + <?php else: ?> + <a class="btn btn-sm" href="../index.php" title="返回站点首页">← 首页</a> + <?php if ($cur): ?> + <a class="btn btn-sm" href="index.php" title="返回文章区首页(文章列表)">← 文章区首页</a> + <?php endif; ?> <?php endif; ?> </div> <div class="art-topbar-title"><?= $cur ? he((string)$cur['title']) : '📚 文章库' ?></div> @@ -146,10 +155,10 @@ layout_head($cur ? (string)$cur['title'] : '文章库'); <div class="art-list"> <div class="art-list-head"> <h1>全部文章 <span class="tip">(共 <?= $total ?> 篇)</span></h1> - <p class="tip">点击任意文章开始阅读;左侧「文章目录」可随时切换文章,中间「段落目录」可快速跳转。</p> + <p class="tip st-tip">点击任意文章开始阅读;左侧「文章目录」可随时切换文章,中间「段落目录」可快速跳转。</p> </div> <?php if (!$rows): ?> - <div class="panel-card"><p class="tip">文章库还没有内容,站长可在后台「文章管理」中发布。</p></div> + <div class="panel-card"><p class="tip">文章库还没有内容<span class="st-tip">,站长可在后台「文章管理」中发布</span>。</p></div> <?php endif; ?> <?php foreach ($rows as $g): ?> <div class="art-part-block"> diff --git a/assets/css/common.css b/assets/css/common.css index cdba311..34857bb 100644 --- a/assets/css/common.css +++ b/assets/css/common.css @@ -215,7 +215,11 @@ a.quick-chip[data-note]:hover::after { opacity: 1; transform: translateX(-50%) t font-size: 30px; color: #fff; background: var(--brand-grad); box-shadow: var(--shadow); } .board-name { font-size: 19px; font-weight: 700; } -.board-hint { font-size: 13px; color: var(--text-3); } +/* 板块说明文案常显(取消悬停浮层后最多两行,超出截断) */ +.board-hint { + font-size: 13px; color: var(--text-3); line-height: 1.5; max-width: 100%; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; +} .func-card .fc-icon img.icon-img { width: 24px; height: 24px; } /* 板块展开说明浮层 */ @@ -236,7 +240,7 @@ a.quick-chip[data-note]:hover::after { opacity: 1; transform: translateX(-50%) t .quote-pool { display: none; } /* ============ 页脚 ============ */ -.footer { background: var(--bg-2); border-top: 1px solid var(--line); color: var(--text-3); font-size: 13px; } +.footer { margin-top: auto; background: var(--bg-2); border-top: 1px solid var(--line); color: var(--text-3); font-size: 13px; } .footer-inner { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 22px 16px; text-align: center; } .footer .admin-link { opacity: .65; } .footer .admin-link:hover { opacity: 1; } @@ -445,9 +449,62 @@ a.nav-btn[data-note]:hover::after { opacity: 1; transform: translateX(-50%) tran .t-row .t-desc { flex: 1 1 170px; min-width: 110px; } .t-row .t-url, .l-row .l-url { flex: 2 1 230px; min-width: 150px; } .t-row .t-ext { flex: 0 1 132px; min-width: 0; width: 100%; } +.t-row .t-grp { flex: 0 1 168px; min-width: 120px; } .l-row .l-note { flex: 1 1 170px; min-width: 120px; } +/* 外部工具管理:分区分组折叠 */ +.xg { border: 1px dashed var(--line); border-radius: 12px; margin-bottom: 12px; overflow: hidden; } +.xg-h { display: flex; align-items: center; gap: 8px; padding: 8px 14px; background: var(--bg-2); cursor: pointer; user-select: none; } +.xg-h .xg-arr { flex: 0 0 auto; border: none; background: none; padding: 2px 4px; font-size: 12px; color: var(--text-3); cursor: pointer; transition: transform .18s ease; } +.xg.is-fold .xg-arr { transform: rotate(-90deg); } +.xg-h .xg-name { font-weight: 600; color: var(--text-2); } +.xg-h .xg-cnt { font-size: 12px; color: var(--text-3); } +.xg-h .xg-acts { margin-left: auto; display: flex; gap: 6px; align-items: center; } +.xg-b { padding: 4px 14px 10px; } +.xg-b .t-row:first-child { margin-top: 8px; } +.xg.is-fold .xg-b { display: none; } +.xg.is-hide { display: none; } +.t-row.is-hide { display: none !important; } +/* 外部工具管理:筛选工具条 */ +.ext-filter { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.ext-filter select { width: auto; min-width: 130px; flex: 0 1 auto; } +.ext-filter #fKw { flex: 1 1 220px; min-width: 180px; } +.ext-stat { font-size: 13px; color: var(--text-3); white-space: nowrap; margin-left: auto; } .t-row .ops, .l-row .ops { display: flex; gap: 6px; flex: 0 0 auto; margin-left: auto; } .t-on { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; color: var(--text-2); white-space: nowrap; flex: 0 0 auto; } +/* 滑动圆条开关:内部 OFF/ON 文本,用于后台行内即时启停 */ +.sw { position: relative; display: inline-flex; align-items: center; flex: 0 0 auto; cursor: pointer; user-select: none; } +.t-row .sw, .l-row .sw { flex: 0 0 auto; } +.sw input { position: absolute; opacity: 0; width: 1px; height: 1px; pointer-events: none; } +.sw .sw-b { + position: relative; display: inline-block; width: 58px; height: 27px; border-radius: 14px; + background: var(--line-2); overflow: hidden; transition: background .18s ease; + box-shadow: inset 0 0 0 1px rgba(0,0,0,.08); +} +.sw .sw-t { + position: absolute; top: 0; height: 100%; line-height: 27px; + font-size: 10px; font-weight: 700; letter-spacing: .5px; + transition: opacity .15s ease; pointer-events: none; +} +.sw .sw-on { left: 6px; color: #fff; opacity: 0; } +.sw .sw-off { right: 6px; color: var(--text-3); opacity: 1; } +.sw .sw-b::after { + content: ''; position: absolute; top: 2px; left: 2px; width: 23px; height: 23px; border-radius: 50%; + background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.28); transition: left .18s ease; +} +.sw input:checked + .sw-b { background: var(--ok); } +.sw input:checked + .sw-b::after { left: 33px; } +.sw input:checked + .sw-b .sw-on { opacity: 1; } +.sw input:checked + .sw-b .sw-off { opacity: 0; } +.sw input:focus-visible + .sw-b { box-shadow: 0 0 0 2px var(--brand); } +/* 后台行内:库首页展示范围多选 */ +.t-cats { + display: inline-flex; align-items: center; gap: 3px; flex-wrap: wrap; + font-size: 12px; color: var(--text-3); white-space: nowrap; flex: 0 1 auto; + border: 1px dashed var(--line-2); border-radius: 8px; padding: 4px 8px; +} +.t-cats .t-cats-t { margin-right: 3px; } +.t-cats label { display: inline-flex; align-items: center; gap: 3px; color: var(--text-2); } +.t-cats label input { margin: 0; accent-color: var(--brand); } .add-btn { margin: 6px 0 18px; } /* 登录页 */ diff --git a/func/_bar.php b/func/_bar.php index 2b518dd..a7556f5 100644 --- a/func/_bar.php +++ b/func/_bar.php @@ -6,9 +6,15 @@ function func_bar(string $title): void { + // 从库首页“内部功能区”卡片进入时(?from=nav)返回按钮改为“返回上一页” + $fromNav = (($_GET['from'] ?? '') === 'nav'); echo '<div class="func-bar">'; echo '<div class="wrap func-bar-inner">'; - echo '<a class="btn btn-sm" href="index.php">← 返回功能区首页</a>'; + if ($fromNav) { + echo '<a class="btn btn-sm" href="javascript:history.back()" title="返回来源页面">← 返回上一页</a>'; + } else { + echo '<a class="btn btn-sm" href="index.php">← 返回功能区首页</a>'; + } echo '<span class="func-bar-title">' . he($title) . '</span>'; echo '<a class="btn btn-sm" href="../index.php">首页</a>'; echo '</div></div>' . "\n"; diff --git a/func/index.php b/func/index.php index 75f088e..c488dfa 100644 --- a/func/index.php +++ b/func/index.php @@ -14,7 +14,7 @@ layout_head('功能区'); <section class="sub-hero"> <a class="back-home" href="../index.php">← 返回首页</a> <h1>功能区</h1> - <p>工具统一收纳于此;新工具的入口可在后台“功能区管理”中登记</p> + <p class="st-tip">工具统一收纳于此;新工具的入口可在后台“功能区管理”中登记</p> </section> <div class="wrap page-body"> @@ -36,7 +36,7 @@ layout_head('功能区'); </div> <?php else: ?> <div class="panel-card"> - <div class="tip">功能区还没有可用的工具,请到后台“功能区管理”中添加。</div> + <div class="tip">功能区还没有可用的工具<span class="st-tip">,请到后台“功能区管理”中添加</span>。</div> </div> <?php endif; ?> </div> diff --git a/includes/db.php b/includes/db.php index 276dc9b..fe3eea6 100644 --- a/includes/db.php +++ b/includes/db.php @@ -95,6 +95,17 @@ function db_init(PDO $pdo): void enabled INTEGER NOT NULL DEFAULT 1, sort INTEGER NOT NULL DEFAULT 0 )'); + $pdo->exec('CREATE TABLE IF NOT EXISTS ext_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + icon TEXT DEFAULT \'\', + url TEXT NOT NULL DEFAULT \'\', + description TEXT DEFAULT \'\', + grp TEXT DEFAULT \'\', + cat_keys TEXT DEFAULT \'\', + enabled INTEGER NOT NULL DEFAULT 1, + sort INTEGER NOT NULL DEFAULT 0 + )'); $pdo->exec('CREATE TABLE IF NOT EXISTS articles ( id INTEGER PRIMARY KEY AUTOINCREMENT, part TEXT NOT NULL DEFAULT \'未分类\', @@ -104,15 +115,21 @@ function db_init(PDO $pdo): void pinned INTEGER NOT NULL DEFAULT 0, enabled INTEGER NOT NULL DEFAULT 1, sort INTEGER NOT NULL DEFAULT 0, + board_cats TEXT DEFAULT \'\', created_at TEXT DEFAULT \'\', updated_at TEXT DEFAULT \'\' )'); - // 兼容旧库:为已存在的 categories 表补充 icon 列 + // 兼容旧库:categories 补充 icon 列 db_ensure_column($pdo, 'categories', 'icon', "TEXT DEFAULT ''"); // 兼容旧库:quick_links / func_tools 若缺列一并补齐 db_ensure_column($pdo, 'quick_links', 'note', "TEXT DEFAULT ''"); db_ensure_column($pdo, 'func_tools', 'note', "TEXT DEFAULT ''"); + // 兼容旧库:工具与文章的“库首页分发范围”(board_cats,逗号分隔库 key;空=通用) + db_ensure_column($pdo, 'func_tools', 'board_cats', "TEXT DEFAULT ''"); + db_ensure_column($pdo, 'articles', 'board_cats', "TEXT DEFAULT ''"); + // 兼容旧库:外部工具的“分区/栏目”(grp,空=不分组平铺) + db_ensure_column($pdo, 'ext_links', 'grp', "TEXT DEFAULT ''"); // ---------- 默认分类种子 ---------- $count = (int)$pdo->query('SELECT COUNT(*) FROM categories')->fetchColumn(); @@ -199,6 +216,25 @@ function db_init(PDO $pdo): void } } + // ---------- 库首页“外部工具区”默认种子(cat_keys 为空=展示于所有库首页;grp=分区/栏目,留空不分) ---------- + $extCount = (int)$pdo->query('SELECT COUNT(*) FROM ext_links')->fetchColumn(); + if ($extCount === 0) { + $seedExt = [ + // [分区/栏目, 名称, 网址, 简介] + ['在线检测', 'VirusTotal', 'https://www.virustotal.com', '多引擎文件与 URL 检测'], + ['在线检测', 'Shodan', 'https://www.shodan.io', '互联网设备与端口测绘'], + ['在线检测', 'URLScan', 'https://urlscan.io', '网站访问行为分析'], + ['情报与数据', 'CyberChef', 'https://gchq.github.io/CyberChef/', '数据加工与编解码台'], + ['情报与数据', 'Exploit-DB', 'https://www.exploit-db.com', '公开漏洞利用库'], + ['情报与数据', 'Have I Been Pwned', 'https://haveibeenpwned.com', '泄露账户与密码查询'], + ]; + $stExt = $pdo->prepare('INSERT INTO ext_links (name, url, description, grp, cat_keys, enabled, sort) VALUES (?,?,?,?,?,1,?)'); + $extSort = 0; + foreach ($seedExt as $e) { + $stExt->execute([$e[1], $e[2], $e[3], $e[0], '', ++$extSort]); + } + } + // ---------- 文章库示例文章(首次建库时提供一篇使用说明) ---------- $artCount = (int)$pdo->query('SELECT COUNT(*) FROM articles')->fetchColumn(); if ($artCount === 0) { diff --git a/includes/layout.php b/includes/layout.php index 6b596ba..bef699c 100644 --- a/includes/layout.php +++ b/includes/layout.php @@ -40,6 +40,10 @@ function layout_head(string $title = ''): void echo '<title>' . he($fullTitle) . ''; echo ''; echo ''; + // 站长提示开关(后台可关):关闭时统一隐藏带 st-tip 标记的辅助文案 + if ((setting_get('show_st_tips', '1')) !== '1') { + echo ''; + } echo '' . "\n"; } diff --git a/index.php b/index.php index e2cbef0..1ddc174 100644 --- a/index.php +++ b/index.php @@ -114,24 +114,22 @@ layout_head(''); -
- - +
- + - + +
diff --git a/nav.php b/nav.php index 90c307d..0fb4219 100644 --- a/nav.php +++ b/nav.php @@ -1,8 +1,15 @@ prepare('SELECT * FROM categories WHERE key = ? LIMIT 1'); $st->execute([$catKey]); @@ -28,11 +42,10 @@ if (!$cat) { exit; } -// 块与按钮数据 +// —— 导航区数据 —— $st = $pdo->prepare('SELECT * FROM nav_blocks WHERE cat_id = ? ORDER BY sort ASC, id ASC'); $st->execute([(int)$cat['id']]); $blocks = $st->fetchAll(); - $itemsByBlock = []; if ($blocks) { $ids = array_map(function ($b) { return (int)$b['id']; }, $blocks); @@ -43,103 +56,334 @@ if ($blocks) { $itemsByBlock[(int)$item['block_id']][] = $item; } } - -// 三列轮转分配(保证列内块数均衡) -$cols = [[], [], []]; +$totalNav = 0; +foreach ($itemsByBlock as $list) { $totalNav += count($list); } +$navCols = [[], [], []]; $idx = 0; -foreach ($blocks as $b) { - $cols[$idx % 3][] = $b; - $idx++; +foreach ($blocks as $b) { $navCols[$idx % 3][] = $b; $idx++; } + +// —— 内部功能区数据(通用或包含当前库) —— +$libTools = []; +foreach ($pdo->query('SELECT * FROM func_tools WHERE enabled = 1 ORDER BY sort ASC, id ASC')->fetchAll() as $t) { + if (board_cats_has((string)($t['board_cats'] ?? ''), $catKey)) { + $libTools[] = $t; + } } -$totalItems = 0; -foreach ($itemsByBlock as $list) { - $totalItems += count($list); + +// —— 文章区数据(仅已被分发到当前库的文章) —— +$libArts = []; +foreach ($pdo->query('SELECT id, part, title, summary, pinned, sort, created_at, updated_at, board_cats FROM articles WHERE enabled = 1 ORDER BY pinned DESC, sort ASC, id ASC')->fetchAll() as $a) { + if (in_array($catKey, array_map('trim', explode(',', trim((string)($a['board_cats'] ?? '')))), true)) { + $libArts[] = $a; + } +} + +// —— 外部工具区数据(通用或包含当前库) —— +$extLinks = []; +foreach ($pdo->query('SELECT * FROM ext_links WHERE enabled = 1 ORDER BY sort ASC, id ASC')->fetchAll() as $e) { + if (board_cats_has((string)($e['cat_keys'] ?? ''), $catKey)) { + $extLinks[] = $e; + } } $siteName = setting_get('site_name', '知识导航站'); $engines = search_engines(); $defaultEngine = $engines[0]['key'] ?? 'bing'; $enginesJson = json_encode($engines, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +$domainOf = static function (string $u): string { + $h = (string)parse_url($u, PHP_URL_HOST); + return $h !== '' ? preg_replace('/^www\./i', '', $h) : $u; +}; +$catName = (string)$cat['name']; +$catIcon = trim((string)$cat['icon']); -layout_head((string)$cat['name']); +layout_head($catName); ?> - - - - -
-
- - - -