更新网页架构,新增外部工具分区,优化web样式

This commit is contained in:
MasonLiu 2026-09-08 23:53:35 +08:00
parent 0c3d3d4977
commit c5b6f4b4c2
15 changed files with 1207 additions and 713 deletions

View File

@ -1,546 +0,0 @@
# 网站首页修订版 Planv2
> 本文件在 `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 CryptoSHAUTF-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)。
- **下方居中**:天气区块:`<iframe width="500" height="40" src="https://i.tianqi.com/?c=code&a=getcode&id=173&icon=1" frameborder="0"></iframe>`(在移动端可改为宽度 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/标题 | 站点名作为 `<title>` 统一后缀 | 后台可改站点名 |
---
## 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"`)。
- 链接规范:允许完整 URLhttp/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或点击图片弹出大图预览大图可在新层中查看**下载**:提供下载按钮,导出为 PNGcanvas 转 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 防护:后台写操作表单带一次性 tokensession 存),提交校验(低成本基础防护)。
- 登录尝试不做复杂限流,仅简单延时提示(个人站可接受)。
### 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 # 鉴权 includerequire 到各后台页顶部)
├── 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%,中间三列 | 工具卡片多列;编码页输入/输出可并排 |
| 7681199px平板 | 四板块两行两列或自适应横排 | 左右留白收窄至 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. 二维码是否提供"滚轮缩放" —— 默认提供尺寸滑块 + 点击放大预览两种。
> 若以上默认与你设想不同,修改对应【决策】标注即可,其余规格不受影响。

View File

@ -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) {

View File

@ -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">

View File

@ -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>

552
admin/exttools.php Normal file
View File

@ -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='[&#10; { "section": "合集" },&#10; { "name": "工具A", "url": "https://example.com/a", "description": "一句话简介" },&#10; { "name": "工具B", "url": "https://example.com/b" },&#10; { "section": "导航" },&#10; { "name": "站点C", "url": "https://example.com/c" }&#10;]'></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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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>

View File

@ -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) {

View File

@ -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">

View File

@ -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; }
/* 登录页 */

View File

@ -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";

View File

@ -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>

View File

@ -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) {

View File

@ -40,6 +40,10 @@ function layout_head(string $title = ''): void
echo '<title>' . he($fullTitle) . '</title>';
echo '<link rel="icon" href="' . he($prefix . $logo) . '">';
echo '<link rel="stylesheet" href="' . he($prefix) . 'assets/css/common.css">';
// 站长提示开关(后台可关):关闭时统一隐藏带 st-tip 标记的辅助文案
if ((setting_get('show_st_tips', '1')) !== '1') {
echo '<style>.st-tip{display:none!important}</style>';
}
echo '</head><body>' . "\n";
}

View File

@ -114,24 +114,22 @@ layout_head('');
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="boards js-boards">
<!-- 展开说明浮层(不参与布局,不可点击) -->
<div class="board-panel js-board-panel" aria-hidden="true">
<div class="board-panel-title js-board-panel-title"></div>
<div class="board-panel-desc js-board-panel-desc"></div>
</div>
<div class="boards">
<?php foreach ($cats as $c):
$k = (string)$c['key'];
$href = $catLink[$k] ?? 'index.php';
$bIcon = trim((string)($c['icon'] ?? ''));
$bIcon = $bIcon !== '' ? $bIcon : ($catIcon[$k] ?? '●');
$hint = $catHint[$k] ?? '进入';
$descTxt = trim((string)($c['description'] ?? ''));
if ($descTxt === '') {
$descTxt = $catHint[$k] ?? '';
}
?>
<a class="board-card js-board-card" href="<?= he($href) ?>"
data-name="<?= he($c['name']) ?>" data-desc="<?= he((string)$c['description']) ?>">
<a class="board-card" href="<?= he($href) ?>" title="<?= he($c['name']) ?><?= he($descTxt) ?>">
<span class="board-icon"><?= site_icon($bIcon, '') ?></span>
<span class="board-name"><?= he($c['name']) ?></span>
<span class="board-hint"><?= he($hint) ?></span>
<!-- 说明文案直接常显(原悬停展开浮层已取消) -->
<span class="board-hint"><?= he($descTxt) ?></span>
</a>
<?php endforeach; ?>
</div>

410
nav.php
View File

@ -1,8 +1,15 @@
<?php
/**
* nav.php —— 导航区模板(科普库 / 红队库 / 蓝队库共用)
* nav.php —— 导航区模板(科普库 / 红队库 / 蓝队库共用)【方案A 正式版】
* URL 参数cat=popular | red | blue
* 布局:顶部留白区(含小型品牌条)→ 搜索栏 三大列导航块区
* 结构:品牌条 搜索条 分区导航(文本居中,下划线滑动指示)
* 四区切换:导航区 / 内部功能区 / 文章区 / 外部工具区
* 说明:库间切换仅在首页板块完成,库内不提供切换入口;板块说明文案常显于首页板块卡。
* 数据来源:
* 导航区 = nav_blocks / nav_items按当前库
* 内部功能区 = func_tools后台“功能区管理”中 board_cats 控制按库展示,空=通用);
* 文章区 = articles后台“文章管理”中 board_cats 勾选分发到库,未分发不显示);
* 外部工具区 = ext_links后台“外部工具”维护cat_keys 控制范围,空=通用)。
*/
$P = '';
require_once __DIR__ . '/includes/layout.php';
@ -10,10 +17,17 @@ require_once __DIR__ . '/includes/layout.php';
$catKey = isset($_GET['cat']) ? (string)$_GET['cat'] : '';
$allowed = ['popular', 'red', 'blue'];
if (!in_array($catKey, $allowed, true)) {
// 非法或缺失分类:默认指向科普库更友好(导航页只承载三大知识库)
$catKey = 'popular';
}
/** 范围列是否包含当前库:空字符串视为“通用(所有库)” */
function board_cats_has(string $csv, string $key): bool
{
$csv = trim($csv);
if ($csv === '') return true;
return in_array($key, array_map('trim', explode(',', $csv)), true);
}
$pdo = db();
$st = $pdo->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);
?>
<!-- 顶部留白 + 品牌条 -->
<div class="nav-top">
<div class="wrap" style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px;flex-wrap:wrap;">
<a class="brand brand-sm" href="index.php" title="返回首页">
<?php layout_logo_img('logo', $siteName); ?>
<span class="site-name"><?= he($siteName) ?></span>
</a>
<span class="board-tag" style="font-size:13px;color:var(--text-3);padding-top:10px;"><?= he((string)$cat['name']) ?></span>
</div>
</div>
<!-- 搜索栏 -->
<div class="search-zone">
<div class="wrap">
<div class="search-box js-search" data-engine="<?= he($defaultEngine) ?>" data-engines="<?= he($enginesJson) ?>">
<button type="button" class="engine-btn js-engine-btn" title="切换搜索引擎">
<span class="js-engine-name"><?= he($engines[0]['icon'] ?? 'S') ?></span>
<span class="arrow"></span>
<ul class="engine-menu js-engine-menu"></ul>
</button>
<div class="search-field">
<input type="text" class="js-search-input" placeholder="输入关键词,回车或点击搜索…" autocomplete="off">
<button type="button" class="search-clear js-search-clear" title="清除"></button>
</div>
<button type="button" class="search-go js-search-go">搜索</button>
<style>
/* ===== 库首页分区导航:文本居中 + 下划线滑动指示 ===== */
.lnx-nav {
position: relative; display: flex; justify-content: center; align-items: center;
gap: 0 38px; margin: 6px 0 16px; border-bottom: 1px solid var(--line); padding: 0 4px;
}
.lnx-tab { position: relative; z-index: 1; border: none; background: none; cursor: pointer;
padding: 12px 6px 13px; font-size: 15px; color: var(--text-3);
transition: color .2s ease; white-space: nowrap; }
.lnx-tab:hover { color: var(--text); }
.lnx-tab.active { color: var(--brand); font-weight: 700; }
.lnx-ink {
position: absolute; left: 0; bottom: -1px; width: 0; height: 3px; border-radius: 3px;
background: var(--brand); box-shadow: 0 0 8px var(--accent-soft);
transition: left .28s cubic-bezier(.45, 0, .25, 1), width .28s cubic-bezier(.45, 0, .25, 1);
}
.lnx-panel { display: none; }
.lnx-panel.active { display: block; }
.lnx-phead { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin: 4px 0 14px; }
.lnx-phead h3 { margin: 0; font-size: 18px; }
.lnx-phead .lnx-go { font-size: 13px; color: var(--text-3); text-decoration: none; }
.lnx-note { font-size: 12.5px; color: var(--text-3); background: var(--card); border: 1px dashed var(--line-2);
border-radius: 10px; padding: 8px 12px; margin-bottom: 14px; }
/* 外部工具区分区标题(按 grp 分组的小节) */
.lnx-eg { display: flex; align-items: center; gap: 10px; margin: 18px 0 8px; font-size: 14.5px; font-weight: 600; color: var(--brand); }
.lnx-eg::after { content: ''; flex: 1; height: 1px; background: var(--line); }
.lnx-exts + .lnx-eg { margin-top: 24px; }
.lnx-empty { color: var(--text-3); font-size: 13.5px; background: var(--card); border: 1px solid var(--line);
border-radius: 12px; padding: 22px 16px; text-align: center; }
.lnx-tools { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
.lnx-tool { display: flex; gap: 12px; align-items: flex-start; text-decoration: none; color: var(--text);
background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 14px; box-shadow: var(--shadow-sm);
transition: transform .15s ease, border-color .15s ease; }
.lnx-tool:hover { transform: translateY(-2px); border-color: var(--brand); }
.lnx-tool .lnx-ti { font-size: 24px; }
.lnx-tool b { display: block; font-size: 15px; margin-bottom: 4px; }
.lnx-tool span.tip { margin: 0; line-height: 1.5; display: block; }
.lnx-arts { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
.lnx-art { display: block; text-decoration: none; color: var(--text); background: var(--card);
border: 1px solid var(--line); border-radius: 14px; padding: 14px; box-shadow: var(--shadow-sm);
transition: transform .15s ease, border-color .15s ease; }
.lnx-art:hover { transform: translateY(-2px); border-color: var(--brand); }
.lnx-art .lnx-ab { display: inline-block; font-size: 12px; color: var(--brand); background: var(--accent-soft);
border-radius: 6px; padding: 2px 8px; margin-bottom: 8px; }
.lnx-art h4 { margin: 0 0 6px; font-size: 15.5px; }
.lnx-art p { margin: 0 0 8px; font-size: 13px; color: var(--text-3); line-height: 1.6; }
.lnx-art time { font-size: 12px; color: var(--text-3); }
.lnx-exts { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; }
.lnx-ext { display: block; text-decoration: none; color: var(--text); background: var(--card);
border: 1px solid var(--line); border-radius: 14px; padding: 12px 14px; box-shadow: var(--shadow-sm);
transition: transform .15s ease, border-color .15s ease; }
.lnx-ext:hover { transform: translateY(-2px); border-color: var(--brand); }
.lnx-ext .lnx-eh { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
.lnx-ext .lnx-en { font-weight: 600; font-size: 14.5px; }
.lnx-ext .lnx-ed { font-size: 12px; color: var(--text-3); word-break: break-all; }
.lnx-ext p { margin: 6px 0 0; font-size: 12.5px; color: var(--text-3); line-height: 1.5; }
@media (max-width: 760px) {
.lnx-nav { gap: 0 10px; }
.lnx-tab { flex: 1 1 0; min-width: 0; font-size: 14px; padding: 10px 2px 12px; text-align: center; }
.nav-cols { grid-template-columns: 1fr; }
}
</style>
<main>
<!-- 顶部留白 + 品牌条(上半区不变) -->
<div class="nav-top">
<div class="wrap" style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px;flex-wrap:wrap;">
<a class="brand brand-sm" href="index.php" title="返回首页">
<?php layout_logo_img('logo', $siteName); ?>
<span class="site-name"><?= he($siteName) ?></span>
</a>
<span style="font-size:13px;color:var(--text-3);padding-top:10px;"><?= he($catIcon) ?> <?= he($catName) ?></span>
</div>
<p class="search-title">可切换搜索引擎进行搜索(<?= count($engines) ?> 个可用,后台可维护)</p>
</div>
</div>
<!-- 导航内容区:左右各留白 15%,中间三大列 -->
<div class="nav-area">
<div class="nav-area-head">
<span class="nav-area-title"><?= he((string)$cat['name']) ?></span>
<span class="nav-area-count"> <?= count($blocks) ?> 个导航块 / <?= $totalItems ?> 个链接</span>
</div>
<?php if (!$blocks): ?>
<div class="panel-card"><div class="tip">该库还没有导航内容,可在后台"导航内容管理"中添加。</div></div>
<?php else: ?>
<div class="nav-cols">
<?php foreach ($cols as $colIdx => $colBlocks): ?>
<div class="nav-col">
<?php foreach ($colBlocks as $b):
$items = $itemsByBlock[(int)$b['id']] ?? [];
$show = array_slice($items, 0, 15);
?>
<div class="nav-block">
<div class="nav-block-title"><?= he((string)$b['title']) ?></div>
<div class="nav-block-grid">
<?php for ($i = 0; $i < 15; $i++): ?>
<?php if (isset($show[$i])):
$it = $show[$i];
$label = (string)$it['label'];
$url = trim((string)$it['url']);
$note = trim((string)$it['note']);
if ($url !== ''): ?>
<a class="nav-btn" href="<?= he($url) ?>" target="_blank" rel="noopener noreferrer"<?php if ($note !== ''): ?> data-note="<?= he($note) ?>"<?php endif; ?>><?= he($label ?: $url) ?></a>
<?php else: ?>
<span class="nav-btn is-off" title="链接未配置"><?= he($label) ?></span>
<?php endif; ?>
<?php else: ?>
<span class="nav-btn is-empty"></span>
<?php endif; ?>
<?php endfor; ?>
</div>
<!-- 搜索条(保持不变) -->
<div class="search-zone">
<div class="wrap">
<div class="search-box js-search" data-engine="<?= he($defaultEngine) ?>" data-engines="<?= he($enginesJson) ?>">
<button type="button" class="engine-btn js-engine-btn" title="切换搜索引擎">
<span class="js-engine-name"><?= he($engines[0]['icon'] ?? 'S') ?></span>
<span class="arrow"></span>
<ul class="engine-menu js-engine-menu"></ul>
</button>
<div class="search-field">
<input type="text" class="js-search-input" placeholder="输入关键词,回车或点击搜索…" autocomplete="off">
<button type="button" class="search-clear js-search-clear" title="清除"></button>
</div>
<button type="button" class="search-go js-search-go">搜索</button>
</div>
<p class="search-title st-tip">可切换搜索引擎进行搜索(<?= count($engines) ?> 个可用,后台可维护)</p>
</div>
</div>
<div class="wrap">
<!-- 主导航:分区文本居中,点击下滑线滑动指示;库间切换请从首页板块入口进入 -->
<nav class="lnx-nav" id="lnxNav">
<button type="button" class="lnx-tab active" data-t="nav">🧭 导航区</button>
<button type="button" class="lnx-tab" data-t="tools">🧰 内部功能区</button>
<button type="button" class="lnx-tab" data-t="art">📚 文章区</button>
<button type="button" class="lnx-tab" data-t="ext">🔗 外部工具区</button>
<span class="lnx-ink" id="lnxInk" aria-hidden="true"></span>
</nav>
<!-- ============ 导航区 ============ -->
<section class="lnx-panel active" data-panel="nav">
<div class="lnx-phead">
<h3>导航区 · <?= he($catName) ?></h3>
<span class="lnx-go"> <?= count($blocks) ?> 个导航块 / <?= $totalNav ?> 个链接 · 悬停按钮可看备注</span>
</div>
<?php if (!$blocks): ?>
<div class="lnx-empty">该库还没有导航内容<span class="st-tip">,可在后台「导航内容管理」中添加</span></div>
<?php else: ?>
<div class="nav-cols" style="margin:0">
<?php foreach ($navCols as $colBlocks): ?>
<div class="nav-col">
<?php foreach ($colBlocks as $b):
$items = $itemsByBlock[(int)$b['id']] ?? [];
$show = array_slice($items, 0, 15);
?>
<div class="nav-block">
<div class="nav-block-title"><?= he((string)$b['title']) ?></div>
<div class="nav-block-grid">
<?php for ($i = 0; $i < 15; $i++): ?>
<?php if (isset($show[$i])):
$it = $show[$i];
$label = (string)$it['label'];
$url = trim((string)$it['url']);
$note = trim((string)$it['note']);
if ($url !== ''): ?>
<a class="nav-btn" href="<?= he($url) ?>" target="_blank" rel="noopener noreferrer"<?php if ($note !== ''): ?> data-note="<?= he($note) ?>"<?php endif; ?>><?= he($label ?: $url) ?></a>
<?php else: ?>
<span class="nav-btn is-off" title="链接未配置"><?= he($label) ?></span>
<?php endif; ?>
<?php else: ?>
<span class="nav-btn is-empty"></span>
<?php endif; ?>
<?php endfor; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
</section>
<!-- ============ 内部功能区 ============ -->
<section class="lnx-panel" data-panel="tools">
<div class="lnx-phead">
<h3>内部功能区 · <?= he($catName) ?>常用工具</h3>
<a class="lnx-go" href="func/index.php">前往完整功能区 </a>
</div>
<div class="lnx-note st-tip">由后台「功能区管理」为每个工具勾选“展示库”,未勾选默认出现在所有库。点击工具进入详情页(返回按钮已适配为返回上一页)。</div>
<?php if (!$libTools): ?>
<div class="lnx-empty">尚未把工具分配到本库<span class="st-tip">,可到后台「功能区管理」开启</span></div>
<?php else: ?>
<div class="lnx-tools">
<?php foreach ($libTools as $tk):
$isExt = (int)$tk['is_external'] === 1;
$url = trim((string)$tk['url']);
$href = $isExt ? $url : 'func/' . $url . '?from=nav';
$tIcon = trim((string)($tk['icon'] ?? ''));
$tIcon = $tIcon !== '' ? $tIcon : '🧩';
?>
<a class="lnx-tool" href="<?= he($href) ?>"<?php if ($isExt): ?> target="_blank" rel="noopener noreferrer"<?php endif; ?>>
<span class="lnx-ti"><?= site_icon($tIcon, '') ?></span>
<span>
<b><?= he((string)$tk['name']) ?></b>
<span class="tip"><?= he((string)$tk['description']) ?></span>
</span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<!-- ============ 文章区 ============ -->
<section class="lnx-panel" data-panel="art">
<div class="lnx-phead">
<h3>文章区 · <?= he($catName) ?>相关</h3>
<a class="lnx-go" href="article/index.php">前往完整文章库 </a>
</div>
<div class="lnx-note st-tip">由后台「文章管理」为文章勾选分发到的库;点击进入阅读页(返回按钮已适配为返回上一页)。</div>
<?php if (!$libArts): ?>
<div class="lnx-empty">还没有文章分发到本库<span class="st-tip">,可到后台「文章管理」编辑文章并勾选“展示到库首页”</span></div>
<?php else: ?>
<div class="lnx-arts">
<?php foreach ($libArts as $ak): ?>
<a class="lnx-art" href="article/index.php?id=<?= (int)$ak['id'] ?>&from=nav&cat=<?= he($catKey) ?>">
<span class="lnx-ab"><?= he((string)$ak['part']) ?></span>
<h4><?= he((string)$ak['title']) ?></h4>
<p><?= he(trim((string)$ak['summary']) !== '' ? (string)$ak['summary'] : '(无摘要)') ?></p>
<time><?= he((string)$ak['created_at']) ?></time>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<!-- ============ 外部工具区 ============ -->
<section class="lnx-panel" data-panel="ext">
<div class="lnx-phead">
<h3>外部工具区 · 开源工具/好用站点/点开即用</h3>
<span class="lnx-go st-tip">快速链接 · 名称 · 简介 · 新标签页打开 · 后台可增删</span>
</div>
<div class="lnx-note st-tip">由后台「外部工具」维护(可限制展示在特定库,留空则所有库通用)。</div>
<?php if (!$extLinks): ?>
<div class="lnx-empty">暂无外部工具<span class="st-tip">,可到后台「外部工具」添加</span></div>
<?php else:
$extGroups = [];
foreach ($extLinks as $ek) {
$eg = trim((string)($ek['grp'] ?? ''));
$extGroups[$eg === '' ? "\x00" : $eg][] = $ek;
}
?>
<?php foreach ($extGroups as $eg => $gItems): ?>
<?php if ($eg !== "\x00"): ?><h4 class="lnx-eg"><?= he($eg) ?></h4><?php endif; ?>
<div class="lnx-exts">
<?php foreach ($gItems as $ek):
$eUrl = trim((string)$ek['url']);
?>
<a class="lnx-ext" href="<?= he($eUrl) ?>" target="_blank" rel="noopener noreferrer">
<span class="lnx-eh">
<span class="lnx-en"><?= he((string)$ek['name']) ?></span>
<span class="lnx-ed"><?= he($domainOf($eUrl)) ?></span>
</span>
<p><?= he((string)($ek['description'] ?? '')) ?></p>
</a>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
<?php endif; ?>
</section>
</div>
</main>
<?php layout_theme_fab(); ?>
<?php layout_footer(); ?>
<script src="assets/js/common.js"></script>
<script>
(function () {
'use strict';
// 下划线滑动指示导航:切换分区时激活项高亮,指示条平滑滑动到该项下方
var nav = document.getElementById('lnxNav');
var ink = document.getElementById('lnxInk');
var tabs = nav ? Array.prototype.slice.call(nav.querySelectorAll('.lnx-tab')) : [];
var panels = document.querySelectorAll('.lnx-panel');
if (!nav || !ink || !tabs.length) return;
function moveInk() {
var cur = nav.querySelector('.lnx-tab.active');
if (!cur) { ink.style.left = '0px'; ink.style.width = '0px'; return; }
ink.style.left = cur.offsetLeft + 'px';
ink.style.width = cur.offsetWidth + 'px';
}
function show(btn, fromHash) {
var t = btn.getAttribute('data-t');
tabs.forEach(function (x) { x.classList.toggle('active', x === btn); });
panels.forEach(function (p) { p.classList.toggle('active', p.getAttribute('data-panel') === t); });
moveInk();
if (!fromHash) window.scrollTo({ top: 0, behavior: 'smooth' });
// 把当前分区写入 URL #hashreplaceState 不产生额外历史记录),
// 这样从本页跳去文章/工具再“返回上一页”或刷新时,仍能回到原来的分区。
if (location.hash !== '#' + t) {
history.replaceState(null, '', '#' + t);
}
}
tabs.forEach(function (btn) {
btn.addEventListener('click', function () { show(btn); });
});
window.addEventListener('resize', function () { moveInk(); });
// 初始恢复URL 中带 #分区(返回上一页 / 手动分享)时,激活对应分区
var wantTab = (location.hash || '').replace(/^#/, '');
var foundTab = null;
tabs.forEach(function (b) { if (!foundTab && b.getAttribute('data-t') === wantTab) foundTab = b; });
if (foundTab) {
show(foundTab, true);
} else {
moveInk();
// 无分区 hash 时清掉可能遗留的无效 hash如 #footer保持地址干净
if (location.hash !== '') {
history.replaceState(null, '', location.pathname + location.search);
}
}
})();
</script>
</body>
</html>

View File

@ -1,50 +0,0 @@
我现在需要你去写一个网站首页,要求如下:
页面分为四种:
1.首页区
2.导航区
3.功能区
4.后台区
技术栈:
常见网页技术都行包括但不限于js、html、php、css。
layui等样式均可使用
基础页面展示要求:
1.首页区要求
主内容占据页面60%内容
主内容为占据页面20%的展示栏位于上方下方40%为导航区块,用户可自行选择四大板块
分别为:科普库,蓝队库,红队库,工具库
剩余40%空间上部10%放置名人名言从quota.txt中获取中部为主内容下部放置ICP备案号公安备案号版权声明和其他内容
2.导航区要求
最大限度保证信息密度和美观度同时页面分为两部分上半部分占据20%,为搜索栏
注意最上方留白10%
下半部分为导航区域
导航区域要求:
PC页面两侧各留白15%,中间划分为三大列,导航块内容分别置于各列中(大列无标题)
导航块为固定一标题加三小列五行的格式,标题加粗居左,行列内容为导航按钮块,鼠标移动上方时变色(若存在备注信息则会在按钮上分蹦出来一个语言栏,没有则仅变色),点击后新建一个页面打开该链接
每个导航块大小固定,在移动设备页面中,单小列显示
3.功能区要求
设计一个功能区首页(要求与其他页面的风格一致)
顶部居中大展示栏,最右侧为首页按钮,点击返回首页区,最左侧为返回功能区首页按钮,中间为本功能页标题
你需要设计以下功能:
编码/加解密页Base64编解码Base32编解码md5加密url编解码unicode编解码需支持中文以及其他复杂内容的编解码各类sha加密
注意:页面需同时具备可自由编辑输入的输入和输出区块
随机密码生成页:用户自由选择大写、小写字母,数字和特殊符号或者自定义内容并自己选择密码长度进行随机密码生成
二维码生成器:用户可放入内容用以生成二维码(可自由放大图片并下载)
杀软识别识别输入框内的tasklist内容随后在avlist.js中匹配内容以表格形式输出匹配结果
格式示例avList = {"360tray.exe": "360 安全卫士 - 实时保护",....}
4.后台区要求
具备对其他页面进行调整的功能
1.顺序排布
2.内容设计
3.导航区导航内容的增删改查
使用sqlite存储登录密码以及其他内容密码加密格式先base64再进行md5
高级要求:
展示栏一个居中小区块左侧为logo右侧为当前时间和上次更新时间在其下还有天气区块<iframe width="500" height="40" src="https://i.tianqi.com/?c=code&a=getcode&id=173&icon=1" frameborder="0"></iframe>
搜索栏最左侧为切换按钮可自由切换导航引擎为bingbaidugithub等中间为输入框最右侧为搜索按钮存在输入内容时在输入框最右侧显示清除按钮用户可点击搜索按钮或者直接回车打开新页面搜索内容
首页四大板块在用户鼠标移动至上方时用延申文本板块,覆盖其他几个板块并展示说明,该延申文本不具备点击效果以及其他特性,用户鼠标移出该四大板块区域时消失,或者移动至其他板块时以同样的效果展开显示
注意,若鼠标所在的板块不在最左侧或最右侧,则在展开说明时移动至旁侧(即最左侧或者最右侧),这时,实际板块位置不变,若用户将鼠标移至其他板块上方,则对该板块进行展开
各类输入输出框:支持拉动右下侧边角进行区域扩大(竖向扩大,横向不变),搜索框除外