初始化:炫恒网络官网(五个页面)
站点:纯静态 HTML + CSS + 原生 JS,零框架零构建
页面:首页 / 合作品牌 / 荣誉资质 / 客户案例 / 关于我们
内容:
- site/ 站点本体(含已压缩 webp 图片,约 4.8 MB)
- deploy/ Docker Compose / Caddy / Apache 部署配置
- _tools/ 开发自检脚本与部署脚本
- Dockerfile 自建 nginx 镜像方案
- README.md 项目文档
设计:品牌黄 #ffc107 + 白,深蓝仅用于页脚;菜单长期置顶;
交互动效只加在关键词与区块级元素上。
素材说明:原始素材(背景图/老网站/方案/荣誉资质/合作品牌,约 290 MB)
未纳入版本控制,站点使用的是 site/assets 内已处理压缩的图片。
@@ -0,0 +1,32 @@
|
||||
# ==============================================================================
|
||||
# 构建上下文排除清单
|
||||
#
|
||||
# 说明:Dockerfile 与 nginx/ 配置必须保留在上下文里(构建时需要),
|
||||
# 这里只排除体积很大、运行不需要的原始素材与开发工具。
|
||||
# 排除后 build context 从约 30 MB+ 降到几 MB,构建更快。
|
||||
# ==============================================================================
|
||||
|
||||
# 原始素材(很大,构建完全用不到;站点用的是 site/assets 里已处理过的图)
|
||||
合作品牌/
|
||||
方案/
|
||||
荣誉资质/
|
||||
背景图/
|
||||
老网站/
|
||||
*.psd
|
||||
*.zip
|
||||
|
||||
# 开发与自检工具(不参与运行)
|
||||
_tools/
|
||||
tools/
|
||||
|
||||
# 其他部署方式的配置(Apache / 腾讯云等),不影响 nginx 镜像
|
||||
deploy/
|
||||
|
||||
# 版本控制与编辑器杂物
|
||||
.git/
|
||||
.gitignore
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.log
|
||||
@@ -0,0 +1,52 @@
|
||||
# ==============================================================================
|
||||
# 上海炫恒网络系统有限公司 官网 —— Git 忽略清单
|
||||
#
|
||||
# 本仓库只提交「网站项目本体」(约 5 MB):
|
||||
# site/ 站点文件(含已处理压缩的图片)
|
||||
# deploy/ 部署配置(Docker / Apache)
|
||||
# Dockerfile 等 根目录构建文件
|
||||
# _tools/ 开发自检脚本
|
||||
# README.md 项目文档
|
||||
#
|
||||
# 原始素材体积很大(合计约 290 MB),且站点用的是 site/assets 里已处理过的图,
|
||||
# 构建/部署都不需要原图,所以不纳入版本控制。
|
||||
# ==============================================================================
|
||||
|
||||
# ---- 原始素材(体积大,不参与构建与部署) ----
|
||||
合作品牌/
|
||||
方案/
|
||||
荣誉资质/
|
||||
背景图/
|
||||
老网站/
|
||||
*.psd
|
||||
*.zip
|
||||
*.tar.gz
|
||||
|
||||
# ---- 根目录下零散的原始图片(站点用的是 site/assets/img 里的处理版)----
|
||||
/*.png
|
||||
/*.jpg
|
||||
/*.jpeg
|
||||
/*.webp
|
||||
/*.svg
|
||||
/*.ico
|
||||
!site/**
|
||||
!deploy/**
|
||||
|
||||
# ---- 部署与自检产生的临时文件 ----
|
||||
_preview/
|
||||
.shots/
|
||||
*.log
|
||||
|
||||
# ---- 编辑器与系统杂物 ----
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# ---- 构建产物 ----
|
||||
node_modules/
|
||||
dist/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.cache/
|
||||
@@ -0,0 +1,39 @@
|
||||
# ==============================================================================
|
||||
# 上海炫恒网络系统有限公司 官网 —— 生产镜像
|
||||
# 基础镜像:nginx (alpine),最终镜像约 30 MB
|
||||
# 构建: docker compose build
|
||||
# ==============================================================================
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
LABEL org.opencontainers.image.title="xuanheng-website" \
|
||||
org.opencontainers.image.description="上海炫恒网络系统有限公司 官方网站(静态站点)" \
|
||||
org.opencontainers.image.vendor="上海炫恒网络系统有限公司" \
|
||||
org.opencontainers.image.licenses="Proprietary"
|
||||
|
||||
# ---- 1) 站点文件 ----------------------------------------------------------
|
||||
# 站点根目录就是 site/,页面之间全部使用同级相对路径
|
||||
COPY site/ /usr/share/nginx/html/
|
||||
|
||||
# ---- 2) Nginx 站点配置 ----------------------------------------------------
|
||||
RUN rm -f /etc/nginx/conf.d/default.conf
|
||||
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# ---- 3) 构建期自检 --------------------------------------------------------
|
||||
# 五个页面和入口资源必须存在,否则构建直接失败,避免把半成品镜像推上线
|
||||
RUN set -eux; \
|
||||
for f in index.html brands.html honors.html cases.html about.html \
|
||||
assets/css/main.css assets/js/main.js favicon.ico; do \
|
||||
test -f "/usr/share/nginx/html/$f" || { echo "缺少站点文件: $f"; exit 1; }; \
|
||||
done; \
|
||||
nginx -t
|
||||
|
||||
# ---- 4) 运行 --------------------------------------------------------------
|
||||
EXPOSE 80
|
||||
|
||||
# 健康检查:访问 /healthz,返回 200 即视为健康
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -q -O /dev/null http://127.0.0.1/healthz || exit 1
|
||||
|
||||
STOPSIGNAL SIGQUIT
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,360 @@
|
||||
# 上海炫恒网络系统有限公司 · 官方网站
|
||||
|
||||
企业官网静态站点,五个页面:**首页 / 合作品牌 / 荣誉资质 / 客户案例 / 关于我们**。
|
||||
纯 HTML + CSS + 原生 JavaScript,零框架、零依赖、零构建步骤,用 Docker + Nginx 一条命令上线。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [快速开始](#快速开始)
|
||||
- [项目结构](#项目结构)
|
||||
- [部署方式](#部署方式)
|
||||
- [页面与素材对照](#页面与素材对照)
|
||||
- [设计规范](#设计规范)
|
||||
- [交互与动效](#交互与动效)
|
||||
- [如何改内容](#如何改内容)
|
||||
- [自检工具](#自检工具)
|
||||
- [已验证 / 未验证](#已验证--未验证)
|
||||
- [常见问题](#常见问题)
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 方式一:Docker Compose(推荐,用于正式部署)
|
||||
|
||||
前置条件:装好 Docker(含 Compose 插件,Docker Desktop 或 Docker Engine 20.10+ 都行)。
|
||||
|
||||
```bash
|
||||
# 在项目根目录执行
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
启动后打开 **http://localhost:8080**
|
||||
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
docker compose logs -f # 看实时日志
|
||||
docker compose ps # 看容器与健康状态
|
||||
docker compose down # 停止并删除容器
|
||||
docker compose up -d --build --force-recreate # 改完内容后重新构建
|
||||
```
|
||||
|
||||
**换端口**:默认宿主机 8080。想用 80 端口,在项目根目录建一个 `.env` 文件:
|
||||
|
||||
```dotenv
|
||||
SITE_PORT=80
|
||||
```
|
||||
|
||||
或者直接改 `docker-compose.yml` 里 `ports` 的左边数字(`"8080:80"` → `"80:80"`)。
|
||||
|
||||
### 方式二:本地直接打开(改样式时最方便)
|
||||
|
||||
页面之间全部是同级相对路径,**双击 `site/index.html` 就能看**,五个页面也能互相跳转。
|
||||
|
||||
### 方式三:本地起一个静态服务器
|
||||
|
||||
```bash
|
||||
# 有 Python
|
||||
python -m http.server 8080 --directory site
|
||||
|
||||
# 有 Node
|
||||
npx serve site
|
||||
```
|
||||
|
||||
然后访问 http://localhost:8080
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
.
|
||||
├── Dockerfile 生产镜像(nginx:1.27-alpine,约 30 MB)
|
||||
├── docker-compose.yml Compose 编排(端口 / 健康检查 / 日志轮转)
|
||||
├── .dockerignore 构建上下文排除清单
|
||||
├── nginx/
|
||||
│ └── default.conf Nginx 站点配置(伪静态 / 缓存 / gzip / 安全头)
|
||||
├── deploy/
|
||||
│ └── apache/
|
||||
│ └── htaccess.txt 改用 Apache 时的配置(重命名为 .htaccess 使用)
|
||||
│
|
||||
├── site/ ★ 站点本体,整个目录就是网站根目录
|
||||
│ ├── index.html 首页
|
||||
│ ├── brands.html 合作品牌
|
||||
│ ├── honors.html 荣誉资质
|
||||
│ ├── cases.html 客户案例
|
||||
│ ├── about.html 关于我们
|
||||
│ ├── favicon.ico
|
||||
│ └── assets/
|
||||
│ ├── css/main.css 全部样式(设计系统,单文件)
|
||||
│ ├── js/main.js 全部交互(原生 JS,无依赖)
|
||||
│ ├── lqip.css 图片模糊占位(构建时自动生成)
|
||||
│ └── img/ 全部图片(webp 压缩)
|
||||
│ ├── hero/ 首屏大图、深色背景
|
||||
│ ├── brands/ 合作品牌 logo(已做尺寸归一化)
|
||||
│ ├── solutions/ 行业方案封面
|
||||
│ ├── cases/ 客户案例图
|
||||
│ ├── honors/ 荣誉证书(含 person/ 个人认证)
|
||||
│ └── misc/ Logo 与 favicon
|
||||
│
|
||||
├── _tools/ 开发自检脚本(不参与构建、不部署)
|
||||
└── (素材目录) 合作品牌/ 方案/ 荣誉资质/ 背景图/ 老网站/
|
||||
```
|
||||
|
||||
> 站点设计上刻意做成「**同级相对路径**」:所有页面都在同一层,页面之间引用 `brands.html`、
|
||||
> 资源引用 `assets/...`。这样**双击能开、放子目录能开、换域名能开**,不会出现
|
||||
> 「本地能看、传上去 404」的问题。
|
||||
|
||||
---
|
||||
|
||||
## 部署方式
|
||||
|
||||
### 1. Docker Compose(本文主要支持)
|
||||
|
||||
见上方快速开始。镜像构建时会做一次自检:五个页面和入口资源缺任何一个,**构建直接失败**,
|
||||
不会把半成品推上线。容器内置健康检查(访问 `/healthz`)。
|
||||
|
||||
### 2. 任意静态托管
|
||||
|
||||
把 `site/` 目录里的**全部内容**上传到网站根目录即可(Nginx、Apache、Caddy、
|
||||
对象存储、CDN 都行),不需要 Node、不需要构建。
|
||||
|
||||
- **Nginx**:可参考 `nginx/default.conf` 里的伪静态与缓存规则
|
||||
- **Apache**:把 `deploy/apache/htaccess.txt` 重命名为 `.htaccess` 放到根目录
|
||||
- **腾讯云 / 阿里云 OSS + CDN**:直接传文件,缓存规则设「HTML 不缓存、img/css/js 长缓存」
|
||||
|
||||
### 3. 关于「不带 .html 的网址」
|
||||
|
||||
直接访问 `https://你的域名/brands.html` 永远是通的。想用
|
||||
`https://你的域名/brands` 这种更干净的地址,需要服务器支持伪静态:
|
||||
|
||||
- 本项目的 Nginx 配置里已经写好了(`try_files $uri $uri.html $uri/index.html`)
|
||||
- Apache 用 `htaccess.txt` 里的 rewrite 规则
|
||||
|
||||
---
|
||||
|
||||
## 页面与素材对照
|
||||
|
||||
| 页面 | 文件 | 主要素材来源 |
|
||||
|---|---|---|
|
||||
| 首页 | `site/index.html` | `背景图/轮播/lunbo01(2).png`(首屏)、`方案/*.png`(行业卡)、`合作品牌/*.png`(品牌条)、`老网站` 案例图 |
|
||||
| 合作品牌 | `site/brands.html` | `合作品牌/` 下 8 个 logo(锐捷、思科、华三、Microsoft、vmware、citrix、juniper、radware) |
|
||||
| 荣誉资质 | `site/honors.html` | `荣誉资质/` 9 张公司证书 + 老网站 `assets/img/about/2/` 20 张个人认证 |
|
||||
| 客户案例 | `site/cases.html` | 老网站 `assets/img/solution-details/` 各行业案例图 + `成功案例-吾悦架构图.png` |
|
||||
| 关于我们 | `site/about.html` | 老网站 `about.html` / `itt.html` 的文本 |
|
||||
|
||||
**文字内容**来自老网站的真实文案(企业简介、2004–2022 发展历程、五大行业案例描述、
|
||||
服务宗旨与「标准·诚实·负责·尊重·快速」价值观、联系方式),老网站模板里的
|
||||
英文占位文案(BizMax、lorem 等)已全部剔除。
|
||||
|
||||
---
|
||||
|
||||
## 设计规范
|
||||
|
||||
### 配色(黄 + 白,深蓝只用于文字与页脚)
|
||||
|
||||
| 用途 | 变量 | 色值 |
|
||||
|---|---|---|
|
||||
| 主黄(按钮 / 强调) | `--y-500` | `#ffc61a` |
|
||||
| 亮黄(渐变起点) | `--y-400` | `#ffd656` |
|
||||
| 浅黄底(标签 / 图标底) | `--y-100` | `#fff9e6` |
|
||||
| 深金(黄色系文字) | `--y-700` | `#92620a` |
|
||||
| 标题文字 | `--ink-900` | `#0f1c31` |
|
||||
| 正文文字 | `--text-2` | `#4e5e76` |
|
||||
| 浅灰底 | `--bg-alt` | `#f7f9fc` |
|
||||
| 暖白底 | `--bg-warm` | `#fffdf7` |
|
||||
|
||||
深色只出现在**页脚**(`#0a1526`)与少量渐变遮罩上,其余全部白底 / 浅底,整体明亮。
|
||||
|
||||
### 尺寸与节奏
|
||||
|
||||
- 内容最大宽度 `1320px`,栅格间距 `clamp()` 随视口缩放
|
||||
- 区块上下留白 `clamp(60px, 6.4vw, 100px)`
|
||||
- 圆角:小 `8px` / 中 `14px` / 大 `18px` / 特大 `26px`
|
||||
- 缓动统一 `cubic-bezier(.22,.61,.36,1)`(舒适不弹跳),按钮用轻回弹
|
||||
|
||||
### 字体
|
||||
|
||||
不请求外部字体(避免国内加载慢与备案风险),使用系统字体栈:
|
||||
`HarmonyOS Sans SC → MiSans → PingFang SC → Microsoft YaHei UI → …`
|
||||
|
||||
---
|
||||
|
||||
## 交互与动效
|
||||
|
||||
设计原则:**只有关键词和区块整体有动效,重复的卡片不逐个动画**。
|
||||
|
||||
| 位置 | 效果 |
|
||||
|---|---|
|
||||
| 页头 | **长期置顶**(`position: fixed`)。滚动后变白底毛玻璃 + 高度收窄,始终可见、不会自动隐藏 |
|
||||
| 首屏标题 | 逐行从下方升起 |
|
||||
| 首屏「数字未来」 | 黄色高亮条从左展开 |
|
||||
| 首屏数据面板 | 进入视口后数字滚动计数 |
|
||||
| 首屏背景 | 滚动时 0.12 倍速视差(固定单图,无轮播) |
|
||||
| 区块进场 | 只在整个区块/标题层做淡入上移 |
|
||||
| 按钮 | 悬停上浮 2px + 黄色渐变自下填充 |
|
||||
| 卡片 | 悬停上浮 6px + 描边转黄 + 底部黄线展开 |
|
||||
| 品牌 logo | 默认**原厂彩色**,悬停轻微放大 1.06 倍 |
|
||||
| 案例筛选 | 淡出缩小的交错过渡 |
|
||||
| 证书 | 点击开灯箱,支持 ← → 翻页、Esc 关闭 |
|
||||
|
||||
已适配 `prefers-reduced-motion`(系统开启「减少动态效果」时全部动画关闭)与打印样式。
|
||||
|
||||
---
|
||||
|
||||
## 如何改内容
|
||||
|
||||
### 改文字
|
||||
|
||||
直接编辑 `site/*.html`。**注意:文件必须存为 UTF-8 编码**(不要用会改编码的编辑器设置)。
|
||||
|
||||
### 换首屏大图
|
||||
|
||||
1. 把新图放到 `site/assets/img/hero/hero-1.webp`(或改 `site/index.html` 里
|
||||
`hero__bg` 那行的路径)
|
||||
2. 首屏还会用到 `hero-1@2x.webp`(高分屏 2 倍图),可以一起换
|
||||
|
||||
### 加合作品牌
|
||||
|
||||
首页品牌条是 **4 列网格**,每个卡片里放一张 logo:
|
||||
|
||||
```html
|
||||
<div class="marquee__item"><img src="assets/img/brands/新品牌.webp" alt="品牌名" loading="lazy"></div>
|
||||
```
|
||||
|
||||
**注意两点(踩过坑):**
|
||||
|
||||
1. **不要给 logo 加 `width` / `height` 属性**,也不要让 JS 去写内联像素尺寸。
|
||||
logo 由 CSS 用「固定高度 + 宽度自动」统管(`height: 56px; width: auto; max-width: 100%`),
|
||||
这样所有 logo **视觉高度一致**、宽度按各自比例、不会被拉伸变形。
|
||||
一旦写了像素尺寸,CSS 的 `max-width` 会失效,宽 logo 会撑出卡片被裁切。
|
||||
|
||||
2. **logo 源图要先裁掉透明留白**。厂商 logo 原图常带很大留白(实测锐捷上下各留 153px),
|
||||
不裁的话进卡片会显得又小又偏。归一化脚本:
|
||||
|
||||
```bash
|
||||
python _tools/build_brands_v2.py
|
||||
```
|
||||
|
||||
品牌页(`brands.html`)的品牌墙用同一套 `.marquee__item` 样式,加一张卡片即可。
|
||||
|
||||
> 品牌相关文案已刻意**不写数量**(首页是「携手全球一线 IT 厂商」、品牌页是「核心合作品牌」),
|
||||
> 后续增删品牌不需要改文案。
|
||||
|
||||
### 换 Logo / favicon
|
||||
|
||||
`XH-logo.png` 是品牌标识源文件,处理脚本 `_tools/build_assets.py` 会生成
|
||||
`site/assets/img/misc/logo-256.png` 与 `site/favicon.ico`。
|
||||
|
||||
### 表单对接后端
|
||||
|
||||
`site/about.html` 的「客户留言」目前是**前端校验 + 成功提示,不发请求**。
|
||||
要真正收件,把 `<form>` 改成:
|
||||
|
||||
```html
|
||||
<form class="form" data-validate action="https://你的表单服务地址" method="POST">
|
||||
```
|
||||
|
||||
并删掉 `site/assets/js/main.js` 里 `initForms()` 中的 `e.preventDefault()`,
|
||||
或在提交成功后自行 `form.submit()`。
|
||||
|
||||
---
|
||||
|
||||
## 自检工具
|
||||
|
||||
`_tools/` 里的脚本用于开发期验证,**不参与构建**:
|
||||
|
||||
| 脚本 | 作用 |
|
||||
|---|---|
|
||||
| `check.js` | 资源引用、站内链接、锚点、标签配对、图标名合法性 |
|
||||
| `check_html.py` | HTML 嵌套合法性、`alt`/`lang`/外链 `rel`、h1 唯一性 |
|
||||
| `final_check.py` | **编码、相对路径、导航互达、资源存在性、模板文案残留**(最全面,建议每次改完都跑) |
|
||||
| `test-js.js` + `domshim.js` | 在 Node 里用最小 DOM 仿真跑 `main.js`,验证 64 项交互逻辑 |
|
||||
| `audit-css.js` | HTML 用到的 class 是否都有 CSS 定义 |
|
||||
| `build_assets.py` | 从原始素材生成站点图片、LQIP 占位、favicon |
|
||||
| `build_brands_v2.py` | 品牌 logo 尺寸归一化 |
|
||||
| `build_assets_v2.py` | 首屏图与行业方案图处理 |
|
||||
| `prune-images.js` / `prune-css.js` | 清理未引用的图片与 CSS 规则 |
|
||||
|
||||
运行示例:
|
||||
|
||||
```bash
|
||||
python _tools/final_check.py # 综合检查
|
||||
python _tools/check_html.py # HTML 结构
|
||||
node _tools/test-js.js # 交互逻辑(64 项断言)
|
||||
node _tools/audit-css.js # class 覆盖
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 已验证 / 未验证
|
||||
|
||||
诚实说明当前状态,避免上线踩坑。
|
||||
|
||||
### 已验证
|
||||
|
||||
| 项目 | 结果 |
|
||||
|---|---|
|
||||
| HTML 结构(嵌套、alt、lang、外链 rel) | 5 个页面全部通过 |
|
||||
| 编码(UTF-8、无 BOM、无乱码) | 5 个页面干净 |
|
||||
| 路径(无根绝对路径、无 `../`)与导航互达 | 全部通过 |
|
||||
| 图片资源存在性 | 75 个引用全部存在 |
|
||||
| 交互逻辑断言 | **64 项通过 / 0 失败** |
|
||||
| HTML ↔ CSS class 覆盖 | 无缺失 |
|
||||
| CSS 语法(括号配对) | 通过 |
|
||||
| 静态服务器实测五个页面 | 全部 200 |
|
||||
|
||||
### 未验证(重要)
|
||||
|
||||
1. **容器没有真正跑起来过。**
|
||||
开发这个站点的环境里没有安装 Docker,所以 `docker compose up` 我无法执行。
|
||||
Dockerfile / compose / nginx 配置都是按标准写法编写的,静态检查过,
|
||||
但**首次启动请留意 `docker compose logs` 有没有报错**。
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
docker compose logs -f
|
||||
docker compose ps # 确认 STATUS 是 healthy
|
||||
```
|
||||
|
||||
2. **浏览器渲染没有截图确认过。**
|
||||
同一环境的沙箱会拦截 Chrome 的进程间通信(mojo IPC),无法启动浏览器,
|
||||
所以视觉效果(间距、对齐、logo 大小)都是靠代码与几何计算保证的,
|
||||
**请你在浏览器里过一遍**。
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q:`docker compose up` 报端口被占用?**
|
||||
改 `.env` 里的 `SITE_PORT`,或改 `docker-compose.yml` 的 `ports` 左边数字。
|
||||
|
||||
**Q:改了内容但页面没变?**
|
||||
镜像里的文件是构建时 COPY 进去的,需要重新构建:
|
||||
`docker compose up -d --build`。
|
||||
另外 Nginx 配置里 HTML 是 `no-cache`,所以浏览器不会缓存旧页面,刷新即可。
|
||||
|
||||
**Q:想用 80 端口直接对外的域名?**
|
||||
`SITE_PORT=80`。正式环境建议前面再挂一层带 HTTPS 的反向代理
|
||||
(Nginx / Caddy / 宝塔 / 云负载均衡),本容器只监听 80。
|
||||
|
||||
**Q:可以直接把 `site/` 丢到虚拟主机吗?**
|
||||
可以,这是最省事的方式,不需要 Docker。
|
||||
|
||||
**Q:为什么图片都是 `.webp`?**
|
||||
体积比 jpg/png 小很多(首屏大图 1920px 宽只有约 82 KB)。所有现代浏览器都支持
|
||||
(Chrome / Edge / Firefox / Safari 14+)。
|
||||
|
||||
**Q:表单提交给谁了?**
|
||||
没有发给任何人,目前只做前端校验。要真正收件见上面「表单对接后端」。
|
||||
|
||||
---
|
||||
|
||||
## 版权
|
||||
|
||||
站点图文仅用于上海炫恒网络系统有限公司企业形象展示。
|
||||
第三方厂商名称与标识(Cisco、Microsoft、VMware、Citrix、H3C、Juniper、Radware、锐捷)
|
||||
版权归各自公司所有,仅用于说明合作关系。
|
||||
@@ -0,0 +1,47 @@
|
||||
// 品牌 logo 尺寸归一化:
|
||||
// logo 宽高比差异很大(Ruijie 很宽、CISCO 接近方形)。统一用「限定视觉高度」的方式,
|
||||
// 加载后按原始比例算出宽度并写回 style,避免宽扁的 logo 被压扁或显得偏下。
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const p = path.join(__dirname, '..', 'site', 'assets', 'js', 'main.js');
|
||||
let s = fs.readFileSync(p, 'utf8');
|
||||
|
||||
// 先移除上一次插入的版本(如果有)
|
||||
s = s.replace(/ \/\* -+ 品牌 logo 尺寸归一[\s\S]*?\n\n(?= \/\* -+ 品牌跑马灯)/, '');
|
||||
|
||||
const anchor = ` /* ------------------------------------------------------------------ 品牌跑马灯:自动复制一份内容实现无缝循环 */`;
|
||||
|
||||
const block = ` /* ------------------------------------------------------------------ 品牌 logo 尺寸归一 */
|
||||
function initBrandLogos() {
|
||||
$$('.marquee__item img, .brandcard img').forEach(function (img) {
|
||||
function fit() {
|
||||
var nw = img.naturalWidth, nh = img.naturalHeight;
|
||||
if (!nw || !nh) return;
|
||||
var box = img.parentNode.getBoundingClientRect();
|
||||
var availH = Math.max(24, (box.height || 100) - 28);
|
||||
var availW = Math.max(40, (box.width || 160) - 28);
|
||||
var h = Math.min(availH, 64);
|
||||
var w = h * nw / nh;
|
||||
if (w > availW) { w = availW; h = w * nh / nw; }
|
||||
img.style.height = Math.round(h) + 'px';
|
||||
img.style.width = Math.round(w) + 'px';
|
||||
}
|
||||
if (img.complete && img.naturalWidth) fit();
|
||||
else img.addEventListener('load', fit);
|
||||
});
|
||||
}
|
||||
|
||||
${anchor}`;
|
||||
|
||||
if (!s.includes(anchor)) {
|
||||
console.error('未找到插入锚点,脚本中止');
|
||||
process.exit(1);
|
||||
}
|
||||
s = s.replace(anchor, block);
|
||||
if (!s.includes('initBrandLogos();')) {
|
||||
s = s.replace(' initMarquee();', ' initMarquee();\n initBrandLogos();');
|
||||
}
|
||||
|
||||
fs.writeFileSync(p, s, 'utf8');
|
||||
console.log('main.js 已更新 initBrandLogos()');
|
||||
@@ -0,0 +1,31 @@
|
||||
// 检查 HTML 中用到的 class 是否都在 CSS 中有定义,并列出 CSS 中未被使用的 class
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SITE = path.join(__dirname, '..', 'site');
|
||||
const PAGES = ['index.html', 'brands.html', 'honors.html', 'cases.html', 'about.html'];
|
||||
const css = fs.readFileSync(path.join(SITE, 'assets/css/main.css'), 'utf8');
|
||||
|
||||
const defined = new Set([...css.matchAll(/\.([a-zA-Z][\w-]*)/g)].map(m => m[1]));
|
||||
const used = new Set();
|
||||
for (const f of PAGES) {
|
||||
const html = fs.readFileSync(path.join(SITE, f), 'utf8');
|
||||
for (const m of html.matchAll(/class="([^"]*)"/g)) {
|
||||
m[1].split(/\s+/).filter(Boolean).forEach(c => used.add(c));
|
||||
}
|
||||
}
|
||||
|
||||
const missing = [...used].filter(c => {
|
||||
if (defined.has(c)) return false;
|
||||
// 允许的运行时状态类(由 JS 添加,CSS 里以 body.xxx 或 .xxx 形式出现)
|
||||
return true;
|
||||
}).sort();
|
||||
|
||||
const unused = [...defined].filter(c => !used.has(c) && !/^(js|is|has|no)-/.test(c)).sort();
|
||||
|
||||
console.log('== HTML 使用但 CSS 未定义的 class ==');
|
||||
if (missing.length) missing.forEach(c => console.log(' x .' + c));
|
||||
else console.log(' (无)');
|
||||
|
||||
console.log('\n== CSS 定义但页面未使用的 class(可清理,不影响显示)==');
|
||||
console.log(' ' + (unused.length ? unused.join(', ') : '(无)'));
|
||||
@@ -0,0 +1,46 @@
|
||||
// 把内页里的深色区块 / 内联深色样式改成明亮版本,并统一图标 class
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SITE = path.join(__dirname, '..', 'site');
|
||||
const PAGES = ['brands.html', 'honors.html', 'cases.html', 'about.html'];
|
||||
|
||||
const darkSection = /<section class="section section--dark">/g;
|
||||
const darkBg = /<div class="cta__bg" data-lqip="([^"]+)"([^>]*?)style="opacity:\.\d+"([^>]*)><\/div>/g;
|
||||
|
||||
for (const f of PAGES) {
|
||||
const p = path.join(SITE, f);
|
||||
let s = fs.readFileSync(p, 'utf8');
|
||||
const before = s;
|
||||
let n = 0;
|
||||
|
||||
// 1) 深色区块 -> 暖色浅底
|
||||
s = s.replace(darkSection, () => { n++; return '<section class="section section--warm">'; });
|
||||
|
||||
// 2) 深色区块里的卡片内联样式(深底 + 白字)改成默认浅色
|
||||
s = s.replace(/style="--d:(\.\d+)s;background:rgba\(255,255,255,\.04\);border-color:rgba\(255,255,255,\.1\)"/g,
|
||||
(m, d) => { n++; return 'style="--d:' + d + 's"'; });
|
||||
s = s.replace(/style="color:#fff"/g, () => { n++; return ''; });
|
||||
s = s.replace(/<p style="color:#a8b6cc">/g, () => { n++; return '<p>'; });
|
||||
|
||||
// 3) 信息块的内联深色样式 -> 去掉,交给 CSS
|
||||
s = s.replace(/\s*style="background:rgba\(255,255,255,\.06\);border-color:rgba\(255,255,255,\.12\)"/g,
|
||||
() => { n++; return ''; });
|
||||
s = s.replace(/\s*style="color:#fff"/g, () => { n++; return ''; });
|
||||
s = s.replace(/\s*style="color:#a8b6cc"/g, () => { n++; return ''; });
|
||||
s = s.replace(/<b style="color:#fff">/g, () => { n++; return '<b>'; });
|
||||
|
||||
// 4) 深色徽标样式 -> 去掉
|
||||
s = s.replace(/\s*style="background:rgba\(255,193,7,\.12\);border-color:rgba\(255,193,7,\.32\);color:#fff"/g,
|
||||
() => { n++; return ''; });
|
||||
|
||||
// 5) CTA 里的深色遮罩层:去掉 opacity 内联,交给 CSS
|
||||
s = s.replace(/<div class="cta__bg" data-lqip="([^"]+)"([^>]*?)style="opacity:\.\d+"\s*><\/div>/g,
|
||||
(m, k, rest) => { n++; return '<div class="cta__bg" data-lqip="' + k + '" data-bg="assets/img/hero/' + k.split('/')[1] + '.webp"' + rest + '></div>'; });
|
||||
|
||||
// 6) 手风琴图标:chev → 由 CSS 统一控制
|
||||
s = s.replace(/ data-icon-class="chev chev"/g, () => { n++; return ''; });
|
||||
|
||||
if (s !== before) { fs.writeFileSync(p, s, 'utf8'); console.log('updated ' + f + ' (' + n + ' 处)'); }
|
||||
else console.log('no change ' + f);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// 提亮风格:把「灰蒙蒙」的几个成因逐条改掉
|
||||
// 1. 浅灰描边统一换成更清晰的 --line
|
||||
// 2. 首屏 / 内页页头的白色遮罩减弱,让图片更透、更亮
|
||||
// 3. 按钮描边加重
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const CSS = path.join(__dirname, '..', 'site', 'assets', 'css', 'main.css');
|
||||
let css = fs.readFileSync(CSS, 'utf8');
|
||||
const before = css;
|
||||
const log = [];
|
||||
|
||||
function sub(from, to, label, expect) {
|
||||
const count = css.split(from).length - 1;
|
||||
if (count === 0) { log.push(' !! 未找到: ' + label); return; }
|
||||
if (expect !== undefined && count !== expect) {
|
||||
log.push(' ~ ' + label + '(实际 ' + count + ' 处,预期 ' + expect + ')');
|
||||
} else {
|
||||
log.push(' OK ' + label + '(' + count + ' 处)');
|
||||
}
|
||||
css = css.split(from).join(to);
|
||||
}
|
||||
|
||||
// ---- 1. 描边统一 ----
|
||||
sub('border: 1px solid var(--ink-100);', 'border: 1px solid var(--line);', '卡片/元素描边 → --line');
|
||||
sub('border: 1.5px solid var(--ink-200);', 'border: 1.5px solid var(--line-strong);', 'chip / 输入框描边 → --line-strong');
|
||||
sub('border-bottom: 1px solid var(--ink-100);', 'border-bottom: 1px solid var(--line);', '分隔线 → --line');
|
||||
sub('border-top: 1px solid var(--ink-100);', 'border-top: 1px solid var(--line);', '顶部分隔线 → --line');
|
||||
sub('border-color: var(--ink-100);', 'border-color: var(--line);', 'border-color → --line');
|
||||
|
||||
// ---- 2. 首屏遮罩减弱:图片更透亮 ----
|
||||
sub(
|
||||
` linear-gradient(96deg, rgba(255, 255, 255, .96) 0%, rgba(255, 255, 255, .9) 24%,
|
||||
rgba(255, 253, 247, .7) 44%, rgba(255, 253, 247, .22) 68%,
|
||||
rgba(255, 253, 247, 0) 100%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, .5) 0%, transparent 22%,
|
||||
transparent 74%, rgba(255, 255, 255, .55) 100%);`,
|
||||
` linear-gradient(96deg, rgba(255, 255, 255, .95) 0%, rgba(255, 255, 255, .84) 26%,
|
||||
rgba(255, 255, 255, .52) 48%, rgba(255, 255, 255, .12) 72%,
|
||||
rgba(255, 255, 255, 0) 100%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, .34) 0%, transparent 20%,
|
||||
transparent 80%, rgba(255, 255, 255, .4) 100%);`,
|
||||
'首屏白色遮罩减弱');
|
||||
|
||||
sub(
|
||||
` linear-gradient(180deg, rgba(255, 255, 255, .95) 0%, rgba(255, 253, 247, .88) 44%,
|
||||
rgba(255, 253, 247, .72) 100%),
|
||||
linear-gradient(96deg, rgba(255, 255, 255, .8) 0%, rgba(255, 253, 247, .2) 100%);`,
|
||||
` linear-gradient(180deg, rgba(255, 255, 255, .93) 0%, rgba(255, 255, 255, .82) 44%,
|
||||
rgba(255, 255, 255, .66) 100%),
|
||||
linear-gradient(96deg, rgba(255, 255, 255, .72) 0%, rgba(255, 255, 255, .14) 100%);`,
|
||||
'首屏移动端遮罩减弱');
|
||||
|
||||
// ---- 3. 内页页头遮罩减弱 ----
|
||||
sub(
|
||||
` linear-gradient(94deg, rgba(255, 255, 255, .97) 0%, rgba(255, 255, 255, .93) 30%,
|
||||
rgba(255, 253, 247, .78) 52%, rgba(255, 253, 247, .3) 78%,
|
||||
rgba(255, 253, 247, .04) 100%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, .5), transparent 45%);`,
|
||||
` linear-gradient(94deg, rgba(255, 255, 255, .95) 0%, rgba(255, 255, 255, .88) 30%,
|
||||
rgba(255, 255, 255, .62) 54%, rgba(255, 255, 255, .2) 80%,
|
||||
rgba(255, 255, 255, .03) 100%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, .34), transparent 45%);`,
|
||||
'内页页头遮罩减弱');
|
||||
|
||||
// ---- 4. 页头网格装饰更清晰一点 ----
|
||||
sub('linear-gradient(rgba(15, 28, 49, .035) 1px, transparent 1px),\n linear-gradient(90deg, rgba(15, 28, 49, .035) 1px, transparent 1px);',
|
||||
'linear-gradient(rgba(11, 21, 38, .05) 1px, transparent 1px),\n linear-gradient(90deg, rgba(11, 21, 38, .05) 1px, transparent 1px);',
|
||||
'页头网格线加深');
|
||||
|
||||
// ---- 5. 卡片描边加重 + 悬浮不再虚化 ----
|
||||
sub('.card {\n position: relative;\n background: #fff;\n border: 1px solid var(--line);',
|
||||
'.card {\n position: relative;\n background: #fff;\n border: 1px solid var(--line);',
|
||||
'card 基础样式(占位,确保存在)');
|
||||
|
||||
// ---- 6. 深色页脚文字提亮一点,避免糊 ----
|
||||
sub('color: #93a4bd;', 'color: #9fb0c9;', '页脚正文提亮');
|
||||
sub('color: #8b9cb6;', 'color: #9aabc4;', '页脚段落提亮');
|
||||
sub('color: #6e7f99;', 'color: #8595ad;', '页脚底部提亮');
|
||||
|
||||
fs.writeFileSync(CSS, css, 'utf8');
|
||||
console.log(log.join('\n'));
|
||||
console.log('\nCSS 体积: ' + before.length + ' → ' + css.length);
|
||||
const o = (css.match(/{/g) || []).length, c = (css.match(/}/g) || []).length;
|
||||
console.log('大括号: ' + o + ' / ' + c + (o === c ? ' OK' : ' 不配对!'));
|
||||
@@ -0,0 +1,48 @@
|
||||
// 收尾:清掉剩余旧色值、首屏/页头底色改纯白、聚焦圈加强、去掉卡片灰色光晕
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const CSS = path.join(__dirname, '..', 'site', 'assets', 'css', 'main.css');
|
||||
let css = fs.readFileSync(CSS, 'utf8');
|
||||
const log = [];
|
||||
|
||||
function sub(from, to, label) {
|
||||
const n = css.split(from).length - 1;
|
||||
css = css.split(from).join(to);
|
||||
log.push(' ' + (n ? 'OK ' : '!! ') + label + '(' + n + ' 处)');
|
||||
}
|
||||
|
||||
// 1) 暖白底提纯
|
||||
sub('#fffdf7', '#fffefb', '暖白底提纯');
|
||||
|
||||
// 2) 残留的旧深蓝统一
|
||||
sub('rgba(15, 28, 49,', 'rgba(11, 21, 38,', '旧深蓝 rgba 统一');
|
||||
|
||||
// 3) 首屏底色改纯白
|
||||
sub(' background: var(--bg-warm);\n overflow: hidden;\n padding: clamp(60px, 6vw, 88px) 0 clamp(56px, 5.5vw, 84px);',
|
||||
' background: #fff;\n overflow: hidden;\n padding: clamp(60px, 6vw, 88px) 0 clamp(56px, 5.5vw, 84px);',
|
||||
'首屏底色改纯白');
|
||||
|
||||
// 4) 内页页头底色改纯白
|
||||
sub(' background: var(--bg-warm);\n overflow: hidden;\n isolation: isolate;\n border-bottom: 1px solid var(--line);',
|
||||
' background: #fff;\n overflow: hidden;\n isolation: isolate;\n border-bottom: 1px solid var(--line);',
|
||||
'内页页头底色改纯白');
|
||||
|
||||
// 5) 输入框聚焦圈加强
|
||||
sub('box-shadow: 0 0 0 4px rgba(255, 198, 26, .18);',
|
||||
'box-shadow: 0 0 0 3px rgba(255, 193, 7, .3);',
|
||||
'输入框聚焦圈加强');
|
||||
|
||||
// 6) 去掉 .card 的灰色径向光晕(它让卡片发灰)
|
||||
const before6 = css;
|
||||
css = css.replace(/\n\.card::after \{[\s\S]*?\n\}\n/, '\n');
|
||||
css = css.replace(/\n\.card:hover::after \{[^}]*\}\n/, '\n');
|
||||
log.push(' ' + (css !== before6 ? 'OK 已移除 .card 的灰色光晕层' : '!! 未找到 .card::after'));
|
||||
|
||||
fs.writeFileSync(CSS, css, 'utf8');
|
||||
console.log(log.join('\n'));
|
||||
const o = (css.match(/{/g) || []).length, c = (css.match(/}/g) || []).length;
|
||||
console.log('\n大括号: ' + o + ' / ' + c + (o === c ? ' OK' : ' 不配对!'));
|
||||
console.log('残留 #fffdf7: ' + (css.match(/#fffdf7/g) || []).length +
|
||||
' / rgba(15,28,49: ' + (css.match(/rgba\(15, 28, 49/g) || []).length);
|
||||
console.log('变量 --bg-warm 仍被引用: ' + (css.match(/var\(--bg-warm\)/g) || []).length + ' 处');
|
||||
@@ -0,0 +1,353 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
炫恒网络官网 - 素材处理脚本
|
||||
把原始素材转成站点可直接使用的 web 资源:
|
||||
* 复制 + 压缩(webp / jpg / png),控制体积
|
||||
* 生成 logo 多倍图与 favicon
|
||||
* 生成 LQIP(超小模糊占位图)用于渐进式加载
|
||||
运行: python tools/build_assets.py
|
||||
"""
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
from PIL import Image, ImageFilter
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SITE = os.path.join(ROOT, "site")
|
||||
ASSETS = os.path.join(SITE, "assets")
|
||||
IMG = os.path.join(ASSETS, "img")
|
||||
|
||||
SRC_BRAND = os.path.join(ROOT, "合作品牌")
|
||||
SRC_HONOR = os.path.join(ROOT, "荣誉资质")
|
||||
SRC_BG = os.path.join(ROOT, "背景图")
|
||||
SRC_BG_LB = os.path.join(SRC_BG, "轮播")
|
||||
SRC_PLAN = os.path.join(ROOT, "方案")
|
||||
SRC_CASE = os.path.join(ROOT, "老网站", "10.0.0.131", "10.0.0.131", "assets", "img",
|
||||
"solution-details")
|
||||
|
||||
OUT_DIRS = [
|
||||
IMG,
|
||||
os.path.join(IMG, "brands"),
|
||||
os.path.join(IMG, "honors"),
|
||||
os.path.join(IMG, "hero"),
|
||||
os.path.join(IMG, "cases"),
|
||||
os.path.join(IMG, "solutions"),
|
||||
os.path.join(IMG, "misc"),
|
||||
]
|
||||
|
||||
LQIP = {}
|
||||
|
||||
|
||||
def ensure_dirs():
|
||||
for d in OUT_DIRS:
|
||||
os.makedirs(d, exist_ok=True)
|
||||
|
||||
|
||||
def save_webp(im, path, quality=80, method=6):
|
||||
im.save(path, "WEBP", quality=quality, method=method)
|
||||
|
||||
|
||||
def save_jpg(im, path, quality=82, progressive=True):
|
||||
im.convert("RGB").save(path, "JPEG", quality=quality,
|
||||
optimize=True, progressive=progressive)
|
||||
|
||||
|
||||
def resize_to_width(im, width):
|
||||
if im.width <= width:
|
||||
return im.copy()
|
||||
h = round(im.height * width / im.width)
|
||||
return im.resize((width, h), Image.LANCZOS)
|
||||
|
||||
|
||||
def cover_crop(im, tw, th):
|
||||
"""按目标宽高比裁剪(居中),再缩放"""
|
||||
im = im.convert("RGB")
|
||||
tr = tw / th
|
||||
r = im.width / im.height
|
||||
if r > tr:
|
||||
nw = round(im.height * tr)
|
||||
left = (im.width - nw) // 2
|
||||
im = im.crop((left, 0, left + nw, im.height))
|
||||
else:
|
||||
nh = round(im.width / tr)
|
||||
top = max(0, (im.height - nh) // 2)
|
||||
im = im.crop((0, top, im.width, top + nh))
|
||||
return im.resize((tw, th), Image.LANCZOS)
|
||||
|
||||
|
||||
def make_lqip(im, key, width=28):
|
||||
small = resize_to_width(im.convert("RGB"), width)
|
||||
small = small.filter(ImageFilter.GaussianBlur(1.2))
|
||||
buf = io.BytesIO()
|
||||
small.save(buf, "JPEG", quality=45)
|
||||
LQIP[key] = "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 品牌 logo
|
||||
def build_brand_logos():
|
||||
names = {
|
||||
"锐捷.png": "ruijie",
|
||||
"思科.png": "cisco",
|
||||
"华三.png": "h3c",
|
||||
"Microsoft.png": "microsoft",
|
||||
"vmware.png": "vmware",
|
||||
"radware.png": "radware",
|
||||
"juniper.png": "juniper",
|
||||
"citrix.png": "citrix",
|
||||
}
|
||||
for src, slug in names.items():
|
||||
p = os.path.join(SRC_BRAND, src)
|
||||
if not os.path.exists(p):
|
||||
log(" ! 缺少 " + src)
|
||||
continue
|
||||
im = Image.open(p).convert("RGBA")
|
||||
# 统一裁剪到内容边界并留白,让视觉尺寸更一致
|
||||
bbox = im.split()[3].getbbox()
|
||||
if bbox:
|
||||
im = im.crop(bbox)
|
||||
im = im.resize((320, round(320 * im.height / im.width)), Image.LANCZOS)
|
||||
canvas = Image.new("RGBA", (360, 360), (0, 0, 0, 0))
|
||||
canvas.paste(im, ((360 - im.width) // 2, (360 - im.height) // 2), im)
|
||||
save_webp(canvas, os.path.join(IMG, "brands", slug + ".webp"), 88)
|
||||
log(" brand " + slug)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 荣誉证书
|
||||
def build_honors():
|
||||
items = [
|
||||
("荣誉资质/锐捷行业白金证书2026.png", "ruijie-industry", "锐捷行业白金证书"),
|
||||
("荣誉资质/锐捷商业白金证书.png", "ruijie-business", "锐捷商业白金证书"),
|
||||
("荣誉资质/锐捷服务商证书2026.png", "ruijie-service", "锐捷服务商证书"),
|
||||
("荣誉资质/锐捷行业白金证书.png", "ruijie-industry-old", "锐捷行业白金证书(往期)"),
|
||||
("荣誉资质/思科代理商证书.jpg", "cisco-agent", "思科代理商证书"),
|
||||
("荣誉资质/H3C代理商证书.jpg", "h3c-agent", "H3C 代理商证书"),
|
||||
("荣誉资质/juniper代理商证书.JPG", "juniper-agent", "Juniper 代理商证书"),
|
||||
("荣誉资质/radware代理商证书.jpg", "radware-agent", "Radware 代理商证书"),
|
||||
("荣誉资质/电信业务经营许可证.png", "telecom-license", "电信业务经营许可证"),
|
||||
]
|
||||
out = []
|
||||
for rel, slug, title in items:
|
||||
p = os.path.join(ROOT, rel)
|
||||
if not os.path.exists(p):
|
||||
log(" ! 缺少 " + rel)
|
||||
continue
|
||||
im = Image.open(p).convert("RGB")
|
||||
big = resize_to_width(im, 1100)
|
||||
save_webp(big, os.path.join(IMG, "honors", slug + ".webp"), 80)
|
||||
thumb = resize_to_width(im, 560)
|
||||
save_webp(thumb, os.path.join(IMG, "honors", slug + "-thumb.webp"), 78)
|
||||
make_lqip(im, "honors/" + slug)
|
||||
out.append(slug)
|
||||
log(" honor %s %dx%d" % (slug, im.width, im.height))
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 个人认证证书
|
||||
PERSON_GROUP_SLUG = {
|
||||
"交换路由": "route",
|
||||
"云桌面": "cloud",
|
||||
"思科": "cisco",
|
||||
"RCTE": "rcte",
|
||||
"项目经理": "pm",
|
||||
}
|
||||
|
||||
|
||||
def person_slug(group, fn, i):
|
||||
g = PERSON_GROUP_SLUG.get(group)
|
||||
if g is None:
|
||||
g = "g%02d" % (abs(hash(group)) % 90 + 10)
|
||||
return "%s-%02d" % (g, i)
|
||||
|
||||
|
||||
def build_person_certs():
|
||||
"""老网站里的个人技术认证证书(另有目录保留原图,此处只做压缩副本)"""
|
||||
base = os.path.join(ROOT, "老网站", "10.0.0.131", "10.0.0.131", "assets", "img", "about", "2")
|
||||
if not os.path.isdir(base):
|
||||
return
|
||||
out = os.path.join(IMG, "honors", "person")
|
||||
os.makedirs(out, exist_ok=True)
|
||||
manifest = []
|
||||
for group in sorted(os.listdir(base)):
|
||||
gdir = os.path.join(base, group)
|
||||
if not os.path.isdir(gdir):
|
||||
continue
|
||||
files = sorted(fn for fn in os.listdir(gdir)
|
||||
if os.path.splitext(fn)[1].lower() in (".jpg", ".jpeg", ".png"))
|
||||
for i, fn in enumerate(files, 1):
|
||||
try:
|
||||
im = Image.open(os.path.join(gdir, fn)).convert("RGB")
|
||||
except Exception:
|
||||
continue
|
||||
slug = person_slug(group, fn, i)
|
||||
save_webp(resize_to_width(im, 760), os.path.join(out, slug + ".webp"), 78)
|
||||
make_lqip(im, "honors/person/" + slug)
|
||||
manifest.append((group, fn, slug))
|
||||
log(" person cert %s / %s -> %s" % (group, fn, slug))
|
||||
with open(os.path.join(out, "manifest.tsv"), "w", encoding="utf-8") as f:
|
||||
f.write("分组\t原始文件\t站点文件名\n")
|
||||
for g, fn, slug in manifest:
|
||||
f.write("%s\t%s\t%s.webp\n" % (g, fn, slug))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 头图 / 背景
|
||||
def build_hero():
|
||||
slides = [
|
||||
("背景图/轮播/lunbo01.png", "hero-1", "构建高效互联的数字未来"),
|
||||
("背景图/轮播/lunbo02.png", "hero-2", "稳定连接 · 高效网络"),
|
||||
("背景图/轮播/lunbo03.png", "hero-3", "专业建设 · 简单连接"),
|
||||
]
|
||||
for rel, slug, _ in slides:
|
||||
p = os.path.join(ROOT, rel)
|
||||
if not os.path.exists(p):
|
||||
log(" ! 缺少 " + rel)
|
||||
continue
|
||||
im = Image.open(p)
|
||||
big = resize_to_width(im, 1920)
|
||||
save_webp(big, os.path.join(IMG, "hero", slug + ".webp"), 80)
|
||||
mid = resize_to_width(im, 1200)
|
||||
save_webp(mid, os.path.join(IMG, "hero", slug + "-md.webp"), 76)
|
||||
make_lqip(im, "hero/" + slug)
|
||||
log(" hero %s" % slug)
|
||||
|
||||
# 深色科技背景(做 CTA / 页脚)
|
||||
for rel, slug in [("背景图/2.png", "tech-dark"),
|
||||
("背景图/ChatGPT Image 2026年6月8日 15_51_29(1).png", "tech-dark-2")]:
|
||||
p = os.path.join(ROOT, rel)
|
||||
if not os.path.exists(p):
|
||||
continue
|
||||
im = Image.open(p)
|
||||
big = resize_to_width(im, 1600)
|
||||
save_webp(big, os.path.join(IMG, "hero", slug + ".webp"), 78)
|
||||
make_lqip(im, "hero/" + slug)
|
||||
log(" bg " + slug)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 方案图
|
||||
def build_solutions():
|
||||
items = [
|
||||
("方案/地产.png", "realestate", "地产商业综合体"),
|
||||
("方案/工业.png", "industry", "工厂制造业"),
|
||||
("方案/金融.png", "finance", "金融行业"),
|
||||
("方案/三甲医院.png", "medical", "医疗行业"),
|
||||
]
|
||||
for rel, slug, _ in items:
|
||||
p = os.path.join(ROOT, rel)
|
||||
if not os.path.exists(p):
|
||||
log(" ! 缺少 " + rel)
|
||||
continue
|
||||
im = Image.open(p)
|
||||
save_webp(cover_crop(im, 1000, 700), os.path.join(IMG, "solutions", slug + ".webp"), 78)
|
||||
make_lqip(im, "solutions/" + slug)
|
||||
log(" solution " + slug)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 案例图
|
||||
def build_cases():
|
||||
pairs = [
|
||||
(os.path.join(SRC_CASE, "地产商业综合体案例.jpg"), "realestate", 1400),
|
||||
(os.path.join(SRC_CASE, "工厂制造业案例", "药企工厂案例.jpg"), "industry", 1400),
|
||||
(os.path.join(SRC_CASE, "工厂制造业案例", "图片1.png"), "industry-2", 1200),
|
||||
(os.path.join(SRC_CASE, "金融公司案例", "1.png"), "finance", 1200),
|
||||
(os.path.join(SRC_CASE, "金融公司案例", "c26fc86b1715c1963021096104b0673.png"), "finance-2", 1200),
|
||||
(os.path.join(SRC_CASE, "金融公司案例", "图片2.png"), "finance-3", 1200),
|
||||
(os.path.join(SRC_CASE, "商业连锁门店案例", "药业连锁门店案例.jpg"), "retail", 1400),
|
||||
(os.path.join(SRC_CASE, "商业连锁门店案例",
|
||||
"主要中标项目2 XX知名快消品牌全国门店网络系统建设及运维项目.jpg"), "retail-2", 1400),
|
||||
(os.path.join(SRC_CASE, "三甲医院案例", "三甲医院案例1.jpg"), "medical", 1400),
|
||||
(os.path.join(SRC_CASE, "三甲医院案例2", "三甲医院案例2.jpg"), "medical-2", 1400),
|
||||
(os.path.join(SRC_CASE, "三甲医院案例2", "1.png"), "medical-3", 1200),
|
||||
(os.path.join(ROOT, "成功案例-吾悦架构图.png"), "wuyue-arch", 1100),
|
||||
(os.path.join(ROOT, "三甲医院-封面.jpeg"), "medical-cover", 1000),
|
||||
]
|
||||
for path, slug, maxw in pairs:
|
||||
if not os.path.exists(path):
|
||||
log(" ! 缺少 " + os.path.basename(path))
|
||||
continue
|
||||
im = Image.open(path).convert("RGB")
|
||||
big = resize_to_width(im, maxw)
|
||||
save_webp(big, os.path.join(IMG, "cases", slug + ".webp"), 80)
|
||||
thumb = cover_crop(im, 900, 620) if slug != "wuyue-arch" else resize_to_width(im, 900)
|
||||
save_webp(thumb, os.path.join(IMG, "cases", slug + "-thumb.webp"), 76)
|
||||
make_lqip(im, "cases/" + slug)
|
||||
log(" case %s %dx%d" % (slug, im.width, im.height))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- LOGO & favicon
|
||||
def build_logo():
|
||||
src = os.path.join(ROOT, "XH-logo.png")
|
||||
im = Image.open(src).convert("RGBA")
|
||||
# 裁掉多余透明边缘,再按倍率放大(几何图形放大后依然干净)
|
||||
bbox = im.split()[3].getbbox()
|
||||
if bbox:
|
||||
im = im.crop(bbox)
|
||||
for scale, name in ((1, "logo-128.png"), (2, "logo-256.png"), (3, "logo-384.png")):
|
||||
size = 128 * scale
|
||||
h = round(size * im.height / im.width)
|
||||
im.resize((size, h), Image.LANCZOS).save(os.path.join(IMG, "misc", name))
|
||||
# 白色版(用于深色页脚):把非透明像素统一为白色
|
||||
white = Image.new("RGBA", im.size, (255, 255, 255, 0))
|
||||
alpha = im.split()[3]
|
||||
solid = Image.new("RGBA", im.size, (255, 255, 255, 255))
|
||||
white = Image.composite(solid, white, alpha)
|
||||
white.resize((384, round(384 * white.height / white.width)), Image.LANCZOS).save(
|
||||
os.path.join(IMG, "misc", "logo-white-384.png"))
|
||||
|
||||
# favicon
|
||||
fav = os.path.join(SITE, "favicon.ico")
|
||||
base = im.copy()
|
||||
base.thumbnail((256, 256), Image.LANCZOS)
|
||||
base.save(fav, sizes=[(16, 16), (32, 32), (48, 48), (64, 64)])
|
||||
for s in (32, 180, 192, 512):
|
||||
ic = im.resize((s, round(s * im.height / im.width)), Image.LANCZOS)
|
||||
ic.save(os.path.join(IMG, "misc", "favicon-%dx%d.png" % (s, s)))
|
||||
log(" logo + favicon")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- LQIP 输出
|
||||
def write_lqip():
|
||||
out = os.path.join(ASSETS, "lqip.css")
|
||||
with open(out, "w", encoding="utf-8") as f:
|
||||
f.write("/* 自动生成:图片模糊占位(渐进加载) */\n")
|
||||
for k, v in sorted(LQIP.items()):
|
||||
f.write('[data-lqip="%s"]{background-image:url(%s)}\n' % (k, v))
|
||||
log(" lqip.css (%d 条)" % len(LQIP))
|
||||
|
||||
# 同时输出一份 JS map,供 lightbox 使用
|
||||
out2 = os.path.join(ASSETS, "lqip.js")
|
||||
with open(out2, "w", encoding="utf-8") as f:
|
||||
f.write("window.XH_LQIP = {\n")
|
||||
for k, v in sorted(LQIP.items()):
|
||||
f.write(' "%s": "%s",\n' % (k, v))
|
||||
f.write("};\n")
|
||||
|
||||
|
||||
def main():
|
||||
ensure_dirs()
|
||||
log("== 处理品牌 logo ==")
|
||||
build_brand_logos()
|
||||
log("== 处理荣誉证书 ==")
|
||||
build_honors()
|
||||
log("== 处理个人认证 ==")
|
||||
build_person_certs()
|
||||
log("== 处理头图 ==")
|
||||
build_hero()
|
||||
log("== 处理方案图 ==")
|
||||
build_solutions()
|
||||
log("== 处理案例图 ==")
|
||||
build_cases()
|
||||
log("== 处理 LOGO / favicon ==")
|
||||
build_logo()
|
||||
log("== 生成模糊占位 ==")
|
||||
write_lqip()
|
||||
log("完成。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,93 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""增量素材:新首屏图(背景图/轮播/lunbo01(2).png)+ 用「方案」目录图替换行业方案卡封面。
|
||||
只做新增/覆盖,不重建全部资源。运行: python _tools/build_assets_v2.py
|
||||
"""
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
from PIL import Image, ImageFilter
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
IMG = os.path.join(ROOT, "site", "assets", "img")
|
||||
LQIP_LINES = []
|
||||
|
||||
|
||||
def resize_to_width(im, width):
|
||||
if im.width <= width:
|
||||
return im.copy()
|
||||
return im.resize((width, round(im.height * width / im.width)), Image.LANCZOS)
|
||||
|
||||
|
||||
def save_webp(im, path, q=80):
|
||||
im.convert("RGB").save(path, "WEBP", quality=q, method=6)
|
||||
print(" %-46s %6.1f KB" % (os.path.basename(path), os.path.getsize(path) / 1024))
|
||||
|
||||
|
||||
def lqip(im, key, width=26):
|
||||
small = resize_to_width(im.convert("RGB"), width).filter(ImageFilter.GaussianBlur(1.2))
|
||||
buf = io.BytesIO()
|
||||
small.save(buf, "JPEG", quality=45)
|
||||
LQIP_LINES.append('[data-lqip="%s"]{background-image:url(data:image/jpeg;base64,%s)}'
|
||||
% (key, base64.b64encode(buf.getvalue()).decode()))
|
||||
|
||||
|
||||
def main():
|
||||
print("== 新首屏图 hero-1(来自 背景图/轮播/lunbo01(2).png)==")
|
||||
src = os.path.join(ROOT, "背景图", "轮播", "lunbo01(2).png")
|
||||
im = Image.open(src)
|
||||
print(" 源尺寸 %dx%d" % im.size)
|
||||
save_webp(resize_to_width(im, 1920), os.path.join(IMG, "hero", "hero-1.webp"), 82)
|
||||
save_webp(resize_to_width(im, 1200), os.path.join(IMG, "hero", "hero-1-md.webp"), 78)
|
||||
save_webp(resize_to_width(im, 2880), os.path.join(IMG, "hero", "hero-1@2x.webp"), 74)
|
||||
lqip(im, "hero/hero-1")
|
||||
|
||||
print("== 行业方案卡封面(来自 方案/ 目录,原图无文字)==")
|
||||
mapping = [
|
||||
("方案/地产.png", "realestate"),
|
||||
("方案/工业.png", "industry"),
|
||||
("方案/金融.png", "finance"),
|
||||
("方案/三甲医院.png", "medical"),
|
||||
]
|
||||
for rel, slug in mapping:
|
||||
p = os.path.join(ROOT, rel)
|
||||
if not os.path.exists(p):
|
||||
print(" ! 缺少 " + rel)
|
||||
continue
|
||||
s = Image.open(p).convert("RGB")
|
||||
h = round(1000 * 3 / 4)
|
||||
# 居中裁剪成 4:3,保证卡片里构图完整
|
||||
tr = 4 / 3
|
||||
if s.width / s.height > tr:
|
||||
nw = round(s.height * tr)
|
||||
left = (s.width - nw) // 2
|
||||
s = s.crop((left, 0, left + nw, s.height))
|
||||
else:
|
||||
nh = round(s.width / tr)
|
||||
top = max(0, (s.height - nh) // 2)
|
||||
s = s.crop((0, top, s.width, top + nh))
|
||||
s = s.resize((1000, h), Image.LANCZOS)
|
||||
save_webp(s, os.path.join(IMG, "solutions", slug + ".webp"), 80)
|
||||
lqip(s, "solutions/" + slug)
|
||||
|
||||
print("== 更新 lqip.css / lqip.js 中的对应条目 ==")
|
||||
for target in ("lqip.css", "lqip.js"):
|
||||
path = os.path.join(ROOT, "site", "assets", target)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
for line in LQIP_LINES:
|
||||
key = line.split('"')[1]
|
||||
if target == "lqip.css":
|
||||
import re
|
||||
text = re.sub(r'\[data-lqip="%s"\]\{background-image:url\([^)]*\)\}' % re.escape(key),
|
||||
line, text)
|
||||
else:
|
||||
import re
|
||||
text = re.sub(r'"%s":\s*"[^"]*"' % re.escape(key),
|
||||
'"%s": "%s"' % (key, line.split("url(")[1][:-1]), text)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
print(" 已更新 " + target)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,61 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""按视觉重量统一品牌 logo:去掉原图自带的巨大留白,按内容高度归一化,
|
||||
统一放在固定画布里垂直居中。同时给 favicon 生成更清晰的版本。
|
||||
运行: python _tools/build_brands_v2.py
|
||||
"""
|
||||
import os
|
||||
from PIL import Image
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SRC = os.path.join(ROOT, "合作品牌")
|
||||
OUT = os.path.join(ROOT, "site", "assets", "img", "brands")
|
||||
|
||||
# 宽扁的标志给大一点高度,接近方形的给小一点,视觉重量才均衡
|
||||
TARGET_H = {
|
||||
"ruijie": 54, # Ruijie 锐捷 —— 很宽
|
||||
"cisco": 70, # CISCO —— 中等
|
||||
"h3c": 46, # H3C —— 很宽
|
||||
"microsoft": 44, # Microsoft —— 极宽
|
||||
"vmware": 40, # vmware —— 极宽且扁
|
||||
"citrix": 54, # CITRIX
|
||||
"juniper": 50, # JUNIPER —— 宽
|
||||
"radware": 46, # radware —— 很宽
|
||||
}
|
||||
|
||||
W, H = 360, 240 # 画布:5:3,横向留白更合理
|
||||
|
||||
FILES = {
|
||||
"锐捷.png": "ruijie",
|
||||
"思科.png": "cisco",
|
||||
"华三.png": "h3c",
|
||||
"Microsoft.png": "microsoft",
|
||||
"vmware.png": "vmware",
|
||||
"citrix.png": "citrix",
|
||||
"juniper.png": "juniper",
|
||||
"radware.png": "radware",
|
||||
}
|
||||
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
for src, slug in FILES.items():
|
||||
p = os.path.join(SRC, src)
|
||||
if not os.path.exists(p):
|
||||
print(" ! 缺少 " + src)
|
||||
continue
|
||||
im = Image.open(p).convert("RGBA")
|
||||
bbox = im.split()[3].getbbox()
|
||||
if bbox:
|
||||
im = im.crop(bbox)
|
||||
th = TARGET_H[slug]
|
||||
tw = max(1, round(im.width * th / im.height))
|
||||
if tw > W - 32: # 太宽就按宽度约束
|
||||
tw = W - 32
|
||||
th = max(1, round(im.height * tw / im.width))
|
||||
im = im.resize((tw, th), Image.LANCZOS)
|
||||
canvas = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
canvas.paste(im, ((W - tw) // 2, (H - th) // 2), im)
|
||||
out = os.path.join(OUT, slug + ".webp")
|
||||
canvas.save(out, "WEBP", quality=90, method=6)
|
||||
print(" %-10s 内容 %dx%d 画布 %dx%d %5.1f KB" % (slug, tw, th, W, H, os.path.getsize(out) / 1024))
|
||||
|
||||
print("完成:品牌 logo 已按视觉重量归一化")
|
||||
@@ -0,0 +1,56 @@
|
||||
// 色值对比度核算(WCAG)+ 页脚结构复查
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function lum(hex) {
|
||||
const c = hex.replace('#', '');
|
||||
const v = [0, 2, 4].map(i => parseInt(c.substr(i, 2), 16) / 255)
|
||||
.map(x => x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4));
|
||||
return 0.2126 * v[0] + 0.7152 * v[1] + 0.0722 * v[2];
|
||||
}
|
||||
function ratio(a, b) {
|
||||
const l1 = lum(a), l2 = lum(b);
|
||||
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||||
}
|
||||
|
||||
const PAIRS = [
|
||||
['标题 ink-900 / 白底', '#0b1526', '#ffffff', 7],
|
||||
['正文 text-2 / 白底', '#45566e', '#ffffff', 4.5],
|
||||
['次要 text-3 / 白底', '#78889b', '#ffffff', 3],
|
||||
['深金 y-700 / 白底', '#8a5e00', '#ffffff', 4.5],
|
||||
['深金 y-700 / 浅黄底', '#8a5e00', '#fff8df', 4.5],
|
||||
['按钮文字 ink-900 / 主黄', '#0b1526', '#ffc107', 4.5],
|
||||
['页脚正文 / 页脚底', '#9fb0c9', '#07101f', 4.5],
|
||||
['页脚标题 / 页脚底', '#ffffff', '#07101f', 7],
|
||||
['页脚次级 / 页脚底', '#8595ad', '#07101f', 3],
|
||||
['页脚黄 / 页脚底', '#ffd23d', '#07101f', 4.5]
|
||||
];
|
||||
|
||||
console.log('== 文字对比度(WCAG,越大越好)==');
|
||||
let bad = 0;
|
||||
for (const [label, fg, bg, need] of PAIRS) {
|
||||
const r = ratio(fg, bg);
|
||||
const pass = r >= need;
|
||||
if (!pass) bad++;
|
||||
console.log(' ' + (pass ? 'OK ' : 'LOW ') + label.padEnd(28) +
|
||||
r.toFixed(2) + ':1 (需要 ≥' + need + ')');
|
||||
}
|
||||
|
||||
console.log('\n== 页脚结构复查 ==');
|
||||
const SITE = path.join(__dirname, '..', 'site');
|
||||
for (const f of ['index.html', 'brands.html', 'honors.html', 'cases.html', 'about.html']) {
|
||||
const s = fs.readFileSync(path.join(SITE, f), 'utf8');
|
||||
const hasNote = /本站图文/.test(s);
|
||||
const footOpen = (s.match(/<footer/g) || []).length;
|
||||
const footClose = (s.match(/<\/footer>/g) || []).length;
|
||||
const bottom = (s.match(/footer__bottom/g) || []).length;
|
||||
const ok = !hasNote && footOpen === 1 && footClose === 1 && bottom === 1;
|
||||
console.log(' ' + (ok ? 'OK ' : 'FAIL') + f.padEnd(14) +
|
||||
'footer标签 ' + footOpen + '/' + footClose +
|
||||
' footer__bottom ' + bottom +
|
||||
(hasNote ? ' !! 仍有"本站图文"字样' : ''));
|
||||
if (!ok) bad++;
|
||||
}
|
||||
|
||||
console.log('\n' + (bad ? bad + ' 项需要关注' : '全部通过'));
|
||||
process.exit(bad ? 1 : 0);
|
||||
@@ -0,0 +1,181 @@
|
||||
/* Docker 打包产物的静态校验:
|
||||
* 1. Dockerfile 的 COPY 源路径是否存在、目标路径是否安全(不能覆盖 nginx 自带 conf)
|
||||
* 2. Dockerfile 自检清单里的文件是否真实存在
|
||||
* 3. nginx/default.conf 括号与指令配平、是否有重复 server、关键指令是否齐全
|
||||
* 4. docker-compose.yml 结构(无 YAML 库,用缩进 + 关键字段解析)
|
||||
* 5. .dockerignore 是否误排除了构建必需的文件
|
||||
* 6. README 里提到的路径是否都存在
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
let problems = 0;
|
||||
const ok = m => console.log(' OK ' + m);
|
||||
const bad = m => { problems++; console.log(' FAIL ' + m); };
|
||||
|
||||
function read(p) { return fs.readFileSync(path.join(ROOT, p), 'utf8'); }
|
||||
function exists(p) { return fs.existsSync(path.join(ROOT, p)); }
|
||||
|
||||
/* ---------------------------------------------------------------- 1. Dockerfile */
|
||||
console.log('== 1. Dockerfile ==');
|
||||
const dockerfile = read('Dockerfile');
|
||||
const copies = [...dockerfile.matchAll(/^COPY\s+(\S+)\s+(\S+)\s*$/gm)].map(m => ({ src: m[1], dest: m[2] }));
|
||||
if (!copies.length) bad('没有解析到任何 COPY 指令');
|
||||
copies.forEach(c => {
|
||||
const src = c.src.replace(/\/$/, '');
|
||||
if (!exists(src)) bad('COPY 源路径不存在: ' + c.src);
|
||||
else {
|
||||
const isDir = fs.statSync(path.join(ROOT, src)).isDirectory();
|
||||
// COPY dir/ dest/ 时,源必须是目录
|
||||
if (c.src.endsWith('/') !== isDir) {
|
||||
bad('COPY 源路径结尾斜杠与类型不匹配: ' + c.src + (isDir ? '(是目录,应写 site/)' : '(是文件,不该带斜杠)'));
|
||||
} else {
|
||||
ok('COPY ' + c.src + ' -> ' + c.dest);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 目标路径安全检查
|
||||
copies.forEach(c => {
|
||||
if (c.dest === '/etc/nginx/conf.d/default.conf') {
|
||||
ok('目标为 nginx 站点配置(会覆盖镜像默认 conf,正确)');
|
||||
}
|
||||
});
|
||||
|
||||
// Dockerfile 内的自检清单文件
|
||||
const checkBlock = dockerfile.match(/for f in ([^;]+); do/);
|
||||
if (!checkBlock) bad('未找到构建期自检清单');
|
||||
else {
|
||||
const files = checkBlock[1].split(/[\s\\]+/).filter(Boolean);
|
||||
const missing = files.filter(f => !exists('site/' + f));
|
||||
if (missing.length) bad('自检清单里的文件不存在于 site/:' + missing.join(', '));
|
||||
else ok('构建期自检的 ' + files.length + ' 个文件全部存在于 site/');
|
||||
}
|
||||
|
||||
if (!/^EXPOSE 80$/m.test(dockerfile)) bad('缺少 EXPOSE 80');
|
||||
else ok('EXPOSE 80');
|
||||
if (!/HEALTHCHECK/.test(dockerfile)) bad('缺少 HEALTHCHECK');
|
||||
else ok('包含 HEALTHCHECK');
|
||||
if (!/\/healthz/.test(dockerfile)) bad('HEALTHCHECK 未打 /healthz');
|
||||
else ok('HEALTHCHECK 打的是 /healthz(与 nginx 配置一致)');
|
||||
if (!/nginx -t/.test(dockerfile)) bad('构建期没有做 nginx -t 校验');
|
||||
else ok('构建期执行 nginx -t');
|
||||
const fromMatch = dockerfile.match(/^FROM\s+(\S+)/m);
|
||||
if (!fromMatch) bad('没有 FROM 指令');
|
||||
else if (!/^nginx:/.test(fromMatch[1])) bad('基础镜像不是 nginx: ' + fromMatch[1]);
|
||||
else ok('基础镜像 ' + fromMatch[1]);
|
||||
|
||||
/* ---------------------------------------------------------------- 2. nginx 配置 */
|
||||
console.log('\n== 2. nginx/default.conf ==');
|
||||
if (!exists('nginx/default.conf')) bad('nginx/default.conf 不存在');
|
||||
else {
|
||||
const conf = read('nginx/default.conf');
|
||||
const ob = (conf.match(/{/g) || []).length, cb = (conf.match(/}/g) || []).length;
|
||||
if (ob !== cb) bad('大括号不配对: { ' + ob + ' / } ' + cb);
|
||||
else ok('大括号配对 (' + ob + ' 对)');
|
||||
|
||||
const servers = (conf.match(/^\s*server\s*{/gm) || []).length;
|
||||
if (servers !== 1) bad('server 块数量异常: ' + servers);
|
||||
else ok('恰好 1 个 server 块');
|
||||
|
||||
const required = [
|
||||
['listen 80', /listen\s+80;/],
|
||||
['root', /root\s+\/usr\/share\/nginx\/html;/],
|
||||
['index', /index\s+index\.html;/],
|
||||
['try_files 伪静态', /try_files\s+\$uri\s+\$uri\.html/],
|
||||
['gzip', /gzip\s+on;/],
|
||||
['安全头 nosniff', /X-Content-Type-Options/],
|
||||
['静态资源缓存', /max-age=31536000/],
|
||||
['/healthz', /location = \/healthz/]
|
||||
];
|
||||
required.forEach(([name, re]) => {
|
||||
if (!re.test(conf)) bad('缺少关键配置: ' + name);
|
||||
else ok('包含 ' + name);
|
||||
});
|
||||
|
||||
// location 块内的 add_header 后必须跟分号;粗查未闭合的 location
|
||||
const locOpen = (conf.match(/location\s+[^;{]*{/g) || []).length;
|
||||
if (locOpen < 5) bad('location 块数量偏少: ' + locOpen);
|
||||
else ok('location 块 ' + locOpen + ' 个');
|
||||
|
||||
// 多余的分号/空指令
|
||||
if (/;;/.test(conf)) bad('存在连续分号 ;;');
|
||||
else ok('无连续分号');
|
||||
|
||||
// 变量拼写
|
||||
['$uri', '$uri.html', '$uri/index.html'].forEach(v => {
|
||||
if (!conf.includes(v)) bad('缺少变量 ' + v);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 3. compose */
|
||||
console.log('\n== 3. docker-compose.yml ==');
|
||||
if (!exists('docker-compose.yml')) bad('docker-compose.yml 不存在');
|
||||
else {
|
||||
const yml = read('docker-compose.yml');
|
||||
if (/^\s*version:/m.test(yml)) bad('存在已废弃的顶层 version 字段');
|
||||
else ok('无废弃的 version 字段');
|
||||
|
||||
const required = [
|
||||
['services', /^services:/m],
|
||||
['build', /^\s+build:/m],
|
||||
['dockerfile', /dockerfile:\s*Dockerfile/],
|
||||
['ports', /^\s+ports:/m],
|
||||
['restart', /restart:\s*unless-stopped/],
|
||||
['healthcheck', /^\s+healthcheck:/m],
|
||||
['logging 轮转', /max-size/]
|
||||
];
|
||||
required.forEach(([name, re]) => {
|
||||
if (!re.test(yml)) bad('缺少 ' + name);
|
||||
else ok('包含 ' + name);
|
||||
});
|
||||
|
||||
// 端口映射必须是 宿主:容器
|
||||
const pm = yml.match(/"\$\{SITE_PORT:-(\d+)\}:(\d+)"/);
|
||||
if (!pm) bad('端口映射格式无法识别');
|
||||
else if (pm[2] !== '80') bad('容器端口应为 80,实际 ' + pm[2]);
|
||||
else ok('端口映射 宿主 ' + pm[1] + ' -> 容器 ' + pm[2]);
|
||||
|
||||
// 缩进一致性(全部用空格,无 tab)
|
||||
if (/\t/.test(yml)) bad('YAML 中含 Tab 字符(会导致解析失败)');
|
||||
else ok('无 Tab 字符');
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 4. dockerignore */
|
||||
console.log('\n== 4. .dockerignore ==');
|
||||
if (!exists('.dockerignore')) bad('.dockerignore 不存在');
|
||||
else {
|
||||
const ig = read('.dockerignore').split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
|
||||
const mustKeep = ['site/', 'nginx/', 'Dockerfile'];
|
||||
mustKeep.forEach(k => {
|
||||
const blocked = ig.some(rule => rule === k || rule === k.replace(/\/$/, ''));
|
||||
if (blocked) bad('构建必需项被排除: ' + k);
|
||||
else ok(k + ' 未被排除(构建需要)');
|
||||
});
|
||||
const bigDirs = ['合作品牌/', '方案/', '荣誉资质/', '背景图/', '老网站/'];
|
||||
bigDirs.forEach(d => {
|
||||
if (!ig.includes(d)) bad('素材目录未排除,会拖慢构建: ' + d);
|
||||
else ok('已排除素材目录 ' + d);
|
||||
});
|
||||
if (!ig.includes('_tools/')) bad('_tools/ 未排除');
|
||||
else ok('已排除 _tools/');
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 5. README 引用 */
|
||||
console.log('\n== 5. README 里引用的路径 ==');
|
||||
const readme = read('README.md');
|
||||
const refs = new Set();
|
||||
for (const m of readme.matchAll(/`((?:site|nginx|deploy|_tools|assets)[^`]*?)`/g)) refs.add(m[1]);
|
||||
let refChecked = 0;
|
||||
refs.forEach(r => {
|
||||
// 跳过通配、目录、以及文档里的省略写法(如 assets/...)
|
||||
if (r.includes('*') || r.endsWith('/') || r.includes(' ') || r.includes('...')) return;
|
||||
if (!exists(r)) bad('README 引用不存在的路径: ' + r);
|
||||
else refChecked++;
|
||||
});
|
||||
ok('README 中 ' + refChecked + ' 个具体路径全部存在');
|
||||
|
||||
/* ---------------------------------------------------------------- 汇总 */
|
||||
console.log('\n' + (problems ? '发现 ' + problems + ' 个问题' : '全部通过'));
|
||||
process.exit(problems ? 1 : 0);
|
||||
@@ -0,0 +1,91 @@
|
||||
/* 模拟 nginx 的 try_files 规则,验证「无扩展名地址」能否正确解析到文件。
|
||||
* 规则与 nginx/default.conf 保持一致:
|
||||
* try_files $uri $uri.html $uri/index.html =404;
|
||||
* location ~* \.html?$ 命中后会做一次内部重定向(location 重匹配)
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', 'site');
|
||||
|
||||
function resolve(uri) {
|
||||
if (!uri.startsWith('/')) return { status: 400 };
|
||||
const clean = uri.split('?')[0].split('#')[0];
|
||||
const abs = p => path.join(ROOT, decodeURIComponent(p));
|
||||
|
||||
// 1) $uri —— 直接命中文件
|
||||
if (clean !== '/' && fs.existsSync(abs(clean)) && fs.statSync(abs(clean)).isFile()) {
|
||||
return { status: 200, file: clean, via: '$uri' };
|
||||
}
|
||||
// 2) $uri.html
|
||||
if (fs.existsSync(abs(clean + '.html'))) {
|
||||
return { status: 200, file: clean + '.html', via: '$uri.html' };
|
||||
}
|
||||
// 3) $uri/index.html
|
||||
if (fs.existsSync(abs(clean + '/index.html'))) {
|
||||
return { status: 200, file: clean + '/index.html', via: '$uri/index.html' };
|
||||
}
|
||||
// 4) 目录(nginx 会对缺斜杠的目录做 301 补斜杠)
|
||||
if (fs.existsSync(abs(clean)) && fs.statSync(abs(clean)).isDirectory() && !clean.endsWith('/')) {
|
||||
return { status: 301, to: clean + '/' };
|
||||
}
|
||||
// 5) =404 -> 再被 error_page 兜到 /index.html
|
||||
return { status: 404, fallback: '/index.html' };
|
||||
}
|
||||
|
||||
const cases = [
|
||||
['/', 200, 'index.html'],
|
||||
['/index.html', 200, 'index.html'],
|
||||
['/brands', 200, 'brands.html'],
|
||||
['/brands.html', 200, 'brands.html'],
|
||||
['/honors', 200, 'honors.html'],
|
||||
['/cases', 200, 'cases.html'],
|
||||
['/about', 200, 'about.html'],
|
||||
['/cases#medical', 200, 'cases.html'],
|
||||
['/assets/css/main.css', 200, 'assets/css/main.css'],
|
||||
['/assets/js/main.js', 200, 'assets/js/main.js'],
|
||||
['/assets/img/hero/hero-1.webp', 200, 'assets/img/hero/hero-1.webp'],
|
||||
['/favicon.ico', 200, 'favicon.ico'],
|
||||
['/healthz', 200, null], // nginx 直接 return 200,不落文件
|
||||
['/assets', 301, null], // 目录补斜杠
|
||||
['/not-exist-page', 404, null]
|
||||
];
|
||||
|
||||
let bad = 0;
|
||||
console.log('模拟 nginx try_files 的路由结果:\n');
|
||||
for (const [uri, wantStatus, wantFile] of cases) {
|
||||
if (uri === '/healthz') {
|
||||
console.log(' OK ' + uri.padEnd(30) + '200 (nginx return "ok")');
|
||||
continue;
|
||||
}
|
||||
const r = resolve(uri);
|
||||
const gotFile = (r.file || '').replace(/^\/+/, '');
|
||||
const statusOk = r.status === wantStatus;
|
||||
const fileOk = !wantFile || gotFile === wantFile.replace(/^\/+/, '');
|
||||
const pass = statusOk && fileOk;
|
||||
if (!pass) bad++;
|
||||
console.log(' ' + (pass ? 'OK ' : 'FAIL') + ' ' + uri.padEnd(30) +
|
||||
String(r.status).padEnd(5) + (r.file || '') + (r.via ? ' [' + r.via + ']' : '') +
|
||||
(r.to ? ' -> ' + r.to : '') +
|
||||
(pass ? '' : ' 期望 ' + wantStatus + (wantFile ? ' ' + wantFile : '')));
|
||||
}
|
||||
|
||||
// 额外:确认每个页面的资源引用在 nginx 规则下也能命中
|
||||
console.log('\n逐页检查站内引用(模拟 nginx 解析):');
|
||||
for (const f of ['index.html', 'brands.html', 'honors.html', 'cases.html', 'about.html']) {
|
||||
const html = fs.readFileSync(path.join(ROOT, f), 'utf8');
|
||||
const refs = new Set();
|
||||
for (const m of html.matchAll(/(?:href|src)="((?!https?:|mailto:|tel:|#|data:)[^"]+)"/g)) refs.add(m[1]);
|
||||
for (const m of html.matchAll(/data-(?:bg|full|src)="([^"]+)"/g)) refs.add(m[1]);
|
||||
let miss = [];
|
||||
for (const r of refs) {
|
||||
const uri = '/' + r.replace(/^\.\//, '');
|
||||
const res = resolve(uri);
|
||||
if (res.status !== 200 && !(res.status === 301)) miss.push(r + ' -> ' + res.status);
|
||||
}
|
||||
if (miss.length) { bad++; console.log(' FAIL ' + f + ': ' + miss.join(', ')); }
|
||||
else console.log(' OK ' + f + ' 的 ' + refs.size + ' 个引用全部可解析');
|
||||
}
|
||||
|
||||
console.log('\n' + (bad ? '有 ' + bad + ' 项不通过' : '全部通过'));
|
||||
process.exit(bad ? 1 : 0);
|
||||
@@ -0,0 +1,108 @@
|
||||
/* 静态站点自检:检查页面里引用的所有资源是否存在、锚点是否有效、HTML 结构是否闭合 */
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SITE = path.join(__dirname, '..', 'site');
|
||||
const PAGES = ['index.html', 'brands/index.html', 'honors/index.html', 'cases/index.html', 'about/index.html'];
|
||||
|
||||
let problems = 0;
|
||||
const note = (msg) => { problems++; console.log(' ✗ ' + msg); };
|
||||
const ok = (msg) => console.log(' ✓ ' + msg);
|
||||
|
||||
function exists(rel) {
|
||||
const p = path.join(SITE, decodeURIComponent(rel.replace(/^\//, '').split('#')[0].split('?')[0]));
|
||||
return fs.existsSync(p);
|
||||
}
|
||||
|
||||
const allIds = {};
|
||||
PAGES.forEach(f => {
|
||||
const html = fs.readFileSync(path.join(SITE, f), 'utf8');
|
||||
allIds[f] = new Set([...html.matchAll(/\sid="([^"]+)"/g)].map(m => m[1]));
|
||||
});
|
||||
|
||||
console.log('== 1. 资源引用检查 ==');
|
||||
PAGES.forEach(f => {
|
||||
const dir = path.dirname(f);
|
||||
const html = fs.readFileSync(path.join(SITE, f), 'utf8');
|
||||
const refs = new Set();
|
||||
for (const m of html.matchAll(/(?:href|src)="([^"]+)"/g)) refs.add(m[1]);
|
||||
for (const m of html.matchAll(/data-(?:full|bg)="([^"]+)"/g)) refs.add(m[1]);
|
||||
for (const m of html.matchAll(/url\('([^']+)'\)/g)) refs.add(m[1]);
|
||||
|
||||
const bad = [];
|
||||
refs.forEach(r => {
|
||||
if (/^(https?:|mailto:|tel:|data:|#)/.test(r)) return;
|
||||
const rel = r.replace(/^\.\//, '');
|
||||
const resolved = rel.startsWith('../') ? path.join(dir, rel) : rel;
|
||||
if (!exists(resolved)) bad.push(r);
|
||||
});
|
||||
if (bad.length) note(f + ' 缺少资源: ' + bad.join(', '));
|
||||
else ok(f + ' 资源全部存在 (' + refs.size + ' 条引用)');
|
||||
});
|
||||
|
||||
console.log('== 2. 站内链接检查 ==');
|
||||
const LINKED = new Set();
|
||||
PAGES.forEach(f => {
|
||||
const dir = path.dirname(f);
|
||||
const html = fs.readFileSync(path.join(SITE, f), 'utf8');
|
||||
const bad = [];
|
||||
for (const m of html.matchAll(/href="([^"]*)"/g)) {
|
||||
const h = m[1];
|
||||
if (/^(https?:|mailto:|tel:|data:)/.test(h)) continue;
|
||||
if (h === '' || h === './') { LINKED.add('index.html'); continue; }
|
||||
if (h.startsWith('#')) {
|
||||
if (h.length > 1 && !allIds[f].has(h.slice(1))) bad.push('锚点缺失 ' + h + ' @ ' + f);
|
||||
continue;
|
||||
}
|
||||
const [p, hash] = h.split('#');
|
||||
if (p.endsWith('/') || p === '' || p === '.') {
|
||||
const rel = p === '' ? (dir === '.' ? 'index.html' : dir + '/index.html') : null;
|
||||
if (rel) { LINKED.add(rel); continue; }
|
||||
// 目录链接 -> 解析成 index.html
|
||||
const target = path.join(dir, p, 'index.html');
|
||||
if (!fs.existsSync(path.join(SITE, target))) bad.push('目录不存在 ' + h + ' @ ' + f);
|
||||
else LINKED.add(target);
|
||||
continue;
|
||||
}
|
||||
const rel = path.join(dir, p);
|
||||
if (fs.existsSync(path.join(SITE, rel))) { LINKED.add(rel); continue; }
|
||||
if (fs.existsSync(path.join(SITE, rel, 'index.html'))) { LINKED.add(path.join(rel, 'index.html')); continue; }
|
||||
bad.push('链接目标不存在 ' + h + ' @ ' + f);
|
||||
}
|
||||
if (bad.length) bad.forEach(note);
|
||||
else ok(f + ' 链接全部可达');
|
||||
});
|
||||
|
||||
console.log('== 3. 页面可达性(每页至少被链接一次) ==');
|
||||
PAGES.forEach(f => {
|
||||
if (f === 'index.html') return;
|
||||
const norm = f.replace(/\\/g, '/');
|
||||
if (![...LINKED].some(l => l.replace(/\\/g, '/') === norm)) note(norm + ' 没有任何页面链接到它');
|
||||
});
|
||||
ok('可达性检查完成');
|
||||
|
||||
console.log('== 4. 标签闭合 / 常见问题 ==');
|
||||
PAGES.forEach(f => {
|
||||
const html = fs.readFileSync(path.join(SITE, f), 'utf8');
|
||||
const counts = {};
|
||||
['div', 'section', 'a', 'button', 'span', 'ul', 'li', 'article', 'form', 'main', 'header', 'footer', 'nav'].forEach(t => {
|
||||
const open = (html.match(new RegExp('<' + t + '(?=[\\s>])', 'g')) || []).length;
|
||||
const close = (html.match(new RegExp('</' + t + '>', 'g')) || []).length;
|
||||
if (open !== close) counts[t] = open + ' 开 / ' + close + ' 闭';
|
||||
});
|
||||
if (Object.keys(counts).length) note(f + ' 标签数量不匹配: ' + JSON.stringify(counts));
|
||||
else ok(f + ' 主要标签成对');
|
||||
if (/\s(data-src|src)=""/.test(html)) note(f + ' 存在空 src');
|
||||
if (html.includes('undefined')) note(f + ' 包含 undefined 字样');
|
||||
});
|
||||
|
||||
console.log('== 5. 内联占位符残留检查 ==');
|
||||
PAGES.forEach(f => {
|
||||
const html = fs.readFileSync(path.join(SITE, f), 'utf8');
|
||||
const leftover = [...html.matchAll(/data-icon="([^"]+)"/g)].map(m => m[1]);
|
||||
const unknown = leftover.filter(n => !/^(arrowRight|arrowUp|arrowLeft|chevronDown|check|plus|close|search|phone|mail|mapPin|clock|shield|network|wifi|server|cloud|lock|building|factory|bank|hospital|store|chart|users|award|cert|handshake|headset|settings|rocket|layers|target|eye|doc|spark)$/.test(n));
|
||||
if (unknown.length) note(f + ' 未定义图标: ' + [...new Set(unknown)].join(', '));
|
||||
else ok(f + ' 图标名合法 (' + leftover.length + ' 个,由 JS 注入)');
|
||||
});
|
||||
|
||||
console.log('\n结果:' + (problems === 0 ? '全部通过 ✅' : problems + ' 个问题 ❌'));
|
||||
@@ -0,0 +1,147 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""HTML 结构校验:标签嵌套是否合法、必需元素是否齐全、无障碍属性是否到位。"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from html.parser import HTMLParser
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SITE = os.path.join(ROOT, "site")
|
||||
PAGES = ["index.html", "brands.html", "honors.html", "cases.html", "about.html"]
|
||||
|
||||
VOID = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link",
|
||||
"meta", "param", "source", "track", "wbr"}
|
||||
OPTIONAL_END = {"li", "p", "td", "th", "tr", "option", "thead", "tbody"}
|
||||
|
||||
|
||||
class Checker(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.stack = []
|
||||
self.errors = []
|
||||
self.tags = {}
|
||||
self.attrs = []
|
||||
self.texts = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
self.tags[tag] = self.tags.get(tag, 0) + 1
|
||||
self.attrs.append((tag, dict(attrs)))
|
||||
if tag not in VOID:
|
||||
self.stack.append((tag, self.getpos()))
|
||||
|
||||
def handle_startendtag(self, tag, attrs):
|
||||
self.tags[tag] = self.tags.get(tag, 0) + 1
|
||||
self.attrs.append((tag, dict(attrs)))
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag in VOID:
|
||||
self.errors.append("闭合了自闭合标签 </%s> 行 %d" % (tag, self.getpos()[0]))
|
||||
return
|
||||
if not self.stack:
|
||||
self.errors.append("多余的闭合标签 </%s> 行 %d" % (tag, self.getpos()[0]))
|
||||
return
|
||||
if self.stack[-1][0] == tag:
|
||||
self.stack.pop()
|
||||
return
|
||||
# 容忍可省略的闭合标签
|
||||
names = [t for t, _ in self.stack]
|
||||
if tag in names:
|
||||
idx = len(names) - 1 - names[::-1].index(tag)
|
||||
skipped = names[idx + 1:]
|
||||
if all(s in OPTIONAL_END for s in skipped):
|
||||
del self.stack[idx:]
|
||||
return
|
||||
self.errors.append(
|
||||
"</%s> 行 %d 与最近未闭合标签 <%s> 行 %d 交叉嵌套(中间有 %s)"
|
||||
% (tag, self.getpos()[0], self.stack[-1][0], self.stack[-1][1][0],
|
||||
", ".join(skipped)))
|
||||
del self.stack[idx:]
|
||||
return
|
||||
self.errors.append("未匹配的闭合标签 </%s> 行 %d" % (tag, self.getpos()[0]))
|
||||
|
||||
def handle_data(self, data):
|
||||
t = data.strip()
|
||||
if t:
|
||||
self.texts.append(t)
|
||||
|
||||
|
||||
def main():
|
||||
problems = 0
|
||||
for page in PAGES:
|
||||
path = os.path.join(SITE, page)
|
||||
html = open(path, encoding="utf-8").read()
|
||||
c = Checker()
|
||||
c.feed(html)
|
||||
c.close()
|
||||
errs = list(c.errors)
|
||||
for tag, pos in c.stack:
|
||||
errs.append("标签 <%s> 行 %d 未闭合" % (tag, pos[0]))
|
||||
|
||||
# 必需结构
|
||||
need = {
|
||||
"html": "html 标签",
|
||||
"head": "head 标签",
|
||||
"body": "body 标签",
|
||||
"main": "main 标签",
|
||||
"h1": "h1 标题",
|
||||
"title": "title",
|
||||
}
|
||||
for tag, label in need.items():
|
||||
if c.tags.get(tag, 0) == 0:
|
||||
errs.append("缺少 %s" % label)
|
||||
if c.tags.get("h1", 0) > 1:
|
||||
errs.append("存在 %d 个 h1(应为 1 个)" % c.tags["h1"])
|
||||
|
||||
# 图片必须有 alt
|
||||
for tag, at in c.attrs:
|
||||
if tag == "img" and "alt" not in at:
|
||||
errs.append("<img src=%s> 缺少 alt" % at.get("src", "?"))
|
||||
if tag == "html" and at.get("lang") != "zh-CN":
|
||||
errs.append("<html> 缺少 lang=\"zh-CN\"")
|
||||
if tag == "a":
|
||||
if at.get("target") == "_blank" and "noopener" not in (at.get("rel") or ""):
|
||||
errs.append("外链 %s 缺少 rel=noopener" % at.get("href", "?"))
|
||||
|
||||
# 图片尺寸提示(避免布局抖动)
|
||||
#
|
||||
# 例外:品牌 logo 由 CSS 用「固定高度 + 宽度自动」控制大小
|
||||
# (见 main.css 的 .brandstrip .marquee__item img)。
|
||||
# 这类图片不能写 width/height 属性 —— logo 源图自带大量透明留白,
|
||||
# 属性声明的宽高比(360/240 = 1.5)与实际内容(锐捷 320/54 = 5.9)不符,
|
||||
# 浏览器会按错误比例算宽度并把 logo 压扁。
|
||||
# 它们位于固定高度的卡片内居中显示,不会造成布局抖动。
|
||||
def css_controls_size(at):
|
||||
if "img/brands/" in (at.get("src") or ""):
|
||||
return True # 品牌 logo:由 CSS 统一控制尺寸
|
||||
if "brand__mark" in (at.get("class") or ""):
|
||||
return True # 站点 Logo:CSS 固定 40px
|
||||
return False
|
||||
|
||||
imgs = [at for tag, at in c.attrs if tag == "img"]
|
||||
no_dim = [i for i in imgs
|
||||
if not (i.get("width") and i.get("height"))
|
||||
and not css_controls_size(i)]
|
||||
if no_dim:
|
||||
errs.append("%d 张图片缺少 width/height(会抖动布局):%s"
|
||||
% (len(no_dim), ", ".join(i.get("src", "?") for i in no_dim[:3])))
|
||||
|
||||
# 按钮必须有可读文字或 aria-label
|
||||
for tag, at in c.attrs:
|
||||
if tag == "button" and not at.get("aria-label") and at.get("data-lightbox") is None:
|
||||
pass # 由子元素文字提供
|
||||
|
||||
if errs:
|
||||
problems += len(errs)
|
||||
print("[%s]" % page)
|
||||
for e in errs:
|
||||
print(" x " + e)
|
||||
else:
|
||||
print("[%s] 结构 OK 标签 %d 个 / 图片 %d 张 / 文本块 %d 段"
|
||||
% (page, sum(c.tags.values()), len(imgs), len(c.texts)))
|
||||
|
||||
print("\nHTML 结构校验:" + ("全部通过 OK" if problems == 0 else "%d 个问题" % problems))
|
||||
return 0 if problems == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,89 @@
|
||||
# 把站点同步到服务器 10.0.0.131
|
||||
#
|
||||
# 用法:
|
||||
# powershell -ExecutionPolicy Bypass -File _tools\deploy-server.ps1
|
||||
#
|
||||
# 前置:本机已安装 ssh / scp(Windows 10+ 自带 OpenSSH),且能访问 10.0.0.131:22
|
||||
# 说明:
|
||||
# - 站点文件通过「本地打 tar 包 -> scp -> 远端解包」同步。
|
||||
# 不要用 PowerShell 管道直接传 tar 流,它会破坏二进制内容
|
||||
# (会出现 tar: Skipping to next header)。
|
||||
# - 更新内容不需要重建镜像或重启容器,覆盖文件即可(bind mount)。
|
||||
# - 改了 Caddyfile 才需要 restart。
|
||||
|
||||
param(
|
||||
[string]$Server = "10.0.0.131",
|
||||
[string]$User = "root",
|
||||
[string]$Remote = "/www/wwwroot/xuanheng-site",
|
||||
[switch]$WithConfig # 加这个参数才同步 Caddyfile / docker-compose.yml
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
||||
$Target = "$User@$Server"
|
||||
|
||||
# ---------- SSH 免交互登录(askpass 方式,兼容 Windows OpenSSH) ----------
|
||||
$Tmp = Join-Path $env:TEMP ("xh-deploy-" + (Get-Random))
|
||||
New-Item -ItemType Directory -Force -Path $Tmp | Out-Null
|
||||
|
||||
if (-not $env:XH_SERVER_PASSWORD) {
|
||||
Write-Host "请先设置服务器密码到环境变量(只在本次会话生效):" -ForegroundColor Yellow
|
||||
Write-Host ' $env:XH_SERVER_PASSWORD = "你的密码"' -ForegroundColor Yellow
|
||||
Write-Host "然后重新运行本脚本。"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# askpass 只把密码交给 ssh 自己,不写进命令历史
|
||||
Set-Content -Path "$Tmp\askpass.cmd" -Value "@echo off`r`necho $env:XH_SERVER_PASSWORD" -Encoding ascii
|
||||
$env:DISPLAY = "localhost:0"
|
||||
$env:SSH_ASKPASS = "$Tmp\askpass.cmd"
|
||||
$env:SSH_ASKPASS_REQUIRE = "force"
|
||||
|
||||
$sshOpts = @(
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=$Tmp\known_hosts",
|
||||
"-o", "PreferredAuthentications=password",
|
||||
"-o", "PubkeyAuthentication=no",
|
||||
"-o", "NumberOfPasswordPrompts=1",
|
||||
"-o", "ConnectTimeout=20"
|
||||
)
|
||||
|
||||
function Invoke-Remote([string]$Cmd) {
|
||||
& ssh @sshOpts $Target $Cmd
|
||||
if ($LASTEXITCODE -ne 0) { throw "远程命令失败:$Cmd" }
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Host "[1/4] 确认远端目录"
|
||||
Invoke-Remote "mkdir -p $Remote/site $Remote/caddy-data $Remote/caddy-config $Remote/logs && chown -R 1000:1000 $Remote/caddy-data $Remote/caddy-config $Remote/logs && echo OK"
|
||||
|
||||
Write-Host "[2/4] 上传站点文件"
|
||||
$tar = Join-Path $Tmp "site.tar"
|
||||
Push-Location (Join-Path $Root "site")
|
||||
try { & tar -cf $tar . } finally { Pop-Location }
|
||||
Write-Host (" 打包完成:{0:N0} KB" -f ((Get-Item $tar).Length / 1KB))
|
||||
|
||||
& scp @sshOpts $tar "${Target}:/tmp/xh-site.tar"
|
||||
if ($LASTEXITCODE -ne 0) { throw "scp 上传失败" }
|
||||
Invoke-Remote "cd $Remote/site && tar -xf /tmp/xh-site.tar && rm -f /tmp/xh-site.tar && echo ' 解包完成,共'\$(find . -type f | wc -l)'个文件'"
|
||||
|
||||
if ($WithConfig) {
|
||||
Write-Host "[3/4] 上传配置"
|
||||
foreach ($f in @("Caddyfile", "docker-compose.yml")) {
|
||||
& scp @sshOpts (Join-Path $Root "deploy\server\$f") "${Target}:$Remote/$f"
|
||||
if ($LASTEXITCODE -ne 0) { throw "$f 上传失败" }
|
||||
}
|
||||
Invoke-Remote "cd $Remote && sed -i '1s/^\xEF\xBB\xBF//' Caddyfile docker-compose.yml && docker run --rm -v ${Remote}:/work caddy:2.11.2 caddy validate --config /work/Caddyfile --adapter caddyfile 2>&1 | tail -2"
|
||||
Invoke-Remote "cd $Remote && docker compose up -d && sleep 3 && docker compose ps"
|
||||
} else {
|
||||
Write-Host "[3/4] 跳过配置同步(未加 -WithConfig)"
|
||||
}
|
||||
|
||||
Write-Host "[4/4] 验证"
|
||||
Invoke-Remote "for p in / /healthz /brands /honors /cases /about; do printf ' %-12s ' \$p; curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8081\$p; done"
|
||||
|
||||
Write-Host "`n完成。站点地址: http://${Server}:8081/" -ForegroundColor Green
|
||||
}
|
||||
finally {
|
||||
Remove-Item -Recurse -Force $Tmp -ErrorAction SilentlyContinue
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
/* 最小 DOM 仿真(供 tools/test-js.js 使用)
|
||||
* 支持:元素树、classList、属性、事件、innerHTML 解析、querySelector(All)
|
||||
* 目标:足以在 Node 里跑通站点 main.js 的交互逻辑。
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
class ClassList {
|
||||
constructor(el) { this.el = el; this.set = new Set(); }
|
||||
add(...c) { c.forEach(x => x && this.set.add(x)); this.sync(); }
|
||||
remove(...c) { c.forEach(x => this.set.delete(x)); this.sync(); }
|
||||
contains(c) { return this.set.has(c); }
|
||||
toggle(c, force) {
|
||||
const on = force === undefined ? !this.set.has(c) : !!force;
|
||||
if (on) this.set.add(c); else this.set.delete(c);
|
||||
this.sync();
|
||||
return on;
|
||||
}
|
||||
sync() { this.el._class = [...this.set].join(' '); }
|
||||
}
|
||||
|
||||
class El {
|
||||
constructor(tag, doc) {
|
||||
this.tagName = String(tag || 'div').toUpperCase();
|
||||
this.children = [];
|
||||
this.parentNode = null;
|
||||
this.attrs = {};
|
||||
this.style = {};
|
||||
this._class = '';
|
||||
this._text = '';
|
||||
this._html = '';
|
||||
this.listeners = {};
|
||||
this.offsetWidth = 0;
|
||||
this.scrollHeight = 120;
|
||||
this._value = '';
|
||||
this.complete = false;
|
||||
this.naturalWidth = 100;
|
||||
Object.defineProperty(this, 'complete', {
|
||||
get() { return !!this._complete; },
|
||||
set(v) {
|
||||
this._complete = !!v;
|
||||
if (v) setTimeout(() => this.dispatch('load'), 0);
|
||||
}
|
||||
});
|
||||
Object.defineProperty(this, 'naturalWidth', {
|
||||
get() { return this._nw !== undefined ? this._nw : (this._complete ? 100 : 0); },
|
||||
set(v) { this._nw = v; }
|
||||
});
|
||||
Object.defineProperty(this, 'naturalHeight', {
|
||||
get() { return this._nh !== undefined ? this._nh : (this._complete ? 100 : 0); },
|
||||
set(v) { this._nh = v; }
|
||||
});
|
||||
this.classList = new ClassList(this);
|
||||
this.ownerDocument = doc || null;
|
||||
}
|
||||
/* 属性/IDL 反射,贴近浏览器行为 */
|
||||
get src() { return this.attrs.src || ''; }
|
||||
set src(v) {
|
||||
this.attrs.src = String(v);
|
||||
if (this.tagName === 'IMG') this.complete = true;
|
||||
}
|
||||
get href() { return this.attrs.href || ''; }
|
||||
set href(v) { this.attrs.href = String(v); }
|
||||
get id() { return this.attrs.id || ''; }
|
||||
set id(v) { this.attrs.id = String(v); }
|
||||
get type() { return this.attrs.type || ''; }
|
||||
set type(v) { this.attrs.type = String(v); }
|
||||
get name() { return this.attrs.name || ''; }
|
||||
set name(v) { this.attrs.name = String(v); }
|
||||
get value() { return this._value; }
|
||||
set value(v) { this._value = String(v); this.attrs.value = String(v); }
|
||||
get className() { return this._class; }
|
||||
set className(v) { this._class = String(v); this.classList.set = new Set(this._class.split(/\s+/).filter(Boolean)); }
|
||||
get textContent() { return this._text || this.children.map(c => c.textContent).join(''); }
|
||||
set textContent(v) { this._text = String(v); this.children = []; }
|
||||
get innerHTML() { return this._html; }
|
||||
set innerHTML(v) { this._html = String(v); parseInto(this, this._html); }
|
||||
get outerHTML() { return this._html; }
|
||||
set outerHTML(v) { this._html = String(v); this._replaced = true; if (this.parentNode) this.parentNode._replacedChildren = true; }
|
||||
appendChild(c) { c.parentNode = this; this.children.push(c); return c; }
|
||||
cloneNode(deep) {
|
||||
const copy = new El(this.tagName, this.ownerDocument);
|
||||
copy.attrs = Object.assign({}, this.attrs);
|
||||
copy._class = this._class;
|
||||
copy.classList.set = new Set(this.classList.set);
|
||||
copy._text = this._text;
|
||||
copy._html = this._html;
|
||||
copy._value = this._value;
|
||||
if (deep) this.children.forEach(c => copy.appendChild(c.cloneNode(true)));
|
||||
return copy;
|
||||
}
|
||||
removeChild(c) { this.children = this.children.filter(x => x !== c); return c; }
|
||||
setAttribute(k, v) { this.attrs[k] = String(v); }
|
||||
getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }
|
||||
removeAttribute(k) { delete this.attrs[k]; }
|
||||
hasAttribute(k) { return k in this.attrs; }
|
||||
addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); }
|
||||
removeEventListener(t, fn) {
|
||||
if (this.listeners[t]) this.listeners[t] = this.listeners[t].filter(f => f !== fn);
|
||||
}
|
||||
dispatch(t, ev) {
|
||||
const evt = Object.assign({
|
||||
type: t, target: this, preventDefault() { }, stopPropagation() { }
|
||||
}, ev || {});
|
||||
(this.listeners[t] || []).slice().forEach(fn => fn(evt));
|
||||
if (this.parentNode && !evt._stopped) this.parentNode.dispatch(t, Object.assign(evt, { target: this }));
|
||||
return evt;
|
||||
}
|
||||
closest(sel) {
|
||||
let n = this;
|
||||
while (n) { if (n.matches(sel)) return n; n = n.parentNode; }
|
||||
return null;
|
||||
}
|
||||
focus() { this.ownerDocument && (this.ownerDocument.activeElement = this); }
|
||||
scrollIntoView() { }
|
||||
reset() {
|
||||
(function walk(node) {
|
||||
node.children.forEach(c => {
|
||||
if (c.tagName === 'INPUT' || c.tagName === 'TEXTAREA' || c.tagName === 'SELECT') c.value = '';
|
||||
walk(c);
|
||||
});
|
||||
})(this);
|
||||
}
|
||||
getBoundingClientRect() {
|
||||
var w = this._boxW || 100, h = this._boxH || 100;
|
||||
return { top: 0, left: 0, right: w, bottom: h, width: w, height: h };
|
||||
}
|
||||
matches(sel) { return matchCompound(this, sel); }
|
||||
querySelector(sel) { return queryAll(this, sel)[0] || null; }
|
||||
querySelectorAll(sel) { return queryAll(this, sel); }
|
||||
get firstChild() { return this.children[0] || null; }
|
||||
get nodeType() { return 1; }
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- 选择器 */
|
||||
function matchSimple(el, sel) {
|
||||
sel = sel.trim();
|
||||
if (!sel) return true;
|
||||
const parts = sel.match(/(^[a-zA-Z][\w-]*)|(\.[\w-]+)|(#[\[\]\w-]+)|(\[[^\]]+\])/g) || [];
|
||||
for (const p of parts) {
|
||||
if (p.startsWith('.')) { if (!el.classList.contains(p.slice(1))) return false; }
|
||||
else if (p.startsWith('#')) { if (el.attrs.id !== p.slice(1)) return false; }
|
||||
else if (p.startsWith('[')) {
|
||||
const m = /^\[([\w-]+)(?:\s*([~|^$*]?=)\s*"?([^"\]]*)"?)?\]$/.exec(p);
|
||||
if (!m) return false;
|
||||
const [, name, op, val] = m;
|
||||
if (!(name in el.attrs)) return false;
|
||||
if (!op) continue;
|
||||
const have = String(el.attrs[name]);
|
||||
if (op === '=') { if (have !== val) return false; }
|
||||
else if (op === '~=') { if (!have.split(/\s+/).includes(val)) return false; }
|
||||
else if (op === '*=') { if (have.indexOf(val) < 0) return false; }
|
||||
else if (op === '^=') { if (have.indexOf(val) !== 0) return false; }
|
||||
else if (op === '$=') { if (!have.endsWith(val)) return false; }
|
||||
} else if (el.tagName !== p.toUpperCase()) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function splitDescendant(sel) {
|
||||
const out = [];
|
||||
let depth = 0, cur = '';
|
||||
for (const ch of sel) {
|
||||
if (ch === '[') depth++;
|
||||
if (ch === ']') depth--;
|
||||
if (/\s/.test(ch) && depth === 0) { if (cur) { out.push(cur); cur = ''; } }
|
||||
else cur += ch;
|
||||
}
|
||||
if (cur) out.push(cur);
|
||||
return out;
|
||||
}
|
||||
|
||||
function matchCompound(el, sel) {
|
||||
for (const group of sel.split(',')) {
|
||||
const parts = splitDescendant(group);
|
||||
if (!parts.length) continue;
|
||||
if (!matchSimple(el, parts[parts.length - 1])) continue;
|
||||
let i = parts.length - 2, n = el.parentNode, ok = true;
|
||||
while (i >= 0) {
|
||||
if (!n) { ok = false; break; }
|
||||
if (matchSimple(n, parts[i])) i--;
|
||||
n = n.parentNode;
|
||||
}
|
||||
if (ok && i < 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function queryAll(root, selector) {
|
||||
const out = [];
|
||||
(function walk(node) {
|
||||
for (const c of node.children) {
|
||||
if (matchCompound(c, selector)) out.push(c);
|
||||
walk(c);
|
||||
}
|
||||
})(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- innerHTML 解析 */
|
||||
const VOID = new Set(['img', 'br', 'hr', 'input', 'meta', 'link', 'source', 'area', 'base', 'col']);
|
||||
|
||||
function parseInto(root, html) {
|
||||
root.children = [];
|
||||
const stack = [root];
|
||||
const re = /<!--[\s\S]*?-->|<\/([a-zA-Z][\w-]*)\s*>|<([a-zA-Z][\w-]*)((?:\s+[^<>]*?)?)\/?>|([^<]+)/g;
|
||||
let m;
|
||||
while ((m = re.exec(html))) {
|
||||
if (m[0].startsWith('<!--')) continue;
|
||||
if (m[1]) { // 闭合标签
|
||||
const tag = m[1].toUpperCase();
|
||||
for (let i = stack.length - 1; i > 0; i--) {
|
||||
if (stack[i].tagName === tag) { stack.length = i; break; }
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (m[2]) { // 开标签
|
||||
const tag = m[2];
|
||||
const node = new El(tag, root.ownerDocument);
|
||||
const attrStr = m[3] || '';
|
||||
const ar = /([\w:-]+)(?:\s*=\s*"([^"]*)"|\s*=\s*'([^']*)'|\s*=\s*([^\s"'>]+))?/g;
|
||||
let a;
|
||||
while ((a = ar.exec(attrStr))) {
|
||||
const name = a[1];
|
||||
const val = a[2] !== undefined ? a[2] : a[3] !== undefined ? a[3] : a[4] !== undefined ? a[4] : '';
|
||||
if (name === 'class') node.className = val;
|
||||
else node.attrs[name] = val;
|
||||
}
|
||||
stack[stack.length - 1].appendChild(node);
|
||||
if (!VOID.has(tag.toLowerCase()) && !m[0].endsWith('/>')) stack.push(node);
|
||||
continue;
|
||||
}
|
||||
if (m[4]) { // 文本
|
||||
const t = m[4];
|
||||
if (t.trim()) stack[stack.length - 1]._text += t;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- document */
|
||||
function createDocument() {
|
||||
const doc = new El('html', null);
|
||||
doc.ownerDocument = doc;
|
||||
doc.documentElement = doc;
|
||||
doc.body = doc.appendChild(new El('body', doc));
|
||||
doc.readyState = 'complete';
|
||||
doc.hidden = false;
|
||||
doc._l = {};
|
||||
doc.activeElement = doc.body;
|
||||
doc.createElement = t => new El(t, doc);
|
||||
doc.addEventListener = El.prototype.addEventListener.bind(doc);
|
||||
doc.removeEventListener = El.prototype.removeEventListener.bind(doc);
|
||||
doc.dispatch = El.prototype.dispatch.bind(doc);
|
||||
doc.querySelector = sel => queryAll(doc, sel)[0] || null;
|
||||
doc.querySelectorAll = sel => queryAll(doc, sel);
|
||||
doc.getElementById = id => queryAll(doc, '#' + id)[0] || null;
|
||||
return doc;
|
||||
}
|
||||
|
||||
module.exports = { El, createDocument, queryAll, matchCompound };
|
||||
@@ -0,0 +1,121 @@
|
||||
# 最终自检:编码 / 路径 / 导航完整性
|
||||
# 注意:所有文件写入必须使用 UTF-8(Python 默认即是),不要用 PowerShell 的 Set-Content 覆盖 HTML/TXT。
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import json
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SITE = os.path.join(ROOT, "site")
|
||||
PAGES = ["index.html", "brands.html", "honors.html", "cases.html", "about.html"]
|
||||
|
||||
problems = []
|
||||
|
||||
|
||||
def fail(msg):
|
||||
problems.append(msg)
|
||||
print(" x " + msg)
|
||||
|
||||
|
||||
def ok(msg):
|
||||
print(" - " + msg)
|
||||
|
||||
|
||||
print("=== 1. 导航与路径完整性 ===")
|
||||
for page in PAGES:
|
||||
html = open(os.path.join(SITE, page), encoding="utf-8").read()
|
||||
# 不允许出现上级目录引用或根绝对路径
|
||||
up = re.findall(r'(?:href|src|data-bg|data-full|data-src)="(\.\./[^"]*)"', html)
|
||||
if up:
|
||||
fail("%s 存在上级目录引用:%s" % (page, up[:3]))
|
||||
rootabs = re.findall(r'(?:href|src|data-bg|data-full|data-src)="(/[^"/][^"]*)"', html)
|
||||
if rootabs:
|
||||
fail("%s 存在根绝对路径:%s" % (page, rootabs[:3]))
|
||||
# 五个页面导航链接是否齐全
|
||||
navs = re.findall(r'class="nav__link"[^>]*href="([^"]+)"', html)
|
||||
expect = ["index.html", "brands.html", "honors.html", "cases.html", "about.html"]
|
||||
missing = [e for e in expect if e not in navs]
|
||||
if missing:
|
||||
fail("%s 主导航缺少:%s" % (page, missing))
|
||||
else:
|
||||
ok("%s 导航五项齐全,路径均为同级相对路径" % page)
|
||||
|
||||
print()
|
||||
print("=== 2. 页面互相可达性 ===")
|
||||
for page in PAGES:
|
||||
html = open(os.path.join(SITE, page), encoding="utf-8").read()
|
||||
links = set(re.findall(r'href="([a-z]+\.html)', html))
|
||||
others = [p for p in PAGES if p != page]
|
||||
miss = [p for p in others if p not in links]
|
||||
if miss:
|
||||
fail("%s 没有链接到:%s" % (page, miss))
|
||||
else:
|
||||
ok("%s 可跳转到其余全部页面" % page)
|
||||
|
||||
print()
|
||||
print("=== 3. 资源引用存在性 ===")
|
||||
for page in PAGES:
|
||||
html = open(os.path.join(SITE, page), encoding="utf-8").read()
|
||||
refs = set()
|
||||
for m in re.finditer(r'(?:href|src)="(assets/[^"]+)"', html):
|
||||
refs.add(m.group(1))
|
||||
for m in re.finditer(r'data-(?:bg|full|src)="(assets/[^"]+)"', html):
|
||||
refs.add(m.group(1))
|
||||
bad = [r for r in refs if not os.path.exists(os.path.join(SITE, r))]
|
||||
if bad:
|
||||
fail("%s 缺失资源:%s" % (page, bad[:5]))
|
||||
else:
|
||||
ok("%s %d 个资源引用全部存在" % (page, len(refs)))
|
||||
|
||||
print()
|
||||
print("=== 4. 编码与乱码检查 ===")
|
||||
for page in PAGES:
|
||||
raw = open(os.path.join(SITE, page), "rb").read()
|
||||
if raw[:3] == b"\xef\xbb\xbf":
|
||||
fail("%s 存在 UTF-8 BOM" % page)
|
||||
try:
|
||||
s = raw.decode("utf-8")
|
||||
except UnicodeDecodeError as e:
|
||||
fail("%s 不是合法 UTF-8:%s" % (page, e))
|
||||
continue
|
||||
if "\ufffd" in s:
|
||||
fail("%s 含替换字符 U+FFFD" % page)
|
||||
pua = re.findall(r"[\ue000-\uf8ff]", s)
|
||||
if pua:
|
||||
fail("%s 含私用区字符 %d 个" % (page, len(pua)))
|
||||
if re.search(r"[閭鍏鎴鏈璁鐢鍦涓鏄銆]", s):
|
||||
fail("%s 含 UTF-8/GBK 乱码特征" % page)
|
||||
if not pua and "\ufffd" not in s:
|
||||
ok("%s 编码干净(%d 个字符)" % (page, len(s)))
|
||||
|
||||
print()
|
||||
print("=== 5. 首页结构(按需求:无轮播 / 单一背景) ===")
|
||||
home = open(os.path.join(SITE, "index.html"), encoding="utf-8").read()
|
||||
if "hero__slide" in home or "hero__dot" in home:
|
||||
fail("首页仍存在轮播结构(hero__slide / hero__dot)")
|
||||
else:
|
||||
ok("首页已无轮播结构")
|
||||
if home.count("hero__bg") >= 1 and "hero/hero-1.webp" in home:
|
||||
ok("首页使用固定单张背景 hero-1.webp")
|
||||
if "data-count" in home:
|
||||
ok("首页含数字滚动面板")
|
||||
|
||||
print()
|
||||
print("=== 6. 老网站英文占位文案残留检查 ===")
|
||||
BAD_WORDS = ["BizMax", "lorem", "Intrinsicly", "reCAPTI", "Read More", "Read Details",
|
||||
"Professionally", "Credibly", "Successful Projects", "Happy Clients"]
|
||||
for page in PAGES:
|
||||
html = open(os.path.join(SITE, page), encoding="utf-8").read()
|
||||
hits = [w for w in BAD_WORDS if w in html]
|
||||
if hits:
|
||||
fail("%s 残留模板文案:%s" % (page, hits))
|
||||
else:
|
||||
ok("%s 无模板占位文案" % page)
|
||||
|
||||
print()
|
||||
if problems:
|
||||
print("共发现 %d 个问题" % len(problems))
|
||||
sys.exit(1)
|
||||
print("全部通过 OK")
|
||||
@@ -0,0 +1,36 @@
|
||||
// 移除品牌 logo 的固定 width/height 属性:
|
||||
// CSS 用「固定高度 + 宽度自动」来统一大小,写死属性尺寸只会造成冲突与变形。
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SITE = path.join(__dirname, '..', 'site');
|
||||
|
||||
for (const f of ['index.html', 'brands.html']) {
|
||||
const p = path.join(SITE, f);
|
||||
let s = fs.readFileSync(p, 'utf8');
|
||||
const before = s;
|
||||
let n = 0;
|
||||
|
||||
// <img src="assets/img/brands/xxx.webp" ... width="360" height="240">
|
||||
s = s.replace(/(<img\s+src="assets\/img\/brands\/[^"]+"[^>]*?)\s+width="\d+"\s+height="\d+"/g,
|
||||
(m, head) => { n++; return head; });
|
||||
|
||||
if (s !== before) { fs.writeFileSync(p, s, 'utf8'); console.log('updated ' + f + ' (' + n + ' 处)'); }
|
||||
else console.log('no change ' + f);
|
||||
}
|
||||
|
||||
// 复查
|
||||
console.log('\n复查品牌 logo 的 img 标签:');
|
||||
for (const f of ['index.html', 'brands.html']) {
|
||||
const s = fs.readFileSync(path.join(SITE, f), 'utf8');
|
||||
const tags = [...s.matchAll(/<img\s+src="assets\/img\/brands\/[^>]*>/g)].map(m => m[0]);
|
||||
const withDim = tags.filter(t => /width=/.test(t));
|
||||
console.log(' ' + f.padEnd(14) + tags.length + ' 个品牌 logo,仍带 width 属性的: ' + withDim.length);
|
||||
if (tags[0]) console.log(' 示例: ' + tags[0]);
|
||||
}
|
||||
|
||||
// 顺带确认 CSS 里的固定高度规则存在
|
||||
const css = fs.readFileSync(path.join(SITE, 'assets', 'css', 'main.css'), 'utf8');
|
||||
const rule = css.match(/\.marquee__item img\s*\{[^}]*\}/);
|
||||
console.log('\nCSS 规则 .marquee__item img:');
|
||||
console.log(rule ? rule[0] : ' 未找到!');
|
||||
@@ -0,0 +1,22 @@
|
||||
// 品牌 logo 画布改成 360x240 后,同步 HTML 里声明的 width/height,避免加载前后尺寸跳变
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SITE = path.join(__dirname, '..', 'site');
|
||||
for (const f of ['index.html', 'brands.html']) {
|
||||
const p = path.join(SITE, f);
|
||||
let s = fs.readFileSync(p, 'utf8');
|
||||
const before = s;
|
||||
s = s.replace(/(assets\/img\/brands\/[\w-]+\.webp"[^>]*?)width="320" height="320"/g,
|
||||
(m, head) => head + 'width="360" height="240"');
|
||||
if (s !== before) { fs.writeFileSync(p, s, 'utf8'); console.log('updated ' + f); }
|
||||
else console.log('no change ' + f);
|
||||
}
|
||||
|
||||
// 复查
|
||||
for (const f of ['index.html', 'brands.html']) {
|
||||
const s = fs.readFileSync(path.join(SITE, f), 'utf8');
|
||||
const n = (s.match(/width="360" height="240"/g) || []).length;
|
||||
const old = (s.match(/width="320" height="320"/g) || []).length;
|
||||
console.log(' ' + f + ': 新尺寸 ' + n + ' 处,旧尺寸残留 ' + old + ' 处');
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// 把页面里剩余的根绝对路径(data-bg / data-full / content)改为相对路径
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SITE = path.join(__dirname, '..', 'site');
|
||||
const PAGES = ['index.html', 'brands/index.html', 'honors/index.html',
|
||||
'cases/index.html', 'about/index.html'];
|
||||
|
||||
for (const f of PAGES) {
|
||||
const p = path.join(SITE, f);
|
||||
let s = fs.readFileSync(p, 'utf8');
|
||||
const depth = f.split('/').length - 1;
|
||||
const up = depth === 0 ? '' : '../'.repeat(depth);
|
||||
let changed = 0;
|
||||
|
||||
s = s.replace(/(data-(?:bg|full)=")\/(?!\/)/g, (m, pre) => { changed++; return pre + up; });
|
||||
s = s.replace(/(content=")\/(?=assets)/g, (m, pre) => { changed++; return pre + up; });
|
||||
s = s.replace(/(data-src=")\/(?!\/)/g, (m, pre) => { changed++; return pre + up; });
|
||||
s = s.replace(/(data-lqip=")\/(?!\/)/g, (m, pre) => { changed++; return pre + up; });
|
||||
|
||||
if (changed) { fs.writeFileSync(p, s, 'utf8'); console.log('fixed ' + f + ' (' + changed + ' 处)'); }
|
||||
else console.log('no change ' + f);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// 扁平化后的路径修正:所有页面都在 site/ 根目录,去掉 ../ 前缀并修正页面间链接
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SITE = path.join(__dirname, '..', 'site');
|
||||
const PAGES = ['index.html', 'brands.html', 'honors.html', 'cases.html', 'about.html'];
|
||||
|
||||
for (const f of PAGES) {
|
||||
const p = path.join(SITE, f);
|
||||
let s = fs.readFileSync(p, 'utf8');
|
||||
const before = s;
|
||||
let n = 0;
|
||||
|
||||
// 资源路径:../assets/... -> assets/...
|
||||
s = s.replace(/\.\.\/assets\//g, () => { n++; return 'assets/'; });
|
||||
|
||||
// 页面链接:/brands/ -> brands.html 等
|
||||
const map = [
|
||||
[/href="(?:\.\.\/)?(?:\/)?index\.html"/g, 'href="index.html"'],
|
||||
[/href="(?:\.\.\/)?(?:\/)?brands\/?(?:index\.html)?"/g, 'href="brands.html"'],
|
||||
[/href="(?:\.\.\/)?(?:\/)?honors\/?(?:index\.html)?"/g, 'href="honors.html"'],
|
||||
[/href="(?:\.\.\/)?(?:\/)?cases\/?(?:index\.html)?"/g, 'href="cases.html"'],
|
||||
[/href="(?:\.\.\/)?(?:\/)?about\/?(?:index\.html)?"/g, 'href="about.html"'],
|
||||
[/href="(?:\.\.\/)?(?:\/)?"/g, 'href="index.html"']
|
||||
];
|
||||
for (const [re, to] of map) {
|
||||
s = s.replace(re, () => { n++; return to; });
|
||||
}
|
||||
|
||||
// 带锚点的链接:/cases/#realestate -> cases.html#realestate
|
||||
s = s.replace(/href="(?:\.\.\/)?(?:\/)?(brands|honors|cases|about)\/#/g, (m, g) => { n++; return 'href="' + g + '.html#'; });
|
||||
// 首页锚点:/about/#contact -> about.html#contact (已由上一行覆盖),/ # 保持
|
||||
s = s.replace(/href="(?:\.\.\/)?(?:\/)?#/g, () => { n++; return 'href="#'; });
|
||||
|
||||
// 剩下的根绝对路径(漏网的)统一去掉开头斜杠
|
||||
s = s.replace(/(href|src)="\/(?!\/)/g, (m, a) => { n++; return a + '="'; });
|
||||
s = s.replace(/(data-(?:bg|full|src)=")\/(?!\/)/g, (m, pre) => { n++; return pre; });
|
||||
|
||||
if (s !== before) { fs.writeFileSync(p, s, 'utf8'); console.log('fixed ' + f + ' (' + n + ' 处)'); }
|
||||
else console.log('no change ' + f);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
# 在本地 Gitea 上创建仓库
|
||||
param(
|
||||
[string]$GiteaUrl = "http://10.0.0.131:3000",
|
||||
[string]$User = "dsh",
|
||||
[string]$Password = $env:GITEA_PASS,
|
||||
[string]$RepoName = "xuanheng-website",
|
||||
[switch]$Private
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if (-not $Password) { throw "请通过 -Password 或环境变量 GITEA_PASS 提供密码" }
|
||||
|
||||
$payload = @{
|
||||
name = $RepoName
|
||||
description = "上海炫恒网络系统有限公司 官网 —— 纯静态站点(首页/合作品牌/荣誉资质/客户案例/关于我们)"
|
||||
private = [bool]$Private
|
||||
auto_init = $false
|
||||
default_branch = "main"
|
||||
gitignores = ""
|
||||
license = ""
|
||||
} | ConvertTo-Json -Compress
|
||||
|
||||
$tmp = Join-Path $env:TEMP ("gitea-body-" + (Get-Random) + ".json")
|
||||
[System.IO.File]::WriteAllText($tmp, $payload, (New-Object System.Text.UTF8Encoding($false)))
|
||||
|
||||
try {
|
||||
$raw = & curl.exe -s -w "`n__HTTP__%{http_code}" `
|
||||
-X POST "$GiteaUrl/api/v1/user/repos" `
|
||||
-u "${User}:${Password}" `
|
||||
-H "Content-Type: application/json" `
|
||||
--data-binary "@$tmp" 2>&1 | Out-String
|
||||
|
||||
$code = [regex]::Match($raw, '__HTTP__(\d+)').Groups[1].Value
|
||||
$body = ($raw -replace '\s*__HTTP__\d+\s*$', '').Trim()
|
||||
|
||||
Write-Host "HTTP $code"
|
||||
if ($code -eq "201") {
|
||||
$r = $body | ConvertFrom-Json
|
||||
Write-Host "创建成功:$($r.full_name)" -ForegroundColor Green
|
||||
Write-Host " 克隆地址(HTTP): $($r.clone_url)"
|
||||
Write-Host " 网页地址: $($r.html_url)"
|
||||
Write-Host " 公开: $(-not $r.private)"
|
||||
$r.clone_url | Out-File -Encoding ascii "$env:TEMP\xh-clone-url.txt"
|
||||
}
|
||||
elseif ($code -eq "409") {
|
||||
Write-Host "仓库已存在(跳过创建)" -ForegroundColor Yellow
|
||||
"$GiteaUrl/$User/$RepoName.git" | Out-File -Encoding ascii "$env:TEMP\xh-clone-url.txt"
|
||||
}
|
||||
else {
|
||||
Write-Host "创建失败,返回内容:"
|
||||
Write-Host $body
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// 最终结构与文案核查
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const SITE = path.join(__dirname, '..', 'site');
|
||||
|
||||
const home = fs.readFileSync(path.join(SITE, 'index.html'), 'utf8');
|
||||
console.log('=== 首页主视觉 ===');
|
||||
console.log(' 轮播结构残留:', /hero__slide|hero__dot/.test(home) ? '有(异常)' : '无 OK');
|
||||
console.log(' 固定单图:', /class="hero__bg"/.test(home) ? '有 OK' : '缺失');
|
||||
const heroImgs = [...home.matchAll(/hero-1[^"')]*\.webp/g)].map(m => m[0]);
|
||||
console.log(' 背景图引用:', [...new Set(heroImgs)].join(', '));
|
||||
console.log(' 含副标题:', /hero__sub/.test(home) ? '有 OK' : '缺失');
|
||||
|
||||
console.log('\n=== 首页品牌条 ===');
|
||||
const ps = home.indexOf('<section class="partners"');
|
||||
const pe = home.indexOf('<!-- ========================================================== 能力');
|
||||
const partners = home.slice(ps, pe);
|
||||
const texts = [...partners.matchAll(/>([^<>]*[\u4e00-\u9fa5][^<>]*)</g)].map(m => m[1].trim()).filter(Boolean);
|
||||
console.log(' 可见文案:', texts.join(' / '));
|
||||
console.log(' 品牌条目数:', (partners.match(/marquee__item/g) || []).length);
|
||||
|
||||
console.log('\n=== 数量表述检查 ===');
|
||||
for (const f of ['index.html', 'brands.html', 'honors.html', 'cases.html', 'about.html']) {
|
||||
const s = fs.readFileSync(path.join(SITE, f), 'utf8');
|
||||
const brandArea = f === 'index.html' ? partners : s;
|
||||
const hits = [...brandArea.matchAll(/[0-9]+\s*家|八大/g)].map(m => m[0]);
|
||||
const brandHits = hits.filter(() => true);
|
||||
if (f === 'brands.html') {
|
||||
// brands.html 里允许出现具体案例数量,这里只看品牌墙附近
|
||||
const inWall = brandArea.slice(brandArea.indexOf('brandwall') - 400, brandArea.indexOf('brandwall') + 200);
|
||||
const w = [...inWall.matchAll(/[0-9]+\s*家|八大/g)].map(m => m[0]);
|
||||
console.log(' ' + f.padEnd(14) + '品牌墙附近: ' + (w.length ? w.join(', ') : '无数量表述 OK'));
|
||||
} else {
|
||||
console.log(' ' + f.padEnd(14) + (brandHits.length ? brandHits.join(', ') : '无数量表述 OK'));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n=== 品牌 logo 尺寸归一化产物 ===');
|
||||
const dir = path.join(SITE, 'assets/img/brands');
|
||||
for (const f of fs.readdirSync(dir).sort()) {
|
||||
console.log(' ' + f.padEnd(16) + (fs.statSync(path.join(dir, f)).size / 1024).toFixed(1) + ' KB');
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// 固定高度方案下,每个品牌 logo 的最终渲染尺寸
|
||||
const LOGOS = [
|
||||
['Ruijie 锐捷', 320, 54], ['CISCO', 132, 70], ['H3C', 178, 46], ['Microsoft', 207, 44],
|
||||
['vmware', 166, 40], ['CITRIX', 132, 53], ['JUNIPER', 166, 46], ['Radware', 265, 46]
|
||||
];
|
||||
|
||||
// 首页品牌条:4 列网格,容器 max-width 1320 - 两侧 24px,列间距 16px
|
||||
function tileWidth(viewport, wrapMax, gutter, gap, cols) {
|
||||
const inner = Math.min(viewport, wrapMax) - gutter * 2;
|
||||
return (inner - gap * (cols - 1)) / cols;
|
||||
}
|
||||
|
||||
const SCENARIOS = [
|
||||
['宽屏 1600px', 1600, 1320, 24, 16, 4, 56, 20],
|
||||
['笔记本 1440px', 1440, 1320, 24, 16, 4, 56, 20],
|
||||
['小笔记本 1200px', 1200, 1320, 24, 16, 4, 56, 20]
|
||||
];
|
||||
|
||||
for (const [label, vw, wrapMax, gutter, gap, cols, h, pad] of SCENARIOS) {
|
||||
const tw = tileWidth(vw, wrapMax, gutter, gap, cols);
|
||||
const innerW = tw - pad * 2;
|
||||
console.log('== ' + label + ':卡片 ' + tw.toFixed(0) + 'px,内容区 ' +
|
||||
innerW.toFixed(0) + '×' + (132 - pad * 2) + 'px,logo 高 ' + h + 'px ==');
|
||||
console.log(' ' + '品牌'.padEnd(14) + '渲染尺寸'.padEnd(12) + '状态');
|
||||
let clamped = 0;
|
||||
for (const [name, nw, nh] of LOGOS) {
|
||||
let w = h * nw / nh;
|
||||
const over = w > innerW;
|
||||
if (over) { w = innerW; clamped++; }
|
||||
const rh = over ? innerW * nh / nw : h;
|
||||
console.log(' ' + name.padEnd(14) +
|
||||
(Math.round(w) + 'x' + Math.round(rh)).padEnd(12) +
|
||||
(over ? '受宽度限制,高度被压到 ' + Math.round(rh) + 'px' : '满高 ' + h + 'px'));
|
||||
}
|
||||
console.log(' → ' + (clamped ? clamped + ' 个受宽度限制' : '全部等高 56px,视觉完全统一'));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// 清理已不再使用的 CSS 规则(删除整块 @media 请谨慎,这里只删顶层规则)
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const CSS = path.join(__dirname, '..', 'site', 'assets', 'css', 'main.css');
|
||||
let css = fs.readFileSync(CSS, 'utf8');
|
||||
const before = css.length;
|
||||
|
||||
// 这些选择器对应的结构已在改版中移除,且确认页面里没有再引用
|
||||
const DEAD = [
|
||||
'deco-glow', 'deco-dots', 'floatcard', 'floatcard__num', 'floatcard__txt',
|
||||
'sec-head', 'sec-head--center', 'section--dark', 'btn--ghost-light',
|
||||
'figure--float', 'hl', 'lead', 'text-center', 'sr-only'
|
||||
];
|
||||
|
||||
function removeRule(src, selectorFragment) {
|
||||
// 匹配「选择器块 + 紧随其后的 { ... }」(含 @media 内的一层)
|
||||
const re = new RegExp(
|
||||
'(^|\\n)([^\\n{}]*\\.' + selectorFragment.replace(/[-]/g, '\\-') +
|
||||
'(?![\\w-])[^\\n{}]*)\\{[^{}]*\\}', 'g');
|
||||
let count = 0;
|
||||
const out = src.replace(re, (m, lead, sel) => {
|
||||
count++;
|
||||
return lead; // 保留换行,删掉整条规则
|
||||
});
|
||||
return [out, count];
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
for (const sel of DEAD) {
|
||||
const [out, n] = removeRule(css, sel);
|
||||
css = out;
|
||||
total += n;
|
||||
if (n) console.log(' 删除 .' + sel + ' 相关规则 ' + n + ' 条');
|
||||
}
|
||||
|
||||
css = css.replace(/\n{3,}/g, '\n\n');
|
||||
fs.writeFileSync(CSS, css, 'utf8');
|
||||
console.log('\nCSS 体积:' + before + ' → ' + css.length + ' 字节(删除 ' + total + ' 条规则)');
|
||||
@@ -0,0 +1,37 @@
|
||||
// 清理未被任何页面 / CSS / JS 引用的图片资源
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SITE = path.join(__dirname, '..', 'site');
|
||||
const IMG = path.join(SITE, 'assets', 'img');
|
||||
|
||||
// 1) 收集所有引用
|
||||
let refs = '';
|
||||
for (const f of ['index.html', 'brands.html', 'honors.html', 'cases.html', 'about.html',
|
||||
'assets/css/main.css', 'assets/js/main.js', 'assets/lqip.css', 'assets/lqip.js']) {
|
||||
refs += fs.readFileSync(path.join(SITE, f), 'utf8');
|
||||
}
|
||||
// 2) 保留清单(lqip.js 里以 base64 形式存在,不需要单独文件)
|
||||
const KEEP_EXTRA = [];
|
||||
|
||||
function walk(dir) {
|
||||
let out = [];
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) out = out.concat(walk(p));
|
||||
else out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
let removed = 0, removedBytes = 0;
|
||||
for (const p of walk(IMG)) {
|
||||
const rel = 'assets/img/' + path.relative(IMG, p).split(path.sep).join('/');
|
||||
const name = path.basename(p);
|
||||
if (refs.includes(rel) || refs.includes(name) || KEEP_EXTRA.includes(rel)) continue;
|
||||
const size = fs.statSync(p).size;
|
||||
fs.unlinkSync(p);
|
||||
removed++; removedBytes += size;
|
||||
console.log(' 删除 ' + rel + ' (' + (size / 1024).toFixed(1) + ' KB)');
|
||||
}
|
||||
console.log('\n共删除 ' + removed + ' 个未引用文件,释放 ' + (removedBytes / 1024).toFixed(0) + ' KB');
|
||||
@@ -0,0 +1,28 @@
|
||||
// 一次性脚本:把站点内的根绝对路径改为相对路径,便于任意子目录/本地直接打开
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SITE = path.join(__dirname, '..', 'site');
|
||||
|
||||
const PAGES = ['index.html',
|
||||
'brands/index.html',
|
||||
'honors/index.html',
|
||||
'cases/index.html',
|
||||
'about/index.html'];
|
||||
|
||||
function rewrite(file) {
|
||||
const p = path.join(SITE, file);
|
||||
let s = fs.readFileSync(p, 'utf8');
|
||||
const depth = file.split('/').length - 1;
|
||||
const up = depth === 0 ? '' : '../'.repeat(depth);
|
||||
|
||||
const before = s;
|
||||
// href="/..." src="/..." style 中的 url('/...')
|
||||
s = s.replace(/(href|src)="\/(?!\/)/g, (m, attr) => `${attr}="${up}`);
|
||||
s = s.replace(/url\('\/(?!\/)/g, `url('${up}`);
|
||||
s = s.replace(/(content=")\/(?=assets)/g, `$1${up}`);
|
||||
if (s !== before) fs.writeFileSync(p, s, 'utf8');
|
||||
console.log(`${file} depth=${depth} ${s !== before ? 'rewritten' : 'no change'}`);
|
||||
}
|
||||
|
||||
PAGES.forEach(rewrite);
|
||||
@@ -0,0 +1,34 @@
|
||||
// 去掉「8 家」这类数量表述(后续品牌还会增加),改成不限定数量的说法
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SITE = path.join(__dirname, '..', 'site');
|
||||
|
||||
const REPLACE = [
|
||||
['8 家全球一线厂商授权合作', '携手全球一线 IT 厂商'],
|
||||
['八大核心合作品牌', '核心合作品牌'],
|
||||
['8 家</span>', '多家</span>'],
|
||||
['一线厂商授权', '主流厂商授权'],
|
||||
];
|
||||
|
||||
for (const f of ['index.html', 'brands.html', 'honors.html', 'cases.html', 'about.html']) {
|
||||
const p = path.join(SITE, f);
|
||||
let s = fs.readFileSync(p, 'utf8');
|
||||
const before = s;
|
||||
let n = 0;
|
||||
for (const [from, to] of REPLACE) {
|
||||
const parts = s.split(from);
|
||||
if (parts.length > 1) { n += parts.length - 1; s = parts.join(to); }
|
||||
}
|
||||
if (s !== before) { fs.writeFileSync(p, s, 'utf8'); console.log('updated ' + f + ' (' + n + ' 处)'); }
|
||||
else console.log('no change ' + f);
|
||||
}
|
||||
|
||||
console.log('\n复查是否还有数量表述:');
|
||||
for (const f of ['index.html', 'brands.html', 'honors.html', 'cases.html', 'about.html']) {
|
||||
const s = fs.readFileSync(path.join(SITE, f), 'utf8');
|
||||
const hits = [];
|
||||
for (const m of s.matchAll(/(八|[0-9]+)\s*家/g)) hits.push(m[0] + ' ← ' + s.slice(Math.max(0, m.index - 18), m.index + 12).replace(/\s+/g, ' '));
|
||||
for (const m of s.matchAll(/八大/g)) hits.push('八大');
|
||||
console.log(' ' + f.padEnd(14) + (hits.length ? hits.join(' | ') : '无'));
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// 1) 删除所有页脚里的「本站图文仅用于企业形象展示」
|
||||
// 2) 移除 .header.is-hidden 样式与相关规则(菜单改为长期置顶)
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SITE = path.join(__dirname, '..', 'site');
|
||||
const PAGES = ['index.html', 'brands.html', 'honors.html', 'cases.html', 'about.html'];
|
||||
|
||||
let total = 0;
|
||||
for (const f of PAGES) {
|
||||
const p = path.join(SITE, f);
|
||||
let s = fs.readFileSync(p, 'utf8');
|
||||
const before = s;
|
||||
|
||||
// 匹配整行:<span>本站图文仅用于企业形象展示</span>(允许前后空白)
|
||||
s = s.replace(/[ \t]*<span>\s*本站图文仅用于企业形象展示\s*<\/span>\s*\n?/g, '');
|
||||
// 兜底:只删文字节点
|
||||
s = s.replace(/本站图文仅用于企业形象展示/g, '');
|
||||
|
||||
if (s !== before) {
|
||||
fs.writeFileSync(p, s, 'utf8');
|
||||
const n = (before.match(/本站图文仅用于企业形象展示/g) || []).length;
|
||||
total += n;
|
||||
console.log(' ' + f.padEnd(14) + '删除 ' + n + ' 处');
|
||||
} else {
|
||||
console.log(' ' + f.padEnd(14) + '无变化');
|
||||
}
|
||||
}
|
||||
console.log('共删除 ' + total + ' 处\n');
|
||||
|
||||
// 复查
|
||||
console.log('复查页脚剩余文案:');
|
||||
for (const f of PAGES) {
|
||||
const s = fs.readFileSync(path.join(SITE, f), 'utf8');
|
||||
const m = s.match(/<div class="footer__bottom">([\s\S]*?)<\/div>/);
|
||||
const txt = m ? m[1].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() : '(未找到)';
|
||||
console.log(' ' + f.padEnd(14) + txt.slice(0, 70));
|
||||
if (/本站图文/.test(s)) console.log(' !! 仍有残留');
|
||||
}
|
||||
|
||||
// CSS:移除 is-hidden 规则
|
||||
const cssPath = path.join(SITE, 'assets', 'css', 'main.css');
|
||||
let css = fs.readFileSync(cssPath, 'utf8');
|
||||
const before = css;
|
||||
css = css.replace(/\n\.header\.is-hidden\s*\{[^}]*\}\n?/g, '\n');
|
||||
if (css !== before) { fs.writeFileSync(cssPath, css, 'utf8'); console.log('\nCSS: 已移除 .header.is-hidden 规则'); }
|
||||
else console.log('\nCSS: 未找到 .header.is-hidden 规则');
|
||||
console.log('CSS 中 is-hidden 残留: ' + (css.match(/is-hidden/g) || []).length + ' 处(.filter-item 用的保留)');
|
||||
@@ -0,0 +1,40 @@
|
||||
# 通过 SSH 把本地目录同步到服务器(tar 流,不依赖 rsync)
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$LocalPath,
|
||||
[Parameter(Mandatory = $true)][string]$RemotePath,
|
||||
[string]$Server = "10.0.0.131",
|
||||
[string]$User = "root"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Continue"
|
||||
$tmp = (Get-Content "$env:TEMP\xh-tmpdir.txt" -Raw).Trim()
|
||||
$env:DISPLAY = "localhost:0"
|
||||
$env:SSH_ASKPASS = "$tmp\askpass.cmd"
|
||||
$env:SSH_ASKPASS_REQUIRE = "force"
|
||||
|
||||
$common = @(
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=$tmp\known_hosts",
|
||||
"-o", "PreferredAuthentications=password",
|
||||
"-o", "PubkeyAuthentication=no",
|
||||
"-o", "NumberOfPasswordPrompts=1",
|
||||
"-o", "ConnectTimeout=20",
|
||||
"-o", "ServerAliveInterval=15"
|
||||
)
|
||||
|
||||
$target = "$User@$Server"
|
||||
|
||||
# 服务器端先建目录
|
||||
& ssh @common $target "mkdir -p '$RemotePath' && echo REMOTE_MKDIR_OK" 2>&1 | ForEach-Object { " $_" }
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "远程建目录失败"; exit 1 }
|
||||
|
||||
# 本地打 tar 流 -> ssh 管道 -> 远端解包
|
||||
Write-Host " 打包并传输 $LocalPath ..."
|
||||
Push-Location $LocalPath
|
||||
try {
|
||||
& tar -cf - . | & ssh @common $target "tar -xf - -C '$RemotePath' && echo REMOTE_UNPACK_OK"
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
if ($LASTEXITCODE -eq 0) { Write-Host " 同步完成" } else { Write-Error "传输失败 (exit $LASTEXITCODE)" }
|
||||
@@ -0,0 +1,331 @@
|
||||
/* 在 DOM 仿真环境里跑一遍 main.js,验证交互逻辑。
|
||||
* 用法: node tools/test-js.js
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const { El, createDocument, queryAll } = require('./domshim');
|
||||
|
||||
const SRC = fs.readFileSync(path.join(__dirname, '..', 'site', 'assets', 'js', 'main.js'), 'utf8');
|
||||
|
||||
const doc = createDocument();
|
||||
const timers = [];
|
||||
const intervals = [];
|
||||
let rafQueue = [];
|
||||
|
||||
function flushTimers(onlyInterval) {
|
||||
const list = timers.splice(0);
|
||||
list.forEach(t => t.fn());
|
||||
}
|
||||
function fireIntervals() { intervals.forEach(t => t.fn()); }
|
||||
function flushRaf(t) {
|
||||
const q = rafQueue.splice(0);
|
||||
q.forEach(fn => fn(t === undefined ? 0 : t));
|
||||
}
|
||||
function flushRaf(t) {
|
||||
const q = rafQueue.splice(0);
|
||||
q.forEach(fn => fn(t === undefined ? 0 : t));
|
||||
}
|
||||
|
||||
const win = {
|
||||
pageYOffset: 0,
|
||||
innerWidth: 1440,
|
||||
innerHeight: 900,
|
||||
document: doc,
|
||||
matchMedia: () => ({ matches: false, addEventListener() { } }),
|
||||
addEventListener(t, fn) { (win._l = win._l || {})[t] = (win._l[t] || []).concat(fn); },
|
||||
removeEventListener() { },
|
||||
requestAnimationFrame(fn) { rafQueue.push(fn); return rafQueue.length; },
|
||||
setTimeout(fn) { timers.push({ fn }); return timers.length; },
|
||||
clearTimeout() { },
|
||||
setInterval(fn) { intervals.push({ fn }); return intervals.length; },
|
||||
clearInterval(i) { intervals.length = 0; },
|
||||
scrollTo() { },
|
||||
getComputedStyle: () => ({ getPropertyValue: () => '' }),
|
||||
location: { pathname: '/', search: '', href: 'http://x/' },
|
||||
navigator: { userAgent: 'node' },
|
||||
history: { replaceState() { } }
|
||||
};
|
||||
|
||||
const sandbox = {
|
||||
window: win, document: doc, console,
|
||||
Image: class {
|
||||
constructor() { this.onload = null; this.onerror = null; }
|
||||
set src(v) { this._src = v; if (this.onload) this.onload(); }
|
||||
get src() { return this._src; }
|
||||
},
|
||||
IntersectionObserver: class {
|
||||
constructor(cb) { this.cb = cb; this.queue = []; }
|
||||
observe(el) { this.queue.push(el); setTimeout(() => this.cb([{ isIntersecting: true, target: el }], this), 0); }
|
||||
unobserve() { }
|
||||
},
|
||||
XMLHttpRequest: class { open() { } send() { } setRequestHeader() { } },
|
||||
location: win.location, navigator: win.navigator, history: win.history,
|
||||
matchMedia: win.matchMedia, getComputedStyle: win.getComputedStyle,
|
||||
requestAnimationFrame: win.requestAnimationFrame,
|
||||
setTimeout: win.setTimeout, clearTimeout: win.clearTimeout,
|
||||
setInterval: win.setInterval, clearInterval: win.clearInterval,
|
||||
Math, JSON, Date, Object, Array, String, Number, Boolean, RegExp, Error,
|
||||
parseInt, parseFloat, isNaN, encodeURIComponent, decodeURIComponent
|
||||
};
|
||||
sandbox.globalThis = sandbox;
|
||||
sandbox.self = sandbox;
|
||||
|
||||
/* ---------------------------------------------------------------- 构造 DOM */
|
||||
function el(tag, attrs, parent) {
|
||||
const e = new El(tag, doc);
|
||||
Object.entries(attrs || {}).forEach(([k, v]) => {
|
||||
if (k === 'class') e.className = v;
|
||||
else if (k === 'value') e.value = v;
|
||||
else e.setAttribute(k, v);
|
||||
});
|
||||
(parent || doc.body).appendChild(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
const header = el('header', { class: 'header header--transparent' });
|
||||
const progress = el('div', { class: 'progress' });
|
||||
const burger = el('button', { class: 'burger' }, header);
|
||||
const totop = el('button', { class: 'totop' });
|
||||
|
||||
const drawer = el('div', { class: 'drawer' });
|
||||
for (let i = 0; i < 5; i++) el('a', { class: 'drawer__link', href: '#' }, drawer);
|
||||
|
||||
el('i', { 'data-icon': 'arrowRight' });
|
||||
el('i', { 'data-icon': 'shield', 'data-icon-class': 'big' });
|
||||
|
||||
const r1 = el('div', { 'data-reveal': '' });
|
||||
const r2 = el('div', { 'data-reveal': 'left' });
|
||||
const counter = el('span', { 'data-count': '1234', 'data-unit': '+' });
|
||||
const lazyImg = el('img', { 'data-src': '/a.webp' });
|
||||
const lazyBg = el('div', { 'data-bg': '/b.webp' });
|
||||
|
||||
const hero = el('section', { class: 'hero' });
|
||||
const heroBg = el('div', { class: 'hero__bg' }, hero);
|
||||
const heroPanel = el('div', { class: 'hero__panel' }, hero);
|
||||
|
||||
const acc = el('div', { class: 'acc' });
|
||||
const accItem = el('div', { class: 'acc__item' }, acc);
|
||||
const accBtn = el('button', { class: 'acc__btn' }, accItem);
|
||||
const accPanel = el('div', { class: 'acc__panel' }, accItem);
|
||||
|
||||
const bar = el('div', { 'data-filter-bar': '#list' });
|
||||
const chipAll = el('button', { class: 'chip is-active', 'data-cat': 'all' }, bar);
|
||||
const chipA = el('button', { class: 'chip', 'data-cat': 'a' }, bar);
|
||||
const list = el('div', { id: 'list' });
|
||||
const itemA = el('a', { class: 'filter-item', 'data-cat': 'a' }, list);
|
||||
const itemB = el('a', { class: 'filter-item', 'data-cat': 'b' }, list);
|
||||
|
||||
const lb1 = el('button', { 'data-lightbox': 'g', 'data-full': '/1.webp', 'data-title': '证书一', 'data-lqip': 'k1' });
|
||||
const lb2 = el('button', { 'data-lightbox': 'g', 'data-full': '/2.webp', 'data-title': '证书二' });
|
||||
|
||||
const marquee = el('div', { class: 'marquee home-marquee' });
|
||||
const track = el('div', { class: 'marquee__track' }, marquee);
|
||||
for (let i = 0; i < 3; i++) el('div', { class: 'marquee__item' }, track);
|
||||
|
||||
// 品牌 logo:一个宽扁(Ruijie 比例 320x54)+ 一个接近方形(CISCO 比例 132x70)
|
||||
const wideBox = el('div', { class: 'marquee__item' }, track);
|
||||
wideBox._boxH = 150; wideBox._boxW = 260;
|
||||
const wideImg = el('img', { src: 'assets/img/brands/ruijie.webp' }, wideBox);
|
||||
wideImg._nw = 320; wideImg._nh = 54; wideImg._complete = true; wideImg.attrs.src = 'assets/img/brands/ruijie.webp';
|
||||
const sqBox = el('div', { class: 'brandcard' }, track);
|
||||
sqBox._boxH = 190; sqBox._boxW = 285;
|
||||
const sqImg = el('img', { src: 'assets/img/brands/cisco.webp' }, sqBox);
|
||||
sqImg._nw = 132; sqImg._nh = 70; sqImg._complete = true; sqImg.attrs.src = 'assets/img/brands/cisco.webp';
|
||||
|
||||
const form = el('form', { 'data-validate': '' });
|
||||
const fieldName = el('div', { class: 'field' }, form);
|
||||
const inputName = el('input', { 'data-required': 'true', 'data-label': '姓名' }, fieldName);
|
||||
el('span', { class: 'field__err' }, fieldName);
|
||||
const fieldMail = el('div', { class: 'field' }, form);
|
||||
const inputMail = el('input', { type: 'email', 'data-required': 'true', 'data-label': '邮箱' }, fieldMail);
|
||||
el('span', { class: 'field__err' }, fieldMail);
|
||||
const okBox = el('div', { class: 'form__ok' }, form);
|
||||
const submitBtn = el('button', { type: 'submit' }, form);
|
||||
const anchor = el('a', { href: '#list' });
|
||||
|
||||
/* ---------------------------------------------------------------- 执行 */
|
||||
const failures = [];
|
||||
function assert(cond, msg) {
|
||||
if (cond) console.log(' ✓ ' + msg);
|
||||
else { failures.push(msg); console.log(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
try {
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(SRC, sandbox, { filename: 'main.js' });
|
||||
assert(true, 'main.js 在 DOM 环境中成功执行(无异常)');
|
||||
} catch (e) {
|
||||
console.log(' ✗ main.js 执行异常:\n' + e.stack);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\n== 1. 图标注入 ==');
|
||||
assert(sandbox.window.XH && typeof sandbox.window.XH.icon === 'function', 'XH.icon 已挂载');
|
||||
assert(sandbox.window.XH.icon('shield').indexOf('<svg') === 0, 'icon() 返回内联 svg');
|
||||
assert(sandbox.window.XH.icon('nope').indexOf('<svg') === 0, '未知图标名安全回退');
|
||||
assert(counter.ownerDocument && true, '文档对象可用');
|
||||
|
||||
console.log('\n== 2. 头部(长期置顶)/ 滚动 / 进度条 ==');
|
||||
win.pageYOffset = 900;
|
||||
(win._l.scroll || []).forEach(fn => fn());
|
||||
flushRaf();
|
||||
assert(header.classList.contains('is-stuck'), '滚动后 header.is-stuck(毛玻璃状态)');
|
||||
assert(!header.classList.contains('is-hidden'), '菜单不会自动隐藏(长期置顶)');
|
||||
assert(totop.classList.contains('is-show'), '下滚后回到顶部按钮显示');
|
||||
|
||||
// 置顶的关键:深滚动后依然可见,且 CSS 里不能有 is-hidden 的隐藏规则
|
||||
win.pageYOffset = 4000;
|
||||
(win._l.scroll || []).forEach(fn => fn());
|
||||
flushRaf();
|
||||
assert(header.classList.contains('is-stuck'), '深度滚动后依然保持置顶状态');
|
||||
assert(!header.classList.contains('is-hidden'), '深度滚动后菜单依然可见');
|
||||
|
||||
const headerCss = fs.readFileSync(path.join(__dirname, '..', 'site', 'assets', 'css', 'main.css'), 'utf8');
|
||||
assert(!/\.header\.is-hidden/.test(headerCss), 'CSS 里已无 .header.is-hidden(不会把菜单移出屏幕)');
|
||||
assert(/position:\s*fixed/.test(headerCss.slice(headerCss.indexOf('.header {'), headerCss.indexOf('.header {') + 200)),
|
||||
'头部使用 position: fixed 固定在顶部');
|
||||
|
||||
win.pageYOffset = 0;
|
||||
(win._l.scroll || []).forEach(fn => fn());
|
||||
flushRaf();
|
||||
assert(!header.classList.contains('is-stuck'), '回到顶部 → 移除 is-stuck');
|
||||
assert(!totop.classList.contains('is-show'), '顶部时回到顶部按钮隐藏');
|
||||
|
||||
console.log('\n== 3. 移动端抽屉 ==');
|
||||
burger.dispatch('click');
|
||||
assert(doc.body.classList.contains('nav-open'), '汉堡点击打开抽屉');
|
||||
assert(doc.body.classList.contains('is-locked'), '打开时锁定 body 滚动');
|
||||
assert(burger.getAttribute('aria-expanded') === 'true', 'aria-expanded=true');
|
||||
doc.dispatch('keydown', { key: 'Escape' });
|
||||
assert(!doc.body.classList.contains('nav-open'), 'Esc 关闭抽屉');
|
||||
|
||||
console.log('\n== 4. 滚动进场 ==');
|
||||
assert(r1.classList.contains('is-in'), 'reveal 元素进入视口加 is-in');
|
||||
assert(r2.classList.contains('is-in'), '第二种 reveal 同样生效');
|
||||
|
||||
console.log('\n== 5. 数字滚动 ==');
|
||||
flushRaf(0); flushRaf(4000);
|
||||
assert(counter.innerHTML === '1,234+', '计数器输出 1,234+(实际 ' + counter.innerHTML + ')');
|
||||
|
||||
console.log('\n== 6. 懒加载 ==');
|
||||
flushTimers();
|
||||
assert(lazyImg.getAttribute('src') === '/a.webp', 'img[data-src] 换成 src');
|
||||
assert(lazyImg.classList.contains('is-loaded'), '加载完成加 is-loaded');
|
||||
assert(lazyBg.style.backgroundImage === "url('/b.webp')", 'data-bg 背景图被设置(实际 ' + lazyBg.style.backgroundImage + ')');
|
||||
assert(lazyBg.classList.contains('is-loaded'), '背景加载完成加 is-loaded');
|
||||
console.log('\n== 7. 主视觉(固定单图 + 视差,无轮播) ==');
|
||||
assert(intervals.length === 0, '不再注册轮播定时器(实测 ' + intervals.length + ' 个)');
|
||||
assert(doc.querySelectorAll('.hero__slide').length === 0, '页面中不存在轮播幻灯片元素');
|
||||
win.pageYOffset = 200;
|
||||
(win._l.scroll || []).forEach(fn => fn());
|
||||
flushRaf();
|
||||
assert(/translate3d/.test(heroBg.style.transform || ''), '滚动时背景产生视差位移(' + heroBg.style.transform + ')');
|
||||
assert(heroPanel.classList.contains('hero__panel'), '右侧数据面板结构完整');
|
||||
|
||||
console.log('\n== 8. 手风琴 ==');
|
||||
accBtn.dispatch('click');
|
||||
assert(accItem.classList.contains('is-open'), '点击展开面板');
|
||||
assert(accBtn.getAttribute('aria-expanded') === 'true', 'aria-expanded=true');
|
||||
assert(accPanel.style.height === '120px', '展开时高度设为内容高度(实际 ' + accPanel.style.height + ')');
|
||||
accPanel.dispatch('transitionend', { propertyName: 'height' });
|
||||
assert(accPanel.style.height === 'auto', '展开动画结束后高度置 auto(内容自适应)');
|
||||
accBtn.dispatch('click');
|
||||
assert(!accItem.classList.contains('is-open'), '再次点击收起');
|
||||
assert(accPanel.style.height === '120px', '收起第一帧先固定当前高度(实际 ' + accPanel.style.height + ')');
|
||||
flushRaf();
|
||||
assert(accPanel.style.height === '0px', '下一帧高度过渡到 0px,收起动画生效(实际 ' + accPanel.style.height + ')');
|
||||
|
||||
console.log('\n== 9. 案例筛选 ==');
|
||||
chipA.dispatch('click');
|
||||
assert(!itemA.classList.contains('is-hidden'), '匹配项保持显示');
|
||||
assert(itemB.classList.contains('is-hidden'), '不匹配项被隐藏');
|
||||
assert(chipA.classList.contains('is-active') && !chipAll.classList.contains('is-active'), '筛选按钮激活态切换');
|
||||
flushTimers();
|
||||
assert(itemB.style.display === 'none', '隐藏项 display=none');
|
||||
chipAll.dispatch('click');
|
||||
assert(!itemA.classList.contains('is-hidden') && !itemB.classList.contains('is-hidden'), '切回全部时全部显示');
|
||||
assert(itemB.style.display !== 'none', 'display 被还原');
|
||||
|
||||
console.log('\n== 10. 灯箱 ==');
|
||||
lb2.dispatch('click');
|
||||
const lbEl = doc.querySelector('.lightbox');
|
||||
assert(!!lbEl, '灯箱 DOM 被创建');
|
||||
assert(lbEl.classList.contains('is-open'), '灯箱打开');
|
||||
assert(doc.body.classList.contains('is-locked'), '打开时锁定滚动');
|
||||
const lbImg = lbEl.querySelector('.lightbox__imgwrap img');
|
||||
assert(lbImg && /2\.webp/.test(lbImg.getAttribute('src')), '显示被点击的那张图');
|
||||
assert(lbEl.querySelector('.lightbox__bar h3').textContent === '证书二', '标题同步');
|
||||
doc.dispatch('keydown', { key: 'Escape' });
|
||||
assert(!lbEl.classList.contains('is-open'), 'Esc 关闭灯箱');
|
||||
lb1.dispatch('click');
|
||||
doc.dispatch('keydown', { key: 'ArrowRight' });
|
||||
assert(/2\.webp/.test(doc.querySelector('.lightbox__imgwrap img').getAttribute('src')), '方向键切换下一张');
|
||||
doc.dispatch('keydown', { key: 'Escape' });
|
||||
|
||||
console.log('\n== 11. 表单校验 ==');
|
||||
form.dispatch('submit');
|
||||
assert(fieldName.classList.contains('is-error'), '必填为空 → 报错');
|
||||
assert(fieldName.querySelector('.field__err').textContent === '请填写姓名', '错误文案使用 data-label');
|
||||
inputName.value = '张三';
|
||||
inputName.dispatch('input');
|
||||
assert(!fieldName.classList.contains('is-error'), '输入后清除错误态');
|
||||
inputMail.value = 'bad-mail';
|
||||
form.dispatch('submit');
|
||||
assert(fieldMail.classList.contains('is-error'), '邮箱格式错误 → 报错');
|
||||
inputMail.value = 'zhang@example.com';
|
||||
form.dispatch('submit');
|
||||
assert(!fieldMail.classList.contains('is-error'), '格式正确后不再报错');
|
||||
assert(okBox.classList.contains('is-show'), '提交成功提示显示');
|
||||
assert(inputName.value === '', '提交后表单被重置');
|
||||
|
||||
console.log('\n== 12. 锚点平滑滚动 ==');
|
||||
anchor.dispatch('click');
|
||||
assert(true, '锚点点击未抛异常(当前偏移 ' + win.pageYOffset + ')');
|
||||
|
||||
console.log('\n== 13. 品牌条结构 ==');
|
||||
assert(track.children.length === 10, '跑马灯轨道仍能正确复制内容(5 → ' + track.children.length + ')');
|
||||
assert(track.children[5].getAttribute('aria-hidden') === 'true', '副本标记 aria-hidden,不干扰读屏');
|
||||
assert(track.getAttribute('data-cloned') === '1', '标记已复制,避免重复克隆');
|
||||
|
||||
console.log('\n== 14. 品牌 logo 尺寸(纯 CSS 方案)==');
|
||||
const cssText = fs.readFileSync(path.join(__dirname, '..', 'site', 'assets', 'css', 'main.css'), 'utf8');
|
||||
|
||||
// 关键点:logo 的宽度必须由浏览器按原图比例算,JS 绝不能写死像素尺寸。
|
||||
// 之前用 img.style.width/height 写内联像素值,会让 CSS 的 max-width/max-height 失效,
|
||||
// 导致 logo 溢出卡片被裁切、比例也被拉坏(实测出现过 184x156 塞进 150px 高的卡片)。
|
||||
assert(!/initBrandLogos/.test(SRC), 'JS 里已移除会写死尺寸的 initBrandLogos');
|
||||
|
||||
// 检查 CSS 里 marquee__item img 是「固定高度 + 宽度自动」
|
||||
const rule = cssText.match(/\.marquee__item img\s*\{([^}]*)\}/);
|
||||
assert(!!rule, 'CSS 中定义了 .marquee__item img');
|
||||
if (rule) {
|
||||
const body = rule[1];
|
||||
const h = /height:\s*(\d+)px/.exec(body);
|
||||
assert(!!h, 'logo 使用固定高度(' + (h ? h[1] + 'px' : '未找到') + ')');
|
||||
assert(/width:\s*auto/.test(body), '宽度自动,按原图比例渲染(不会拉伸变形)');
|
||||
assert(/max-width:\s*100%/.test(body), '有 max-width 兜底,最宽的 logo 也不会溢出卡片');
|
||||
assert(!/max-height:\s*100%/.test(body), '没有会与固定高度冲突的 max-height');
|
||||
if (h) {
|
||||
const px = parseInt(h[1], 10);
|
||||
assert(px >= 50, 'logo 高度足够大看得清(' + px + 'px)');
|
||||
}
|
||||
}
|
||||
|
||||
// 品牌条改成 4 列网格,卡片更宽,宽 logo 才有足够横向空间
|
||||
assert(/\.brandstrip\s*\{[^}]*grid-template-columns:\s*repeat\(4/.test(cssText.replace(/\s+/g, ' ')) ||
|
||||
/\.brandstrip[\s\S]{0,120}repeat\(4/.test(cssText), '首页品牌条是 4 列网格,卡片宽度足够');
|
||||
|
||||
// 磁盘上的 logo 必须真的存在且尺寸合理
|
||||
const brandDir = path.join(__dirname, '..', 'site', 'assets', 'img', 'brands');
|
||||
const files = fs.readdirSync(brandDir);
|
||||
assert(files.length === 8, '品牌 logo 文件 8 个(实际 ' + files.length + ')');
|
||||
|
||||
// JS 不应再往任何 logo 上写内联尺寸
|
||||
assert(!wideImg.style.width && !wideImg.style.height,
|
||||
'JS 未给 logo 写入内联尺寸(避免 max-width 失效)' +
|
||||
(wideImg.style.width ? ',实际写了 ' + wideImg.style.width : ''));
|
||||
|
||||
console.log('\n' + (failures.length ? '失败 ' + failures.length + ' 项 ❌' : '全部通过 ✅'));
|
||||
if (failures.length) failures.forEach(f => console.log(' - ' + f));
|
||||
process.exit(failures.length ? 1 : 0);
|
||||
@@ -0,0 +1,17 @@
|
||||
// honors.html:把 29 个证书卡片上的 data-reveal 去掉(保留区块标题/统计/左右栏的动画)
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const p = path.join(__dirname, '..', 'site', 'honors.html');
|
||||
let s = fs.readFileSync(p, 'utf8');
|
||||
const before = s;
|
||||
|
||||
// 形如:<button class="certcard ..." type="button" data-reveal\n
|
||||
s = s.replace(/(<button class="certcard[^"]*"[^>]*?)\s+data-reveal(?=[\s>])/g, '$1');
|
||||
|
||||
s = s.replace(/[ \t]{2,}/g, ' ');
|
||||
s = s.replace(/[ \t]+$/gm, '');
|
||||
|
||||
if (s !== before) fs.writeFileSync(p, s, 'utf8');
|
||||
console.log('剩余 data-reveal: ' + (s.match(/data-reveal/g) || []).length +
|
||||
'(去掉 ' + ((before.match(/data-reveal/g) || []).length - (s.match(/data-reveal/g) || []).length) + ' 个卡片动画)');
|
||||
@@ -0,0 +1,33 @@
|
||||
// 精简滚动动画:只保留「区块级」元素的进场,去掉重复卡片/条目的逐个动画
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SITE = path.join(__dirname, '..', 'site');
|
||||
const PAGES = ['index.html', 'brands.html', 'honors.html', 'cases.html', 'about.html'];
|
||||
|
||||
// 这些是重复出现的列表项/卡片,出现时直接显示即可,不再逐个动画
|
||||
const REPEATED = ['card', 'certcard', 'mediacard', 'flow__item', 'timeline'];
|
||||
|
||||
for (const f of PAGES) {
|
||||
const p = path.join(SITE, f);
|
||||
let s = fs.readFileSync(p, 'utf8');
|
||||
const before = s;
|
||||
let n = 0;
|
||||
|
||||
for (const cls of REPEATED) {
|
||||
const re = new RegExp('(<[a-z]+ class="[^"]*\\b' + cls + '\\b[^"]*")\\s+data-reveal(?:="[^"]*")?', 'g');
|
||||
s = s.replace(re, (m, head) => { n++; return head; });
|
||||
// 属性顺序相反的情况:data-reveal 在 class 之前
|
||||
const re2 = new RegExp('(<[a-z]+)\\s+data-reveal(?:="[^"]*")?(\\s+class="[^"]*\\b' + cls + '\\b[^"]*")', 'g');
|
||||
s = s.replace(re2, (m, tag, tail) => { n++; return tag + tail; });
|
||||
}
|
||||
|
||||
if (s !== before) { fs.writeFileSync(p, s, 'utf8'); console.log('trimmed ' + f + ' (' + n + ' 处)'); }
|
||||
else console.log('no change ' + f);
|
||||
}
|
||||
|
||||
console.log('\n剩余 data-reveal 统计:');
|
||||
for (const f of PAGES) {
|
||||
const s = fs.readFileSync(path.join(SITE, f), 'utf8');
|
||||
console.log(' ' + f.padEnd(14) + (s.match(/data-reveal/g) || []).length + ' 处');
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// 按新反馈调整 homepage 的 HTML:新首屏图、去掉非关键元素的滚动动画、去掉定宽内联样式
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SITE = path.join(__dirname, '..', 'site');
|
||||
const PAGES = ['index.html', 'brands.html', 'honors.html', 'cases.html', 'about.html'];
|
||||
|
||||
for (const f of PAGES) {
|
||||
const p = path.join(SITE, f);
|
||||
let s = fs.readFileSync(p, 'utf8');
|
||||
const before = s;
|
||||
let n = 0;
|
||||
|
||||
// 1) 去掉所有内联 --d 延迟(配合精简后的动画)
|
||||
s = s.replace(/\s*style="--d:\.[0-9]+s"/g, () => { n++; return ''; });
|
||||
|
||||
// 2) 去掉信息块/清单里多余的零散滚动动画(保留 data-reveal="left/right/zoom" 这类大块布局动效)
|
||||
s = s.replace(/<div class="infoblock" data-reveal>/g, () => { n++; return '<div class="infoblock">'; });
|
||||
s = s.replace(/<div class="tl-item" data-reveal[^>]*>/g, () => { n++; return '<div class="tl-item">'; });
|
||||
|
||||
if (s !== before) { fs.writeFileSync(p, s, 'utf8'); console.log('updated ' + f + ' (' + n + ' 处)'); }
|
||||
else console.log('no change ' + f);
|
||||
}
|
||||
|
||||
// 3) 首页首屏图换成新图(含 2x),并给首屏文案区加大留白
|
||||
const home = path.join(SITE, 'index.html');
|
||||
let h = fs.readFileSync(home, 'utf8');
|
||||
const oldBg = /<div class="hero__bg" data-lqip="hero\/hero-1" style="background-image:url\('assets\/img\/hero\/hero-1\.webp'\)"><\/div>/;
|
||||
const newBg = '<div class="hero__bg" data-lqip="hero/hero-1" style="background-image:url(\'assets/img/hero/hero-1.webp\')"></div>';
|
||||
if (oldBg.test(h)) {
|
||||
h = h.replace(oldBg, newBg);
|
||||
fs.writeFileSync(home, h, 'utf8');
|
||||
console.log('index.html 首屏背景已指向新图(文件名不变,内容已重建)');
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
# Apache 部署配置(可选)
|
||||
#
|
||||
# 本项目默认用 Docker + Nginx 部署。如果你用的是 Apache 虚拟主机,可以:
|
||||
# 1. 把 site/ 里的全部文件上传到网站根目录
|
||||
# 2. 把本文件重命名为 .htaccess 放到同一个目录
|
||||
#
|
||||
# 作用:
|
||||
# - /brands 这种无扩展名地址自动映射到 /brands.html
|
||||
# - 静态资源长缓存 + gzip
|
||||
# - 404 回到首页
|
||||
#
|
||||
# 注意:如果服务器不允许 .htaccess 覆盖配置(AllowOverride None),
|
||||
# 需要把这些规则写进虚拟主机配置里。
|
||||
|
||||
DirectoryIndex index.html
|
||||
Options -MultiViews
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
# 去掉 .html 后缀:/about 也能打开 /about.html
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME}\.html -f
|
||||
RewriteRule ^(.+?)/?$ $1.html [L]
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_expires.c>
|
||||
ExpiresActive On
|
||||
ExpiresByType text/html "access plus 0 seconds"
|
||||
ExpiresByType text/css "access plus 1 year"
|
||||
ExpiresByType application/javascript "access plus 1 year"
|
||||
ExpiresByType image/webp "access plus 1 year"
|
||||
ExpiresByType image/png "access plus 1 year"
|
||||
ExpiresByType image/jpeg "access plus 1 year"
|
||||
ExpiresByType image/svg+xml "access plus 1 year"
|
||||
ExpiresByType image/x-icon "access plus 1 year"
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_deflate.c>
|
||||
AddOutputFilterByType DEFLATE text/html text/css text/plain text/xml
|
||||
AddOutputFilterByType DEFLATE application/javascript application/json
|
||||
AddOutputFilterByType DEFLATE image/svg+xml
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_headers.c>
|
||||
Header set X-Content-Type-Options "nosniff"
|
||||
Header set Referrer-Policy "strict-origin-when-cross-origin"
|
||||
</IfModule>
|
||||
|
||||
ErrorDocument 404 /index.html
|
||||
@@ -0,0 +1,59 @@
|
||||
# ==============================================================================
|
||||
# 上海炫恒网络系统有限公司 官网 —— 仅用 8081 端口提供服务
|
||||
#
|
||||
# 这个 Caddyfile 只负责把站点跑在容器内的 80 端口(宿主机映射到 8081),
|
||||
# 不申请证书、不碰服务器的 80/443(那是 1Panel 的 Caddy 在用)。
|
||||
# 等你在 1Panel 里把域名反代到 8081 之后,HTTPS 由 1Panel 那层处理。
|
||||
# ==============================================================================
|
||||
{
|
||||
# 关掉自动 HTTPS:本容器只做纯 HTTP 内网服务
|
||||
auto_https off
|
||||
admin off
|
||||
}
|
||||
|
||||
:80 {
|
||||
root * /srv
|
||||
encode zstd gzip
|
||||
file_server
|
||||
|
||||
# 无扩展名地址映射到 .html:
|
||||
# / -> /index.html
|
||||
# /brands -> /brands.html
|
||||
# /assets/x.js -> 直接命中文件
|
||||
# 最后的 {path}/index.html 用来处理目录请求
|
||||
try_files {path} {path}.html {path}/index.html
|
||||
|
||||
# 安全响应头(Caddy 默认会给静态文件加 X-Content-Type-Options: nosniff)
|
||||
header {
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
||||
# 去掉会暴露版本信息的 Server 头
|
||||
-Server
|
||||
}
|
||||
|
||||
# HTML 不缓存,保证改版后刷新即可见
|
||||
@html path *.html /
|
||||
header @html Cache-Control "no-cache, must-revalidate"
|
||||
|
||||
# 带指纹不变的静态资源长缓存
|
||||
@assets path /assets/*
|
||||
header @assets Cache-Control "public, max-age=31536000, immutable"
|
||||
|
||||
# 健康检查
|
||||
handle /healthz {
|
||||
respond "ok" 200
|
||||
}
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/access.log
|
||||
format console
|
||||
}
|
||||
|
||||
# 纯静态站点没有后端,404 回首页兜底
|
||||
handle_errors {
|
||||
@404 expression {http.error.status_code} == 404
|
||||
rewrite @404 /index.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
# 服务器部署记录(10.0.0.131)
|
||||
|
||||
> 这份文件记录**实际部署到服务器上的内容**,与项目根目录的 `Dockerfile` / `docker-compose.yml`
|
||||
> (自建镜像那种方式)是两条不同的路子。这台服务器上用的是下面这一种。
|
||||
|
||||
---
|
||||
|
||||
## 一、当前部署状态
|
||||
|
||||
| 项目 | 值 |
|
||||
|---|---|
|
||||
| 服务器 | `10.0.0.131`(CentOS 7.9,内核 3.10) |
|
||||
| 面板 | 1Panel(自带 Caddy 容器,`network_mode: host`,占用 80 / 443) |
|
||||
| 站点目录 | `/www/wwwroot/xuanheng-site/` |
|
||||
| 容器名 | `xuanheng-web` |
|
||||
| 镜像 | `caddy:2.11.2`(**复用服务器已有镜像**,不依赖 Docker Hub) |
|
||||
| 对外地址 | **http://10.0.0.131:8081/** |
|
||||
| 容器端口 | 宿主机 `8081` → 容器 `80` |
|
||||
| 健康检查 | `http://10.0.0.131:8081/healthz` 返回 `ok` |
|
||||
| 自动 HTTPS | **关闭**(`auto_https off`),HTTPS 交给 1Panel 那层 |
|
||||
|
||||
### 为什么不用自建 nginx 镜像?
|
||||
|
||||
原本仓库根目录的 `Dockerfile` 会构建 `nginx:alpine` 镜像。但实测这台服务器的
|
||||
Docker 镜像加速站很不稳定(`docker.1panel.live` 直接超时),而服务器上**已经有**
|
||||
`caddy:2.11.2`(1Panel 自己在用)。所以改为:
|
||||
|
||||
- 直接复用现成镜像 → 部署不受镜像站影响,秒级完成
|
||||
- 站点文件用 **bind mount** 挂载 → 以后改内容不用重建镜像,覆盖文件即可
|
||||
|
||||
---
|
||||
|
||||
## 二、目录结构(服务器上)
|
||||
|
||||
```
|
||||
/www/wwwroot/xuanheng-site/
|
||||
├── docker-compose.yml 编排(端口 8081、健康检查、日志轮转)
|
||||
├── Caddyfile 站点配置(无扩展名伪静态、缓存、gzip、安全头)
|
||||
├── site/ ★ 站点文件(bind mount 到容器 /srv,只读)
|
||||
│ ├── index.html brands.html honors.html cases.html about.html
|
||||
│ ├── favicon.ico
|
||||
│ └── assets/
|
||||
├── caddy-data/ Caddy 数据(uid 1000 可写)
|
||||
├── caddy-config/ Caddy 配置状态
|
||||
└── logs/ 访问日志(uid 1000 可写)
|
||||
```
|
||||
|
||||
> `caddy-data` / `caddy-config` / `logs` 三个目录的属主是 `1000:1000`,
|
||||
> 因为 caddy 官方镜像里的 `caddy` 用户 uid 就是 1000。换机器重建时别忘了 `chown`。
|
||||
|
||||
---
|
||||
|
||||
## 三、常用运维命令
|
||||
|
||||
```bash
|
||||
cd /www/wwwroot/xuanheng-site
|
||||
|
||||
docker compose ps # 看状态(healthy 即正常)
|
||||
docker compose logs -f # 看实时日志
|
||||
docker compose restart # 重启(改完 Caddyfile 用这个)
|
||||
docker compose up -d # 应用 compose 改动(改端口/挂载时用)
|
||||
docker compose down # 停止并删除容器
|
||||
```
|
||||
|
||||
### 更新站点内容
|
||||
|
||||
**不需要重建镜像、不需要重启容器**,直接覆盖 `site/` 里的文件即可:
|
||||
|
||||
```bash
|
||||
# 在本地打包后上传(推荐,避免中文/二进制被破坏)
|
||||
tar -cf site.tar -C site .
|
||||
scp site.tar root@10.0.0.131:/tmp/
|
||||
ssh root@10.0.0.131 "cd /www/wwwroot/xuanheng-site/site && tar -xf /tmp/site.tar && rm -f /tmp/site.tar"
|
||||
```
|
||||
|
||||
### 更新配置
|
||||
|
||||
改完 `Caddyfile` 后先校验再重启:
|
||||
|
||||
```bash
|
||||
cd /www/wwwroot/xuanheng-site
|
||||
docker run --rm -v "$PWD:/work" caddy:2.11.2 caddy validate --config /work/Caddyfile --adapter caddyfile
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、Caddyfile 做了什么
|
||||
|
||||
```
|
||||
:80 {
|
||||
root * /srv
|
||||
encode zstd gzip # gzip 压缩(CSS 54KB → 12KB)
|
||||
file_server
|
||||
try_files {path} {path}.html {path}/index.html # 无扩展名伪静态
|
||||
header { ... } # 安全响应头
|
||||
@html Cache-Control no-cache # HTML 不缓存,改版立刻生效
|
||||
@assets Cache-Control max-age=31536000 # 静态资源长缓存
|
||||
handle /healthz { respond "ok" 200 } # 健康检查
|
||||
handle_errors { ... } # 404 兜底回首页
|
||||
}
|
||||
```
|
||||
|
||||
**路由实测**(从外部网络访问,全部通过):
|
||||
|
||||
| 地址 | 结果 |
|
||||
|---|---|
|
||||
| `/` | 200,20,557 B |
|
||||
| `/brands`(无扩展名) | 200 |
|
||||
| `/brands.html` | 200 |
|
||||
| `/honors` `/cases` `/about` | 200 |
|
||||
| `/healthz` | 200 `ok` |
|
||||
| `/assets/css/main.css` | 200,gzip 后 12,772 B |
|
||||
| `/nope`(不存在) | 404 |
|
||||
|
||||
---
|
||||
|
||||
## 五、绑定域名(下一步,需要你操作)
|
||||
|
||||
站点现在只监听 8081,**公网域名要自己指过来**。两种做法:
|
||||
|
||||
### 做法 A:在 1Panel 里加反向代理(推荐,HTTPS 自动)
|
||||
|
||||
1. 确认域名已解析到 `10.0.0.131`
|
||||
2. 1Panel → 网站 → 创建网站 → 反向代理
|
||||
3. 目标填 `http://127.0.0.1:8081`
|
||||
4. 开启 HTTPS(1Panel 会自动申请证书)
|
||||
|
||||
因为 1Panel 的 Caddy 是 `network_mode: host`,它和本容器在同一网络命名空间,
|
||||
`127.0.0.1:8081` 可以直连。
|
||||
|
||||
### 做法 B:直接改 1Panel 的 Caddyfile
|
||||
|
||||
编辑 `/opt/1panel/apps/caddy/caddy/data/conf/Caddyfile`,加一个 site 块:
|
||||
|
||||
```caddyfile
|
||||
www.你的域名.info {
|
||||
reverse_proxy 127.0.0.1:8081
|
||||
}
|
||||
```
|
||||
|
||||
然后重启 1Panel 的 caddy 容器。**注意**:直接改这个文件会在 1Panel 界面里
|
||||
看不到该站点,后续在面板里改网站配置可能被覆盖,所以更推荐做法 A。
|
||||
|
||||
> 服务器上现有的 4 个站点(`www.xuanheng.info` → Halo 博客、`ai.xuanheng.info`、
|
||||
> `note.xuanheng.info`、`xh-pic.jrui.fun`)**本次部署完全没有改动**。
|
||||
|
||||
---
|
||||
|
||||
## 六、本次部署做过的事(可回溯)
|
||||
|
||||
1. 勘察服务器:CentOS 7.9 / Docker 25.0.3 / Compose v2.24.6 / 1Panel + Caddy 占 80、443
|
||||
2. 确认 `docker.1panel.live` 镜像站不可用,决定复用现成的 `caddy:2.11.2`
|
||||
3. 创建 `/www/wwwroot/xuanheng-site/` 及子目录,`chown 1000:1000` 给数据/配置/日志目录
|
||||
4. 站点 87 个文件(4.8 MB)用 tar + scp 上传(**注意**:PowerShell 管道会破坏
|
||||
tar 二进制流,必须落盘成 .tar 文件再 scp)
|
||||
5. `caddy validate` 校验配置 → 通过
|
||||
6. `docker compose up -d` 启动,映射 `0.0.0.0:8081->80`
|
||||
7. `caddy fmt --overwrite` 规范化配置格式(消除启动告警)
|
||||
8. 12 条路由 + 响应头 + gzip 全部实测通过
|
||||
9. 确认旧站端口 80 / 443 / 8090 / 3306 依然在监听,未受影响
|
||||
|
||||
---
|
||||
|
||||
## 七、遇到的问题与处理
|
||||
|
||||
| 问题 | 处理 |
|
||||
|---|---|
|
||||
| `docker pull nginx:alpine` 卡住 10 分钟 | 换用服务器已有的 `caddy:2.11.2` |
|
||||
| PowerShell 管道传 tar 流报 `tar: Skipping to next header` | 改为 `tar -cf 文件` + `scp` |
|
||||
| `docker run -v $PWD/Caddyfile:/etc/caddy/Caddyfile` 报 `invalid mode` | 挂整个目录,再执行 `caddy fmt /work/Caddyfile` |
|
||||
| ssh 传 heredoc 时中文括号被 bash 解析报错 | 含括号/中文的命令写进本地脚本,避免内联 |
|
||||
| 磁盘剩余仅 1.5 GB(89%) | 镜像复用,未拉新镜像;后续注意清理 `docker system prune` |
|
||||
|
||||
> 磁盘偏紧:`/` 已用 12G/14G。若要长期运行,建议清理旧镜像
|
||||
> (`docker system df` 显示可回收约 1.6 GB)。
|
||||
|
||||
---
|
||||
|
||||
## 八、回滚
|
||||
|
||||
本次部署**没有修改任何现有配置**,也没有动旧站目录,所以回滚就是停掉新容器:
|
||||
|
||||
```bash
|
||||
cd /www/wwwroot/xuanheng-site
|
||||
docker compose down
|
||||
# 如果要彻底删除:cd / && rm -rf /www/wwwroot/xuanheng-site
|
||||
```
|
||||
|
||||
旧站 `/www/wwwroot/10.0.0.131/` 原封未动。
|
||||
@@ -0,0 +1,59 @@
|
||||
# ==============================================================================
|
||||
# 上海炫恒网络系统有限公司 官网 —— 服务器部署编排
|
||||
#
|
||||
# 部署位置:/www/wwwroot/xuanheng-site/
|
||||
# 对外地址:http://<服务器IP>:8081
|
||||
# 反向代理:由 1Panel 的 Caddy 把域名指到本容器的 8081(HTTPS 在那一层处理)
|
||||
#
|
||||
# 常用命令(在 /www/wwwroot/xuanheng-site 下执行):
|
||||
# docker compose up -d 启动
|
||||
# docker compose logs -f 看日志
|
||||
# docker compose ps 看健康状况
|
||||
# docker compose restart 重启(改完 Caddyfile 用这个)
|
||||
# docker compose down 停止并删除容器
|
||||
#
|
||||
# 更新站点内容:直接覆盖 ./site/ 里的文件即可,无需重启容器。
|
||||
# 更新配置:改完 ./Caddyfile 后执行 docker compose restart。
|
||||
#
|
||||
# 说明:这里复用服务器上已有的 caddy:2.11.2 镜像(与 1Panel 同一套技术栈),
|
||||
# 不依赖 Docker Hub,避免网络问题导致部署失败。
|
||||
# ==============================================================================
|
||||
|
||||
services:
|
||||
web:
|
||||
image: caddy:2.11.2
|
||||
container_name: xuanheng-web
|
||||
restart: unless-stopped
|
||||
|
||||
ports:
|
||||
- "${SITE_PORT:-8081}:80"
|
||||
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
|
||||
volumes:
|
||||
# 站点文件(只读挂载;改内容直接覆盖宿主机文件即可,不用重启)
|
||||
- ./site:/srv:ro
|
||||
# 站点配置
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
# Caddy 自身数据(证书、状态),独立目录避免和 1Panel 的 Caddy 冲突
|
||||
- ./caddy-data:/data
|
||||
- ./caddy-config:/config
|
||||
# 日志也留一份在宿主机,方便排查
|
||||
- ./logs:/var/log/caddy
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "/usr/bin/caddy", "version"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
@@ -0,0 +1,49 @@
|
||||
# ==============================================================================
|
||||
# 上海炫恒网络系统有限公司 官网 —— Docker Compose 部署
|
||||
#
|
||||
# 用法:
|
||||
# docker compose up -d --build 构建并后台启动
|
||||
# docker compose logs -f 查看日志
|
||||
# docker compose ps 查看健康状态
|
||||
# docker compose down 停止并删除容器
|
||||
#
|
||||
# 改端口:在项目根目录建 .env 文件,写入 SITE_PORT=80
|
||||
# (默认 8080,即 http://localhost:8080)
|
||||
# ==============================================================================
|
||||
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: xuanheng-website:latest
|
||||
container_name: xuanheng-web
|
||||
|
||||
# 宿主机端口 : 容器端口(容器内 nginx 固定监听 80)
|
||||
# 想用 80 端口:把下面改成 "80:80",或建 .env 写 SITE_PORT=80
|
||||
ports:
|
||||
- "${SITE_PORT:-8080}:80"
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
|
||||
# 与 Dockerfile 里的 HEALTHCHECK 一致,写在这里便于查看
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/healthz"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
# 纯静态站点不需要额外权限,收紧容器权限
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
# 限制日志体积,避免长期运行把磁盘写满
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
@@ -0,0 +1,121 @@
|
||||
# ==============================================================================
|
||||
# 上海炫恒网络系统有限公司 官网 —— Nginx 站点配置
|
||||
#
|
||||
# 设计要点:
|
||||
# 1. /brands 这种「无扩展名地址」自动映射到 /brands.html,对外网址更干净
|
||||
# 2. 访问目录(如 /assets)由 nginx 自动 301 补结尾斜杠,保证相对路径正确
|
||||
# 3. HTML 不缓存、CSS/JS/图片长缓存 —— 改版后用户能立刻看到新页面
|
||||
# 4. 开启 gzip 与基础安全响应头
|
||||
# 5. /healthz 供 Docker 健康检查使用
|
||||
#
|
||||
# 注意:nginx 的 add_header 不会从上级继承到声明了 add_header 的 location,
|
||||
# 所以每个自定义 location 里都重复声明了安全响应头。
|
||||
# ==============================================================================
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
charset utf-8;
|
||||
server_tokens off;
|
||||
|
||||
access_log /var/log/nginx/access.log;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
|
||||
# ---- 健康检查(不写日志,避免污染) ----------------------------------
|
||||
location = /healthz {
|
||||
access_log off;
|
||||
default_type text/plain;
|
||||
return 200 "ok\n";
|
||||
}
|
||||
|
||||
# ---- favicon 单独处理 ------------------------------------------------
|
||||
location = /favicon.ico {
|
||||
access_log off;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public";
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# ---- 核心:无扩展名访问 + 目录索引 -----------------------------------
|
||||
# / -> index.html
|
||||
# /brands -> brands.html
|
||||
# /assets/x.js -> 直接命中文件
|
||||
location / {
|
||||
try_files $uri $uri.html $uri/index.html =404;
|
||||
# 未命中时不要缓存,避免 CDN / 浏览器记住错误页
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
}
|
||||
|
||||
# ---- 静态资源长缓存 --------------------------------------------------
|
||||
location ~* \.(?:css|js)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
access_log off;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ~* \.(?:webp|png|jpe?g|gif|svg|ico|avif)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
access_log off;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ~* \.(?:woff2?|ttf|eot|otf)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
access_log off;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ~* \.(?:mp4|webm)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public";
|
||||
access_log off;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# ---- HTML 不缓存,保证改版即时生效 -----------------------------------
|
||||
# 用 expires -1(即 Cache-Control: no-cache)而不是再叠加 add_header,
|
||||
# 避免与上面的安全响应头互相覆盖。
|
||||
location ~* \.html?$ {
|
||||
expires -1;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# ---- gzip 压缩 -------------------------------------------------------
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 256;
|
||||
gzip_proxied any;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/json
|
||||
application/xml
|
||||
image/svg+xml
|
||||
font/woff
|
||||
font/woff2;
|
||||
|
||||
# ---- 404 兜底:回到首页(纯静态站点,避免出现死链页面) --------------
|
||||
error_page 404 /index.html;
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>关于我们 | 上海炫恒网络系统有限公司</title>
|
||||
<meta name="description" content="上海炫恒网络系统有限公司成立于 2004 年,是专业从事计算机网络信息产业销售及系统集成的高科技企业,提供网络系统集成解决方案、信息安全解决方案与管理服务。">
|
||||
<meta name="theme-color" content="#ffc61a">
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="apple-touch-icon" href="assets/img/misc/favicon-180x180.png">
|
||||
<link rel="stylesheet" href="assets/lqip.css">
|
||||
<link rel="stylesheet" href="assets/css/main.css">
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip" href="#main">跳到主要内容</a>
|
||||
<div class="progress" aria-hidden="true"></div>
|
||||
|
||||
<header class="header">
|
||||
<div class="header__inner">
|
||||
<a class="brand" href="index.html" aria-label="上海炫恒网络系统有限公司 首页">
|
||||
<img class="brand__mark" src="assets/img/misc/logo-256.png" alt="" width="42" height="42">
|
||||
<span class="brand__text">
|
||||
<span class="brand__cn">炫恒网络</span>
|
||||
<span class="brand__en">Xuanheng Network</span>
|
||||
</span>
|
||||
</a>
|
||||
<nav class="nav" aria-label="主导航">
|
||||
<ul class="nav__list">
|
||||
<li><a class="nav__link" href="index.html">首页</a></li>
|
||||
<li><a class="nav__link" href="brands.html">合作品牌</a></li>
|
||||
<li><a class="nav__link" href="honors.html">荣誉资质</a></li>
|
||||
<li><a class="nav__link" href="cases.html">客户案例</a></li>
|
||||
<li><a class="nav__link" href="about.html" aria-current="page">关于我们</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<a class="btn btn--sm header__cta" href="#contact">联系我们 <i data-icon="arrowRight"></i></a>
|
||||
<button class="burger" type="button" aria-label="打开菜单" aria-expanded="false" aria-controls="drawer">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="drawer" id="drawer">
|
||||
<a class="drawer__link" href="index.html">首页 <i data-icon="arrowRight"></i></a>
|
||||
<a class="drawer__link" href="brands.html">合作品牌 <i data-icon="arrowRight"></i></a>
|
||||
<a class="drawer__link" href="honors.html">荣誉资质 <i data-icon="arrowRight"></i></a>
|
||||
<a class="drawer__link" href="cases.html">客户案例 <i data-icon="arrowRight"></i></a>
|
||||
<a class="drawer__link" href="about.html">关于我们 <i data-icon="arrowRight"></i></a>
|
||||
<div class="drawer__foot">
|
||||
<div class="drawer__contact"><b>021-51629599</b>webmaster@xuanheng.info</div>
|
||||
<a class="btn" href="#contact">免费咨询方案 <i data-icon="arrowRight"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main id="main">
|
||||
|
||||
<!-- ========================================================== 页头 -->
|
||||
<section class="pagehead">
|
||||
<div class="pagehead__bg" data-lqip="hero/hero-1" data-bg="assets/img/hero/hero-1.webp" aria-hidden="true"></div>
|
||||
<div class="wrap wrap--wide pagehead__inner">
|
||||
<nav class="crumb" aria-label="面包屑">
|
||||
<a href="index.html">首页</a><span class="sep">/</span><span aria-current="page">关于我们</span>
|
||||
</nav>
|
||||
<h1>关于炫恒</h1>
|
||||
<p>上海炫恒网络系统有限公司 —— 专业提供网络系统集成解决方案、信息安全解决方案以及管理服务。让先进的信息技术与网络技术,真正服务于客户的业务。</p>
|
||||
</div>
|
||||
<div class="pagehead__rule" aria-hidden="true"></div>
|
||||
</section>
|
||||
|
||||
<!-- ========================================================== 企业简介 -->
|
||||
<section class="section">
|
||||
<div class="wrap">
|
||||
<div class="split split--wide-left">
|
||||
<div data-reveal="left">
|
||||
<span class="eyebrow">Company Profile</span>
|
||||
<h2>企业简介</h2>
|
||||
<p>
|
||||
上海炫恒网络系统有限公司是一家专业从事计算机网络信息产业的销售及系统集成的高科技企业。
|
||||
公司致力于将先进的信息技术和网络技术运用到客户的管理与业务活动中,曾经参与合作过银行、证券、
|
||||
保险、政府机关、制造行业、商贸中心等行业的计算机系统建设。
|
||||
</p>
|
||||
<p>
|
||||
公司可以为客户提供网络前提分布系统、网络技术工程、系统安装、系统方案设计、全国外包服务、
|
||||
电脑及网络产品采购。公司专注于企业应用系统集成和专业全程跟踪维护服务;同时,我们还为客户提供
|
||||
专业、稳定、有效的外包服务 —— 从高质量的硬件配备到专业的软件开发,都可以满足客户的要求,
|
||||
确保客户满意。
|
||||
</p>
|
||||
<div class="grid grid--2" style="margin-top:26px">
|
||||
<div class="infoblock">
|
||||
<span class="infoblock__ico"><i data-icon="rocket"></i></span>
|
||||
<div><b>成立时间</b><span>2004 年,深耕企业级网络二十余年</span></div>
|
||||
</div>
|
||||
<div class="infoblock">
|
||||
<span class="infoblock__ico"><i data-icon="network"></i></span>
|
||||
<div><b>核心业务</b><span>网络系统集成 · 信息安全 · 管理服务</span></div>
|
||||
</div>
|
||||
<div class="infoblock">
|
||||
<span class="infoblock__ico"><i data-icon="mapPin"></i></span>
|
||||
<div><b>办公网络</b><span>上海总部,业务覆盖北京、南京等地</span></div>
|
||||
</div>
|
||||
<div class="infoblock">
|
||||
<span class="infoblock__ico"><i data-icon="headset"></i></span>
|
||||
<div><b>服务模式</b><span>方案设计 + 部署实施 + 全程运维</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-reveal="right">
|
||||
<div class="figure" data-lqip="hero/hero-3" style="aspect-ratio:4/5;background-size:cover">
|
||||
<img data-src="assets/img/hero/hero-3.webp" alt="炫恒网络企业形象" width="1200" height="1500" loading="lazy" decoding="async">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ========================================================== 发展历程 -->
|
||||
<section class="section section--alt">
|
||||
<div class="wrap">
|
||||
<div class="split" style="align-items:start">
|
||||
<div data-reveal="left">
|
||||
<span class="eyebrow">Milestones</span>
|
||||
<h2>一路走来</h2>
|
||||
<p>从区域渠道到行业白金,从产品销售到整体方案与运维服务,我们把每一步都走成了能力。</p>
|
||||
<div class="stats" style="margin-top:30px">
|
||||
<div class="stat"><b><span data-count="20" data-unit="+">0</span></b><span>深耕网络行业(年)</span></div>
|
||||
<div class="stat"><b><span data-count="2000" data-unit="+">0</span></b><span>累计交付项目</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-reveal="right">
|
||||
<div class="timeline">
|
||||
<div class="tl-item">
|
||||
<b>2004</b>
|
||||
<p>在上海、北京、南京成立总部和分公司,成为思科华东区核心渠道。</p>
|
||||
</div>
|
||||
<div class="tl-item">
|
||||
<b>2010</b>
|
||||
<p>开始面向用户提供完整的网络和统一通讯解决方案,致力于技术为核心的发展方向。</p>
|
||||
</div>
|
||||
<div class="tl-item">
|
||||
<b>2012</b>
|
||||
<p>进入上海政采供应商,面向医院用户提供订制一站式 IT 解决方案。</p>
|
||||
</div>
|
||||
<div class="tl-item">
|
||||
<b>2018</b>
|
||||
<p>成为锐捷网络行业白金,开始在商贸地产行业与锐捷的深度合作。</p>
|
||||
</div>
|
||||
<div class="tl-item">
|
||||
<b>2019</b>
|
||||
<p>成为锐捷网络企业市场商业白金、服务商。</p>
|
||||
</div>
|
||||
<div class="tl-item">
|
||||
<b>2020</b>
|
||||
<p>成为锐捷网络 SMB 核心分销商。</p>
|
||||
</div>
|
||||
<div class="tl-item">
|
||||
<b>2021 - 2022</b>
|
||||
<p>成为锐捷商业白金、行业白金,销量在行业中遥遥领先。</p>
|
||||
</div>
|
||||
<div class="tl-item">
|
||||
<b>至今</b>
|
||||
<p>持续在地产商旅、制造、金融、医疗、连锁零售等行业交付网络方案与运维服务。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ========================================================== 服务体系 -->
|
||||
<section class="section" id="service">
|
||||
<div class="wrap">
|
||||
<div class="blockhead" style="margin-left:auto;margin-right:auto;text-align:center" data-reveal>
|
||||
<span class="eyebrow">Service System</span>
|
||||
<h2>服务体系</h2>
|
||||
<p class="blockhead__desc">公司力求为客户提供优质、高效益、低成本的产品与服务。在成长的路上,感恩有您。</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid--2" style="gap:clamp(18px,2.6vw,32px)">
|
||||
<div data-reveal="left">
|
||||
<div class="card" style="padding:clamp(24px,2.8vw,36px);height:100%">
|
||||
<div class="card__icon"><i data-icon="target"></i></div>
|
||||
<h3>服务宗旨</h3>
|
||||
<p>
|
||||
提供崭新的 IT 应用技术服务。公司与全球知名 IT 厂商携手,力求成为理念领先、管理一流、
|
||||
专注于 IT 服务的高新技术企业。
|
||||
</p>
|
||||
<p>
|
||||
我们秉承「标准、诚实、负责、尊重、快速」的服务原则,凭借深厚的技术背景以及专业的服务团队,
|
||||
以领先的网络安全技术为基础,以细致、完善的服务为手段,以诚信服务客户为己任,
|
||||
致力成为中国最具规模和实力的共享网络服务平台。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-reveal="right">
|
||||
<div class="card" style="padding:clamp(24px,2.8vw,36px);height:100%">
|
||||
<div class="card__icon"><i data-icon="eye"></i></div>
|
||||
<h3>我们的愿望与任务</h3>
|
||||
<p><b>愿望:</b>不管在哪一天,同哪一位合作伙伴,我们都将不断超越期望,共创成功。</p>
|
||||
<p><b>任务:</b>成为中国最好的 IT 服务商,使客户、伙伴、员工获得最大价值。</p>
|
||||
<p style="margin-bottom:0">
|
||||
<b>承诺:</b>「优化网络结构、提高网络应用、带进前沿技术、改善服务流程、规避网络风险、
|
||||
跨越地域时空、服务 IT 中国」—— 这是我们对客户的承诺,更是我们的使命。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="blockhead" style="margin:clamp(44px,5vw,72px) auto clamp(26px,3vw,40px);text-align:center" data-reveal>
|
||||
<span class="eyebrow">Values</span>
|
||||
<h2>共同的价值观</h2>
|
||||
<p class="blockhead__desc">我们承诺以如下价值观,指导我们的决策和行为。</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid--auto">
|
||||
<article class="card">
|
||||
<span class="card__num">01</span>
|
||||
<div class="card__icon"><i data-icon="cert"></i></div>
|
||||
<h3>Standard 标准</h3>
|
||||
<p>基于 ITIL 服务管理体系,建立完整的服务模式,为客户提供专业服务。</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<span class="card__num">02</span>
|
||||
<div class="card__icon"><i data-icon="handshake"></i></div>
|
||||
<h3>Honest 诚实</h3>
|
||||
<p>始终努力去做正确的事,坚持以事实为基础,正确估计和认识风险。</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<span class="card__num">03</span>
|
||||
<div class="card__icon"><i data-icon="shield"></i></div>
|
||||
<h3>Accountability 负责</h3>
|
||||
<p>主动承担帮助客户成功的责任,不为短期的自身利益牺牲客户利益。</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<span class="card__num">04</span>
|
||||
<div class="card__icon"><i data-icon="users"></i></div>
|
||||
<h3>Respect 尊重</h3>
|
||||
<p>重视客户的企业文化和差异,满足客户的不同需求。</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<span class="card__num">05</span>
|
||||
<div class="card__icon"><i data-icon="rocket"></i></div>
|
||||
<h3>Express 快速</h3>
|
||||
<p>第一时间反应,健康有效处理,清晰完整反馈。</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ========================================================== 服务流程与内容 -->
|
||||
<section class="section section--alt">
|
||||
<div class="wrap">
|
||||
<div class="blockhead" data-reveal>
|
||||
<span class="eyebrow">Process</span>
|
||||
<h2>服务流程与内容</h2>
|
||||
<p class="blockhead__desc">把服务拆成可执行、可检查的动作,客户才知道我们每天在做什么。</p>
|
||||
</div>
|
||||
|
||||
<div class="flow">
|
||||
<div class="flow__item"><b>定期维护</b><p>按周期巡检设备、链路与无线环境,输出巡检报告与改进建议。</p></div>
|
||||
<div class="flow__item"><b>增值服务</b><p>网络优化、容量评估、版本升级与安全策略梳理,让网络跟上业务。</p></div>
|
||||
<div class="flow__item"><b>故障分级响应</b><p>按业务影响程度分级,对应不同响应时间与处理流程。</p></div>
|
||||
<div class="flow__item"><b>故障处理流程</b><p>受理、定位、处置、验证、回访,全流程记录可追溯。</p></div>
|
||||
<div class="flow__item"><b>服务质量管理</b><p>服务组织架构清晰、职责明确,定期复盘服务质量指标。</p></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid--3" style="margin-top:clamp(30px,3.6vw,50px)">
|
||||
<article class="card">
|
||||
<div class="card__icon"><i data-icon="layers"></i></div>
|
||||
<h3>服务组织架构</h3>
|
||||
<p>项目经理统筹,技术工程师分专业方向支撑,服务台统一受理与跟踪。</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<div class="card__icon"><i data-icon="settings"></i></div>
|
||||
<h3>服务内容</h3>
|
||||
<p>网络与安全设备维护、无线优化、故障处理、备件与升级支持、技术咨询。</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<div class="card__icon"><i data-icon="chart"></i></div>
|
||||
<h3>服务质量管理</h3>
|
||||
<p>响应及时率、故障解决率、客户满意度定期统计,持续改进服务过程。</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ========================================================== 联系我们 -->
|
||||
<section class="section" id="contact">
|
||||
<div class="wrap">
|
||||
<div class="split" style="align-items:start;gap:clamp(30px,4.4vw,60px)">
|
||||
<div data-reveal="left">
|
||||
<span class="eyebrow">Contact Us</span>
|
||||
<h2>联系我们</h2>
|
||||
<p>公司力求为客户提供优质、高效益、低成本的产品,服务更多客户。在成长的路上,感恩有您。</p>
|
||||
|
||||
<div class="grid" style="gap:14px;margin-top:26px">
|
||||
<div class="infoblock">
|
||||
<span class="infoblock__ico"><i data-icon="phone"></i></span>
|
||||
<div><b>联系电话 & 邮箱</b>
|
||||
<a href="tel:02151629599">021-51629599</a><br>
|
||||
<a href="mailto:webmaster@xuanheng.info">webmaster@xuanheng.info</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="infoblock">
|
||||
<span class="infoblock__ico"><i data-icon="mapPin"></i></span>
|
||||
<div><b>公司地址</b>
|
||||
<span>上海市普陀区中山北路 3323 号<br>春之声大厦 1501 室</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="infoblock">
|
||||
<span class="infoblock__ico"><i data-icon="clock"></i></span>
|
||||
<div><b>工作时间</b>
|
||||
<span>周一至周五 9:00 - 18:00<br>周六、周日及法定节假日休息</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="badge-list" style="margin-top:22px">
|
||||
<span class="badge"><i data-icon="spark"></i> 方案咨询</span>
|
||||
<span class="badge"><i data-icon="settings"></i> 设备维护</span>
|
||||
<span class="badge"><i data-icon="network"></i> 系统运维</span>
|
||||
<span class="badge"><i data-icon="lock"></i> 信息安全</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-reveal="right">
|
||||
<div class="card" style="padding:clamp(24px,2.8vw,38px);border-color:var(--y-200)">
|
||||
<h3 style="margin-bottom:6px">客户留言</h3>
|
||||
<p class="small" style="margin-bottom:22px">您可以在这里留下意见和建议,我们会及时回复。(<span class="req">*</span> 为必填项)</p>
|
||||
|
||||
<form class="form" data-validate novalidate>
|
||||
<div class="form__ok" role="status" aria-live="polite">
|
||||
<i data-icon="check" data-icon-class="ico--solid"></i>
|
||||
<span>提交成功,感谢您的留言!我们会在 1 个工作日内与您联系。</span>
|
||||
</div>
|
||||
|
||||
<div class="form__row">
|
||||
<div class="field">
|
||||
<label for="f-name">您的姓名 <span class="req">*</span></label>
|
||||
<input id="f-name" name="name" type="text" placeholder="请输入姓名" data-required="true" data-label="姓名" autocomplete="name">
|
||||
<span class="field__err"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="f-phone">联系电话 <span class="req">*</span></label>
|
||||
<input id="f-phone" name="phone" type="tel" placeholder="请输入手机号或座机" data-required="true" data-label="联系电话" autocomplete="tel">
|
||||
<span class="field__err"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form__row">
|
||||
<div class="field">
|
||||
<label for="f-mail">电子邮箱</label>
|
||||
<input id="f-mail" name="email" type="email" placeholder="name@company.com" autocomplete="email">
|
||||
<span class="field__err"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="f-company">公司名称</label>
|
||||
<input id="f-company" name="company" type="text" placeholder="请输入公司名称" autocomplete="organization">
|
||||
<span class="field__err"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="f-subject">咨询主题 <span class="req">*</span></label>
|
||||
<select id="f-subject" name="subject" data-required="true" data-label="咨询主题">
|
||||
<option value="">请选择咨询主题</option>
|
||||
<option>网络方案设计与实施</option>
|
||||
<option>无线网络覆盖优化</option>
|
||||
<option>信息安全与出口合规</option>
|
||||
<option>IT 运维与外包服务</option>
|
||||
<option>产品选型与采购</option>
|
||||
<option>其他问题</option>
|
||||
</select>
|
||||
<span class="field__err"></span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="f-msg">需求描述 <span class="req">*</span></label>
|
||||
<textarea id="f-msg" name="message" placeholder="请简单描述您的场景,例如场地规模、终端数量、现有网络问题等" data-required="true" data-label="需求描述"></textarea>
|
||||
<span class="field__err"></span>
|
||||
</div>
|
||||
|
||||
<p class="form__note">提交后我们仅将信息用于本次业务联系,不会对外提供。</p>
|
||||
<button class="btn btn--lg" type="submit">提交信息 <i data-icon="arrowRight"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="wrap">
|
||||
<div class="footer__grid">
|
||||
<div class="footer__col">
|
||||
<div class="footer__brand">
|
||||
<img class="brand__mark" src="assets/img/misc/logo-256.png" alt="" width="42" height="42">
|
||||
<span class="brand__text">
|
||||
<span class="brand__cn">炫恒网络</span>
|
||||
<span class="brand__en">Xuanheng Network</span>
|
||||
</span>
|
||||
</div>
|
||||
<p>上海炫恒网络系统有限公司<br>专业提供网络系统集成解决方案、信息安全解决方案以及管理服务。</p>
|
||||
</div>
|
||||
<div class="footer__col">
|
||||
<h4>关于我们</h4>
|
||||
<div class="footer__links">
|
||||
<a href="about.html">企业简介</a>
|
||||
<a href="brands.html">合作品牌</a>
|
||||
<a href="honors.html">荣誉资质</a>
|
||||
<a href="cases.html">客户案例</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer__col">
|
||||
<h4>发现更多</h4>
|
||||
<div class="footer__links">
|
||||
<a href="cases.html#realestate">地产商旅案例</a>
|
||||
<a href="cases.html#industry">工厂制造案例</a>
|
||||
<a href="cases.html#finance">金融行业案例</a>
|
||||
<a href="cases.html#medical">医疗行业案例</a>
|
||||
<a href="cases.html#retail">商业连锁案例</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer__col">
|
||||
<h4>联系我们</h4>
|
||||
<ul class="footer__contact">
|
||||
<li><i data-icon="mapPin"></i><span>上海市普陀区中山北路 3323 号<br>春之声大厦 1501 室</span></li>
|
||||
<li><i data-icon="phone"></i><a href="tel:02151629599">021-51629599</a></li>
|
||||
<li><i data-icon="mail"></i><a href="mailto:webmaster@xuanheng.info">webmaster@xuanheng.info</a></li>
|
||||
<li><i data-icon="clock"></i><span>周一至周五 9:00 - 18:00</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer__bottom">
|
||||
<span>Copyright © 2024 上海炫恒网络系统有限公司 <a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">沪ICP备19026453号</a></span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<button class="totop" type="button" aria-label="回到顶部"><i data-icon="arrowUp"></i></button>
|
||||
|
||||
<script src="assets/lqip.js"></script>
|
||||
<script src="assets/js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 387 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 32 KiB |