commit f81d41c5ca822e06d8d51d974c3b0bb5735d9da4 Author: dsh Date: Tue Oct 13 16:11:43 2026 +0800 初始化:炫恒网络官网(五个页面) 站点:纯静态 HTML + CSS + 原生 JS,零框架零构建 页面:首页 / 合作品牌 / 荣誉资质 / 客户案例 / 关于我们 内容: - site/ 站点本体(含已压缩 webp 图片,约 4.8 MB) - deploy/ Docker Compose / Caddy / Apache 部署配置 - _tools/ 开发自检脚本与部署脚本 - Dockerfile 自建 nginx 镜像方案 - README.md 项目文档 设计:品牌黄 #ffc107 + 白,深蓝仅用于页脚;菜单长期置顶; 交互动效只加在关键词与区块级元素上。 素材说明:原始素材(背景图/老网站/方案/荣誉资质/合作品牌,约 290 MB) 未纳入版本控制,站点使用的是 site/assets 内已处理压缩的图片。 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..eb50bd5 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0cbcb27 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b6f6279 --- /dev/null +++ b/Dockerfile @@ -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;"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..742c507 --- /dev/null +++ b/README.md @@ -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 +
品牌名
+``` + +**注意两点(踩过坑):** + +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` 的「客户留言」目前是**前端校验 + 成功提示,不发请求**。 +要真正收件,把 `
` 改成: + +```html + +``` + +并删掉 `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、锐捷) +版权归各自公司所有,仅用于说明合作关系。 diff --git a/_tools/add-logo-fit.js b/_tools/add-logo-fit.js new file mode 100644 index 0000000..6ba701f --- /dev/null +++ b/_tools/add-logo-fit.js @@ -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()'); diff --git a/_tools/audit-css.js b/_tools/audit-css.js new file mode 100644 index 0000000..8f60d12 --- /dev/null +++ b/_tools/audit-css.js @@ -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(', ') : '(无)')); diff --git a/_tools/brighten-inner.js b/_tools/brighten-inner.js new file mode 100644 index 0000000..c94cb3d --- /dev/null +++ b/_tools/brighten-inner.js @@ -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 = /
/g; +const darkBg = /
]*?)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 '
'; }); + + // 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(/

/g, () => { n++; return '

'; }); + + // 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(//g, () => { n++; return ''; }); + + // 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(/

]*?)style="opacity:\.\d+"\s*><\/div>/g, + (m, k, rest) => { n++; return '
'; }); + + // 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); +} diff --git a/_tools/brighten.js b/_tools/brighten.js new file mode 100644 index 0000000..4047e5b --- /dev/null +++ b/_tools/brighten.js @@ -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' : ' 不配对!')); diff --git a/_tools/brighten2.js b/_tools/brighten2.js new file mode 100644 index 0000000..0bd635a --- /dev/null +++ b/_tools/brighten2.js @@ -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 + ' 处'); diff --git a/_tools/build_assets.py b/_tools/build_assets.py new file mode 100644 index 0000000..ae85119 --- /dev/null +++ b/_tools/build_assets.py @@ -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() diff --git a/_tools/build_assets_v2.py b/_tools/build_assets_v2.py new file mode 100644 index 0000000..2a6852a --- /dev/null +++ b/_tools/build_assets_v2.py @@ -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() diff --git a/_tools/build_brands_v2.py b/_tools/build_brands_v2.py new file mode 100644 index 0000000..79488cc --- /dev/null +++ b/_tools/build_brands_v2.py @@ -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 已按视觉重量归一化") diff --git a/_tools/check-contrast.js b/_tools/check-contrast.js new file mode 100644 index 0000000..03e73ba --- /dev/null +++ b/_tools/check-contrast.js @@ -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(/