Commit bfe511a1 authored by xuchentao's avatar xuchentao

feat: initialize Yinzhuang corporate website

parents
# 本地开发配置:复制为 .env 后使用。
# 生产环境请生成独立密钥,并将数据目录改为仓库外的持久化绝对路径。
CMS_PORT=8791
CMS_PASSWORD=replace-with-a-strong-password
CMS_SECRET=replace-with-openssl-rand-hex-32
CMS_API_KEY=replace-with-openssl-rand-hex-32
CMS_DATA_DIR=.runtime/cms-data
CMS_BUILD_LOCK=.runtime/site-build.lock
node_modules/
dist/
.astro/
.codex-tmp/
.runtime/
.env
!.env.example
public/uploads/
.DS_Store
# 银妆官网 · GitLab CI/CD 安全部署
# 流程:幂等部署 → 配置 Nginx → 验证与清理
# 触发:push 到 main 分支
#
# ⚠️ GitLab 版本:11.7(较旧)
# - 不使用 workflow:rules
# - 不使用 rules
# - 不使用 needs/DAG
# - 不使用 resource_group
# - 不使用 cache:policy
# 仅使用 only、stages、script、when 等基础语法。
# Runner 注册时请使用标签:yinzhuang-prod
variables:
DEPLOY_ROOT: "/root/yinzhuang"
RELEASES_DIR: "/root/yinzhuang/releases"
SHARED_DIR: "/root/yinzhuang/shared"
SHARED_ENV: "/root/yinzhuang/shared/.env"
APP_NAME: "yinzhuang"
PORT: "8790"
SITE_URL: "http://127.0.0.1:8790/"
PM2_BIN: "/usr/bin/pm2"
PM2_HOME_DIR: "/root/.pm2"
PM2_USE_SUDO: "1"
PUBLIC_HOST: "101.126.10.129"
stages:
- deploy
- nginx
- verify
# CI_PIPELINE_ID 保证不同 pipeline 不会写同一个 release。同一 deploy job 被重试时,
# 会重新导出相同提交并执行 npm ci;若该 pipeline 已上线,则直接成功退出。
# ── 1. 幂等部署 ──────────────────────────────────────
# 创建 release、安装依赖和部署在同一个 job 中完成,不依赖跨 job 保留 node_modules。
deploy:
stage: deploy
tags:
- yinzhuang-prod
only:
- main
script:
- |
set -Eeuo pipefail
RELEASE_DIR="$RELEASES_DIR/${CI_COMMIT_SHA}-${CI_PIPELINE_ID}"
echo "[deploy] 提交:$CI_COMMIT_SHA"
echo "[deploy] 目标 Release:$RELEASE_DIR"
test -n "$CI_COMMIT_SHA" || (echo "CI_COMMIT_SHA 缺失" && exit 1)
test -n "$CI_PIPELINE_ID" || (echo "CI_PIPELINE_ID 缺失" && exit 1)
case "$CI_PIPELINE_ID" in
*[!0-9]*) echo "CI_PIPELINE_ID 必须是数字" >&2; exit 2 ;;
esac
test -f "$SHARED_ENV" || (echo "缺少生产配置:$SHARED_ENV" && exit 1)
test -x "$PM2_BIN" || (echo "PM2 不存在或不可执行:$PM2_BIN" && exit 1)
case "$RELEASE_DIR" in
"$RELEASES_DIR"/*) ;;
*) echo "release 路径越界" >&2; exit 2 ;;
esac
current_target=$(readlink -f "$DEPLOY_ROOT/current" 2>/dev/null || true)
if [ "$current_target" = "$RELEASE_DIR" ] && [ -f "$RELEASE_DIR/.deploy-success" ]; then
if [ -d "$RELEASE_DIR/node_modules" ] && curl --fail --silent --show-error --max-time 5 "$SITE_URL" >/dev/null; then
echo "[deploy] 该 pipeline 已部署且服务正常,无需重复执行"
exit 0
fi
echo "本次 release 已上线但内容不完整或服务异常,拒绝原地修改" >&2
exit 2
fi
if [ "$current_target" = "$RELEASE_DIR" ]; then
echo "current 已指向未标记成功的本次 release,拒绝原地修改" >&2
exit 2
fi
if [ -n "$current_target" ] && [ -f "$current_target/.pipeline-id" ]; then
current_pipeline_id=$(cat "$current_target/.pipeline-id")
case "$current_pipeline_id" in
*[!0-9]*|'') echo "当前版本的 pipeline 标识不合法" >&2; exit 2 ;;
esac
if [ "$current_pipeline_id" -gt "$CI_PIPELINE_ID" ]; then
echo "较新的 pipeline $current_pipeline_id 已部署,拒绝重试旧 pipeline $CI_PIPELINE_ID" >&2
exit 3
fi
fi
test ! -L "$RELEASE_DIR" || (echo "release 目录不能是软链接" && exit 2)
mkdir -p "$RELEASES_DIR" "$SHARED_DIR" "$RELEASE_DIR"
if [ -f "$RELEASE_DIR/.commit-sha" ] && [ "$(cat "$RELEASE_DIR/.commit-sha")" != "$CI_COMMIT_SHA" ]; then
echo "release 中的提交标识不匹配" >&2
exit 2
fi
if [ -f "$RELEASE_DIR/.pipeline-id" ] && [ "$(cat "$RELEASE_DIR/.pipeline-id")" != "$CI_PIPELINE_ID" ]; then
echo "release 中的 pipeline 标识不匹配" >&2
exit 2
fi
if [ -e "$RELEASE_DIR/.env" ] && [ ! -L "$RELEASE_DIR/.env" ]; then
echo "release 中的 .env 必须是软链接" >&2
exit 2
fi
git config --global --add safe.directory "$CI_PROJECT_DIR"
git archive "$CI_COMMIT_SHA" | tar -x -C "$RELEASE_DIR"
printf '%s\n' "$CI_COMMIT_SHA" > "$RELEASE_DIR/.commit-sha"
printf '%s\n' "$CI_PIPELINE_ID" > "$RELEASE_DIR/.pipeline-id"
ln -sfn "$SHARED_ENV" "$RELEASE_DIR/.env"
cd "$RELEASE_DIR"
echo "[deploy] 安装依赖"
npm ci
du -sh node_modules
echo "[deploy] 等待 CMS 共享构建锁"
node --env-file="$SHARED_ENV" scripts/run-with-site-lock.mjs -- bash scripts/deploy-release.sh "$DEPLOY_ROOT" "$RELEASE_DIR" "$APP_NAME" "$PM2_BIN" "$PM2_HOME_DIR" "$PM2_USE_SUDO" "$SITE_URL" "$CI_PIPELINE_ID"
echo "[deploy] 当前版本:$(readlink -f "$DEPLOY_ROOT/current")"
# ── 2. 安装并验证 Nginx 配置 ─────────────────────────
# 复制 current release 中的配置,检查语法后重新加载 Nginx。
configure_nginx:
stage: nginx
tags:
- yinzhuang-prod
only:
- main
script:
- echo "[nginx] 开始配置公网入口:$PUBLIC_HOST"
- test -f "$DEPLOY_ROOT/current/deploy/nginx/yinzhuang.conf" || (echo "缺少 Nginx 配置" && exit 2)
- sudo -n /usr/bin/install -o root -g root -m 0644 "$DEPLOY_ROOT/current/deploy/nginx/yinzhuang.conf" /etc/nginx/conf.d/yinzhuang.conf
- sudo -n /usr/sbin/nginx -t
- sudo -n /usr/bin/systemctl reload nginx
- 'curl --fail --silent --show-error --max-time 5 --header "Host: $PUBLIC_HOST" http://127.0.0.1/ >/dev/null'
- echo "[nginx] Nginx 配置与公网入口验证完成"
# ── 3. 验证并只保留当前 + 上一个成功版本 ─────────────
# when: always 用于输出最终状态;生产健康检查和自动回退已经在 deploy 的共享锁内完成。
verify:
stage: verify
tags:
- yinzhuang-prod
only:
- main
when: always
script:
- export RELEASE_DIR="$RELEASES_DIR/${CI_COMMIT_SHA}-${CI_PIPELINE_ID}"
- echo "[verify] 本次 Release:$RELEASE_DIR"
- echo "[verify] 当前指向:$(readlink -f "$DEPLOY_ROOT/current" 2>/dev/null || echo 未部署)"
- |
verify_status=0
current_target=$(readlink -f "$DEPLOY_ROOT/current" 2>/dev/null || true)
if [ "$current_target" = "$RELEASE_DIR" ]; then
if curl --fail --silent --show-error --max-time 5 "$SITE_URL" >/dev/null; then
echo "[verify] 服务运行正常:$SITE_URL"
else
echo "[verify] 服务无响应,输出本项目最近日志"
sudo -n env PM2_HOME="$PM2_HOME_DIR" "$PM2_BIN" logs "$APP_NAME" --lines 30 --nostream || true
verify_status=1
fi
else
echo "[verify] 本次版本未部署成功,线上 current 保持在其他版本"
fi
echo "[verify] PM2 当前运行项目:"
if ! sudo -n env PM2_HOME="$PM2_HOME_DIR" "$PM2_BIN" list; then
echo "[verify] PM2 项目列表读取失败"
verify_status=1
fi
bash "$CI_PROJECT_DIR/scripts/cleanup-releases.sh" "$DEPLOY_ROOT" "$RELEASE_DIR"
exit "$verify_status"
# 银妆银饰志后台
本项目采用与官网同端口的 Astro 文章后台:Markdown 文件负责内容存储,Astro 同时提供后台 API 和静态页面生成。文章列表、分类页和详情页全部预渲染为 HTML,便于 SEO 与 GEO 搜索。
## 本地启动
首次启动先创建本地配置:
```bash
cp .env.example .env
```
请至少替换其中的 `CMS_PASSWORD``CMS_SECRET``CMS_API_KEY`,不要在共享环境中使用示例占位值。
开发模式(Astro 默认端口,支持页面热更新与同端口后台):
```bash
npm run dev
```
- 官网:终端显示的开发地址(通常为 `http://localhost:4321/`
- 文章后台:在同一地址后添加 `/admin`(通常为 `http://localhost:4321/admin`
生产模式预览:
```bash
npm run build
npm start
```
- 官网:`http://localhost:8790/`
- 文章后台:`http://localhost:8790/admin`
- 银饰志:`http://localhost:8790/articles/`
启动命令会自动读取项目根目录的 `.env`,其中包含:
- `CMS_PORT`:官网与后台共用的服务端口,默认 `8790`(斯嘉丽项目端口 `8788` + 1)
- `CMS_PASSWORD`:后台登录密码
- `CMS_SECRET`:后台会话签名密钥
- `CMS_API_KEY`:外部程序调用管理 API 时使用的 Bearer Token
- `CMS_DATA_DIR`:生产环境的用户数据目录,建议设置为仓库外的绝对路径
- `CMS_BUILD_LOCK`:代码部署与文章发布共用的锁文件,生产环境各个 release 必须配置成同一路径
## 后台 API
所有管理接口统一使用 `/api/cms` 前缀:
| 分组 | 方法与地址 | 用途 |
| --- | --- | --- |
| 会话 | `GET /api/cms/session` | 查询登录状态 |
| 会话 | `POST /api/cms/session` | 登录后台 |
| 会话 | `DELETE /api/cms/session` | 退出登录 |
| 分类 | `GET /api/cms/categories` | 分类列表与文章数量 |
| 分类 | `POST /api/cms/categories` | 新建分类 |
| 分类 | `PATCH /api/cms/categories` | 重命名分类 |
| 分类 | `DELETE /api/cms/categories` | 删除分类并迁移文章 |
| 文章 | `GET /api/cms/articles` | 文章列表 |
| 文章 | `POST /api/cms/articles` | 新建草稿 |
| 文章 | `GET /api/cms/articles/:slug` | 读取文章 |
| 文章 | `PUT /api/cms/articles/:slug` | 保存文章 |
| 文章 | `DELETE /api/cms/articles/:slug` | 删除文章 |
| 文章 | `DELETE /api/cms/articles/:slug/draft` | 放弃未发布修改 |
| 发布 | `POST /api/cms/articles/:slug/publish` | 发布并生成静态页面 |
| 发布 | `POST /api/cms/articles/:slug/unpublish` | 下架并生成静态页面 |
| 工具 | `POST /api/cms/preview` | Markdown 预览 |
| 工具 | `POST /api/cms/uploads` | 上传文章图片 |
| 构建 | `GET /api/cms/build` | 查询静态构建状态 |
除会话登录接口外,外部程序可通过 `Authorization: Bearer <CMS_API_KEY>` 调用这些地址。
## 内容目录
- `src/content/articles/`:后台工作稿,包括草稿、待发布和已发布文章
- `src/content/published/`:Astro 实际构建的已发布文章
- `src/content/categories.json`:后台用户维护的文章分类
- `public/uploads/`:后台上传的文章图片
以上目录均属于运行时用户数据,已在 `.gitignore` 中排除,不随 Git 提交或 CI/CD 部署覆盖。部署时应由服务器持久化并单独备份。
后台始终维护两个版本:
- `articles/` 是当前编辑稿;新建文章和已上线文章的未发布修改都保存在这里。
- `published/` 是官网当前使用的线上稿;保存草稿不会改动它。
- 点击“发布到官网”后,编辑稿才会覆盖线上稿,并重新执行 `npm run build`
- 点击“下架文章”后,线上稿会移除,编辑稿继续保留为未发布草稿。
- 点击“删除文章”会同时删除编辑稿和线上稿;若官网构建失败,系统会自动恢复删除前的版本。
文章状态分为“新建未发布”“修改未发布”和“已上线”。文章网址、摘要、作者及 SEO 时间信息均由程序自动生成,编辑人员只需填写标题、分类和正文。
## 两条部署流水线
### 用户提交文章
1. 保存草稿只写入 `articles/`,不影响官网。
2. 发布、下架、删除文章或修改已使用的分类时,后台先取得共享构建锁。
如果代码部署或其他构建已持有锁,本次操作直接返回冲突且不会排队受理,编辑人员稍后重试即可,避免服务重启导致已受理任务丢失。
3. 系统备份用户内容,修改 `published/`,在临时目录执行 Astro 静态构建。
4. 构建成功后原子替换 `dist/`;构建失败则恢复文章和分类数据,旧的 `dist/` 保持不变。
### Git 代码部署
生产环境必须把用户数据放在仓库外,例如:
```env
CMS_DATA_DIR=/root/yinzhuang/shared/cms-data
CMS_BUILD_LOCK=/root/yinzhuang/shared/site-build.lock
```
CI/CD 应在新的 release 目录部署代码,不要在正在运行的目录执行 `git clean`。仓库中的 `.gitlab-ci.yml` 兼容 GitLab 11.7,分为幂等部署、配置 Nginx、验证三个阶段。创建 release、执行 `npm ci`、生产构建和切换都在同一个 deploy job 中完成,不依赖不同 job 之间保留 `node_modules`。同一 pipeline 重试时会重建缺失的依赖;如果该 release 已成功上线,则直接作为成功处理。最终生产构建、切换 current 软链接、重启 PM2 和健康检查在同一把共享锁内完成。应用健康检查通过后才复制 Nginx 配置、检查语法并 reload。健康检查失败时会自动切回上一版本。
```bash
node --env-file=/root/yinzhuang/shared/.env scripts/run-with-site-lock.mjs -- bash scripts/deploy-release.sh /root/yinzhuang /root/yinzhuang/releases/<release> yinzhuang /usr/bin/pm2 /root/.pm2 1 http://127.0.0.1:8790/
```
### GitLab CI/CD 配置
默认假设 GitLab Shell Runner 与生产服务位于同一台服务器,并使用以下路径:
- 部署根目录:`/root/yinzhuang`
- 持久化配置:`/root/yinzhuang/shared/.env`
- PM2 应用名:`yinzhuang`
- 服务端口:`8790`
- Nginx 配置:`/etc/nginx/conf.d/yinzhuang.conf`
- 公网入口:`101.126.10.129:80`
如果服务器实际路径或 PM2 应用名不同,只修改 `.gitlab-ci.yml` 顶部变量即可。脚本只会对 `APP_NAME` 指定的一个 PM2 进程执行 `describe``delete``start`,不会停止、重启或保存其他 PM2 项目。
服务器需提前创建 `shared/.env`,至少配置 `CMS_DATA_DIR``CMS_BUILD_LOCK``CMS_PASSWORD``CMS_SECRET``CMS_API_KEY``CMS_PORT`。其中建议使用:
```env
CMS_DATA_DIR=/root/yinzhuang/shared/cms-data
CMS_BUILD_LOCK=/root/yinzhuang/shared/site-build.lock
CMS_PORT=8790
```
首次运行 pipeline 前,需要允许 Runner 执行参数固定的 Nginx 安装、检查和 reload 命令:
```bash
sudo visudo -f /etc/sudoers.d/yinzhuang-runner
```
`/etc/sudoers.d/yinzhuang-runner` 内容:
```sudoers
Cmnd_Alias YINZHUANG_NGINX = \
/usr/bin/install -o root -g root -m 0644 /root/yinzhuang/current/deploy/nginx/yinzhuang.conf /etc/nginx/conf.d/yinzhuang.conf, \
/usr/sbin/nginx -t, \
/usr/bin/systemctl reload nginx
gitlab-runner ALL=(root) NOPASSWD: YINZHUANG_NGINX
```
保存后执行 `sudo visudo -cf /etc/sudoers.d/yinzhuang-runner` 检查语法。CI 只会从 `/root/yinzhuang/current` 读取本项目配置,并只会更新 `/etc/nginx/conf.d/yinzhuang.conf`
多个 GitLab pipeline 即使同时进入部署阶段,也会由服务器共享锁依次执行;部署持锁时,文章后台不会排队受理发布操作,而会明确提示编辑人员稍后重试。
验证阶段会输出 PM2 当前运行的全部项目,并只保留当前版本和上一个成功版本。清理脚本不会删除其他 pipeline 尚未完成的 release;本 pipeline 失败产生的目录会在自己的验证阶段清理。即使失败目录已被清理,重试 deploy job 也会重新创建 release 和依赖。若 Runner 被强制终止导致验证阶段未执行,可能留下一个未完成目录,但不会影响线上版本,可在确认没有对应 pipeline 运行后手动删除。
普通本地构建或不包含 release 切换的 CI 可以直接运行 `npm run build`,它会自行取得同一把锁。这样两条流水线不会同时读取或覆盖文章数据,也不会同时替换静态网站。
# 银妆企业官网
长沙银妆文化发展有限公司官方网站。项目基于 Astro 5 + Node standalone adapter,包含品牌官网、产品轮播、响应式页面、银饰志内容后台、SEO、原子构建与 GitLab/Nginx 部署配置。
## 本地运行
```bash
npm install
npm run dev
```
- 官网开发地址:`http://localhost:4321/`
- 内容后台:`http://localhost:4321/admin/`
生产构建与本地服务:
```bash
npm run build
npm start
```
生产模式默认监听 `http://127.0.0.1:8790/`
## 常用命令
```bash
npm run check # Astro / TypeScript 检查
npm run build # 带共享锁的原子生产构建
npm start # 启动 standalone 服务
```
## 内容管理
银饰志使用 Markdown 存储,并由同端口 `/admin/` 后台管理。工作稿位于 `src/content/articles/`,线上稿位于 `src/content/published/`;生产环境可通过 `CMS_DATA_DIR` 将内容持久化到仓库外。发布、下架、删除和分类修改会触发带共享锁的静态页面重建。
完整后台与部署说明见 [CMS_README.md](./CMS_README.md)
## 部署
- Astro standalone 输出:`dist/server/``dist/client/`
- GitLab CI:`.gitlab-ci.yml`
- Nginx 配置:`deploy/nginx/yinzhuang.conf`
- 生产端口:`8790`
- 站点域名:`www.inzung.cn`
部署前请在服务器创建独立 `.env`,替换 `CMS_PASSWORD``CMS_SECRET``CMS_API_KEY`,并按实际环境调整部署路径、域名和 Runner 标签。
import { defineConfig } from "astro/config";
import sitemap from "@astrojs/sitemap";
import node from "@astrojs/node";
import path from "node:path";
const customOutDir = process.env.YINZHUANG_BUILD_OUT_DIR;
export default defineConfig({
site: "https://www.inzung.cn",
integrations: [sitemap()],
adapter: node({ mode: "standalone" }),
...(customOutDir ? { outDir: path.resolve(customOutDir) } : {}),
build: { format: "directory" },
trailingSlash: "ignore",
});
# Nginx 单端口多项目部署方案
## 目标
在同一台服务器、同一个公网 IP 上,让多个项目共同使用 `80` 和后续的 `443` 端口,同时保持项目之间互不覆盖。
本方案采用:
- 每个项目使用独立的内部端口;
- 每个项目使用独立的 Nginx `.conf` 文件;
- Nginx 根据请求的主机名 `server_name` 将流量转发到对应项目;
- 项目始终部署在网站根路径 `/`,不使用 `/a/``/b/` 这类子路径,避免修改应用路由、静态资源路径和后台地址。
当前银妆项目的内部服务地址为:
```text
127.0.0.1:8790
```
## 请求链路
临时展示阶段:
```text
http://yinzhuang.101-126-10-129.sslip.io
→ Nginx 80
→ 127.0.0.1:8790
→ 银妆项目
```
其他项目使用各自的临时主机名和内部端口,例如:
```text
http://project-b.101-126-10-129.sslip.io
→ Nginx 80
→ 127.0.0.1:8790
→ Project B
```
`sslip.io` 会把主机名中包含的 IP 自动解析到对应服务器,无需提前购买域名。它只适合临时开发和展示,不应作为正式生产域名。
## 一个项目一个配置文件
服务器配置目录示例:
```text
/etc/nginx/conf.d/
├── yinzhuang.conf
├── project-b.conf
└── other-project.conf
```
每个项目只维护自己的配置文件,不修改其他项目的配置。
银妆项目使用:
```text
/etc/nginx/conf.d/yinzhuang.conf
```
仓库中的配置源文件为:
```text
deploy/nginx/yinzhuang.conf
```
现有 GitLab CI 会把该文件安装到服务器的 `/etc/nginx/conf.d/yinzhuang.conf`。因此,银妆的 Nginx 修改必须提交到仓库,不能只在服务器上手工修改,否则后续部署会将手工修改覆盖。
## 临时 HTTP 配置
银妆项目可使用以下配置:
```nginx
server {
listen 80;
server_name yinzhuang.101-126-10-129.sslip.io;
client_max_body_size 12m;
location / {
proxy_pass http://127.0.0.1:8790;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
}
```
另一个项目应在自己的 `.conf` 文件中配置,例如:
```nginx
server {
listen 80;
server_name project-b.101-126-10-129.sslip.io;
location / {
proxy_pass http://127.0.0.1:8790;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
}
```
虽然多个配置都声明了 `listen 80`,但 Nginx 会根据不同的 `server_name` 选择对应项目,因此不会产生端口冲突。
## 配置约束
- 每个项目必须使用不同的 `server_name`
- 每个应用必须使用不同的内部端口。
- 不要在多个配置文件中重复声明相同的 `server_name`
- 整台服务器最多保留一个 `default_server`,不要让每个项目都成为默认站点。
- Node/Astro 服务应只监听 `127.0.0.1`,不直接向公网开放内部端口。
- 云服务器安全组只需对公网开放 `80``443``8790` 等应用端口不应对公网开放。
## 应用和检查配置
修改配置后执行:
```bash
sudo nginx -t
sudo systemctl reload nginx
```
检查临时主机名是否解析到当前服务器:
```bash
dig +short yinzhuang.101-126-10-129.sslip.io A
```
预期返回:
```text
101.126.10.129
```
检查 HTTP 入口:
```bash
curl -I http://yinzhuang.101-126-10-129.sslip.io/
```
检查应用内部端口:
```bash
curl -I http://127.0.0.1:8790/
```
如果内部端口正常、临时主机名异常,应优先检查 Nginx 配置、安全组和防火墙。
## 正式域名上线
拿到正式域名后,不需要修改应用内部端口,也不需要把项目改成子路径部署。
外部人员需要完成:
1. 提供最终域名,例如 `example.com`
2. 将域名的 DNS 解析到服务器公网 IP `101.126.10.129`
3. 如果服务器位于中国大陆,完成所需的备案和接入手续。
后端部署人员需要完成:
1.`server_name` 从临时主机名替换为正式域名;
2. 确认正式域名通过 HTTP 可以访问当前项目;
3. 在服务器申请正式域名的 HTTPS 证书;
4. 在仓库的 Nginx 配置中增加 `443 ssl`,并让 `80` 跳转到 HTTPS;
5. 更新项目中的站点规范地址,例如 Astro 的 `site` 配置;
6. 提交配置并通过现有 CI 部署。
正式域名的 HTTP 配置示例:
```nginx
server {
listen 80;
server_name example.com www.example.com;
location / {
proxy_pass http://127.0.0.1:8790;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
}
```
## 正式 HTTPS 配置
DNS 生效并确认 HTTP 可访问后,可在服务器申请证书:
```bash
sudo certbot certonly --nginx -d example.com -d www.example.com
```
证书通常保存在:
```text
/etc/letsencrypt/live/example.com/fullchain.pem
/etc/letsencrypt/live/example.com/privkey.pem
```
证书和私钥只保存在服务器,不提交到 Git。仓库只保存证书路径引用。
最终 Nginx 配置示例:
```nginx
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
client_max_body_size 12m;
location / {
proxy_pass http://127.0.0.1:8790;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
}
```
多个项目可以共同使用服务器的 `443` 端口。Nginx 会根据域名选择对应的 `server` 块和证书,不会影响已经部署的其他 HTTPS 网站。
申请证书后检查自动续期:
```bash
sudo certbot renew --dry-run
sudo systemctl status certbot.timer
```
## 从临时地址迁移到正式域名时的最小改动
采用本方案后,正式上线主要只修改:
1. Nginx 的 `server_name`
2. Nginx 的 HTTPS 证书路径和 HTTP 跳转;
3. GitLab CI 中用于入口验证的 `PUBLIC_HOST`
4. Astro 的正式站点地址 `site`(仅当最终域名与现有配置不一致时)。
应用仍然运行在 `127.0.0.1:8790`,页面路由、静态资源、后台地址和文章地址不需要因为域名迁移而修改。
# 银妆官网 Nginx 配置
# 通过服务器 IP 访问,反向代理到本机 8790 端口。
server {
listen 80;
server_name inzung.cn www.inzung.cn 101.126.10.129;
# 图片上限为 20MB,JSON 中的 Base64 数据会额外增大请求体。
client_max_body_size 28m;
location / {
proxy_pass http://127.0.0.1:8790;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
}
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "yinzhuang-official-site",
"type": "module",
"version": "1.0.0",
"private": true,
"engines": {
"node": ">=20.20.0 <21"
},
"scripts": {
"dev": "node --env-file=.env ./node_modules/astro/astro.js dev",
"build": "node scripts/build-site.mjs",
"build:inside-lock": "node scripts/build-site.mjs --inside-lock",
"build:astro": "astro check && astro build",
"preview": "astro preview",
"check": "astro check",
"start": "node --env-file=.env server.mjs",
"admin": "npm start",
"serve": "npm run build && npm start"
},
"dependencies": {
"@astrojs/node": "9.5.5",
"@astrojs/sitemap": "^3.7.3",
"astro": "5.18.2",
"gray-matter": "^4.0.3",
"marked": "^14.1.4"
},
"devDependencies": {
"@astrojs/check": "^0.9.6",
"typescript": "^5.9.3"
}
}
<svg xmlns="http://www.w3.org/2000/svg" width="1197" height="401" viewBox="0 0 1197 401">
<title>银妆 INZUNG</title>
<path d="M 577 309 L 573 313 L 571 317 L 571 319 L 570 320 L 570 324 L 569 325 L 569 331 L 570 332 L 570 336 L 571 337 L 572 342 L 574 344 L 574 345 L 575 346 L 593 346 L 593 326 L 585 326 L 585 336 L 584 337 L 581 337 L 579 335 L 579 333 L 578 332 L 578 325 L 580 321 L 582 319 L 586 317 L 592 317 L 596 319 L 598 321 L 600 325 L 600 332 L 597 337 L 597 339 L 601 343 L 602 345 L 606 341 L 607 339 L 607 337 L 608 336 L 608 332 L 609 331 L 609 326 L 608 325 L 608 320 L 605 314 L 603 312 L 603 311 L 602 311 L 599 308 L 597 307 L 595 307 L 594 306 L 584 306 L 583 307 L 581 307 Z M 374 260 L 375 259 L 377 260 L 376 261 Z M 946 251 L 947 250 L 949 251 L 948 252 Z M 570 251 L 570 262 L 588 262 L 589 263 L 582 269 L 581 269 L 577 273 L 576 273 L 572 277 L 570 278 L 570 288 L 608 288 L 608 277 L 590 277 L 589 276 L 597 269 L 598 269 L 602 265 L 603 265 L 606 262 L 608 261 L 608 251 Z M 42 258 L 40 262 L 40 272 L 41 273 L 41 275 L 43 277 L 45 281 L 53 289 L 54 289 L 58 293 L 59 293 L 64 297 L 80 305 L 83 305 L 84 306 L 88 306 L 89 307 L 91 306 L 94 306 L 98 302 L 98 299 L 99 298 L 99 295 L 98 294 L 98 292 L 97 290 L 88 281 L 88 280 L 84 276 L 84 275 L 78 267 L 74 259 L 74 257 L 72 253 L 72 247 L 68 249 L 65 252 L 64 251 L 57 251 L 56 252 L 51 252 L 50 253 L 48 253 Z M 778 236 L 779 235 L 780 236 L 779 237 Z M 202 237 L 198 235 L 192 235 L 191 234 L 190 235 L 183 235 L 177 231 L 177 240 L 176 241 L 176 244 L 173 250 L 173 252 L 169 260 L 167 262 L 166 265 L 164 267 L 164 268 L 162 270 L 160 274 L 156 278 L 156 287 L 159 291 L 161 291 L 162 292 L 165 292 L 166 291 L 171 290 L 173 288 L 178 286 L 180 284 L 181 284 L 183 282 L 187 280 L 190 277 L 191 277 L 202 266 L 202 265 L 204 263 L 207 257 L 207 253 L 208 252 L 208 249 L 207 248 L 207 244 L 205 240 Z M 283 214 L 283 215 L 281 217 L 280 222 L 277 225 L 276 225 L 273 228 L 272 228 L 272 229 L 275 230 L 277 232 L 278 232 L 280 234 L 281 234 L 283 236 L 287 238 L 290 241 L 291 241 L 294 244 L 295 244 L 298 247 L 299 247 L 304 252 L 305 252 L 321 268 L 321 269 L 338 286 L 338 287 L 351 300 L 351 301 L 380 331 L 381 331 L 391 341 L 392 341 L 400 348 L 401 348 L 406 352 L 416 357 L 418 357 L 422 359 L 426 359 L 427 360 L 437 360 L 438 359 L 442 359 L 443 358 L 445 358 L 446 357 L 451 356 L 455 353 L 456 353 L 458 351 L 459 351 L 466 344 L 466 343 L 470 338 L 470 336 L 472 333 L 472 331 L 473 330 L 473 327 L 474 326 L 474 319 L 475 317 L 488 305 L 489 305 L 496 298 L 498 297 L 498 296 L 496 297 L 479 297 L 478 296 L 470 296 L 469 295 L 463 295 L 462 294 L 453 293 L 452 292 L 449 292 L 448 291 L 441 290 L 437 288 L 434 288 L 433 287 L 431 287 L 430 286 L 425 285 L 422 283 L 417 282 L 414 280 L 412 280 L 407 277 L 405 277 L 383 266 L 381 264 L 378 263 L 376 261 L 377 260 L 379 260 L 383 262 L 394 262 L 395 261 L 399 261 L 400 260 L 404 260 L 405 259 L 409 259 L 410 258 L 413 258 L 414 257 L 416 257 L 417 256 L 419 256 L 420 255 L 422 255 L 423 254 L 428 253 L 436 249 L 443 242 L 445 238 L 445 235 L 446 234 L 446 226 L 445 225 L 445 223 L 444 222 L 444 220 L 443 218 L 440 215 L 439 213 L 438 213 L 433 209 L 431 209 L 428 207 L 425 207 L 422 204 L 417 194 L 416 194 L 416 196 L 415 197 L 415 199 L 414 201 L 412 203 L 410 207 L 407 210 L 407 211 L 394 224 L 393 224 L 384 232 L 383 232 L 381 234 L 380 234 L 369 242 L 366 243 L 364 245 L 358 248 L 356 248 L 354 246 L 353 246 L 349 242 L 348 242 L 340 234 L 339 234 L 320 216 L 319 216 L 315 212 L 312 211 L 308 208 L 306 208 L 305 207 L 293 207 L 287 210 Z M 574 197 L 574 198 L 572 200 L 570 204 L 570 211 L 569 212 L 570 214 L 570 220 L 573 226 L 576 229 L 580 231 L 608 231 L 608 220 L 584 220 L 582 219 L 579 216 L 579 214 L 578 213 L 579 208 L 582 205 L 584 205 L 585 204 L 608 204 L 608 193 L 582 193 Z M 570 142 L 570 176 L 579 176 L 579 159 L 580 158 L 582 160 L 586 162 L 589 165 L 590 165 L 593 168 L 597 170 L 603 175 L 608 175 L 608 143 L 599 143 L 599 158 L 598 159 L 596 158 L 593 155 L 592 155 L 589 152 L 585 150 L 582 147 L 581 147 L 575 142 Z M 187 132 L 165 132 L 164 133 L 144 133 L 143 134 L 127 134 L 126 135 L 115 135 L 114 136 L 104 136 L 103 137 L 92 137 L 91 138 L 64 138 L 65 140 L 65 142 L 69 149 L 66 155 L 66 163 L 67 164 L 67 166 L 68 168 L 73 173 L 79 176 L 86 176 L 87 175 L 89 175 L 90 174 L 93 174 L 94 173 L 99 173 L 100 172 L 105 172 L 106 171 L 109 171 L 110 172 L 110 180 L 109 181 L 109 190 L 105 192 L 93 192 L 92 193 L 81 193 L 80 194 L 69 194 L 68 195 L 57 195 L 56 196 L 34 196 L 32 195 L 32 197 L 36 205 L 36 209 L 35 210 L 35 217 L 36 218 L 36 221 L 37 223 L 43 229 L 49 232 L 51 232 L 52 233 L 63 233 L 64 232 L 67 232 L 68 231 L 72 231 L 73 230 L 79 230 L 80 229 L 88 229 L 89 228 L 99 228 L 100 227 L 107 227 L 108 228 L 108 258 L 107 259 L 107 298 L 106 299 L 106 310 L 105 311 L 101 311 L 100 312 L 97 312 L 96 313 L 93 313 L 92 314 L 88 314 L 87 315 L 84 315 L 83 316 L 80 316 L 79 317 L 75 317 L 74 318 L 71 318 L 70 319 L 66 319 L 65 320 L 62 320 L 61 321 L 57 321 L 56 322 L 52 322 L 51 323 L 47 323 L 46 324 L 40 324 L 39 325 L 22 325 L 22 326 L 35 340 L 35 342 L 34 343 L 34 351 L 35 352 L 35 355 L 37 359 L 42 364 L 46 366 L 49 366 L 50 367 L 60 367 L 61 366 L 65 366 L 66 365 L 69 365 L 70 364 L 72 364 L 84 358 L 86 358 L 87 357 L 89 357 L 90 356 L 92 356 L 93 355 L 95 355 L 99 353 L 102 353 L 103 352 L 106 352 L 110 350 L 113 350 L 114 349 L 117 349 L 118 348 L 122 348 L 123 347 L 126 347 L 127 346 L 130 346 L 131 345 L 134 345 L 135 344 L 139 344 L 140 343 L 143 343 L 144 342 L 148 342 L 149 341 L 153 341 L 154 340 L 159 340 L 160 339 L 168 339 L 169 338 L 177 338 L 178 339 L 192 339 L 193 338 L 195 338 L 201 335 L 208 328 L 208 327 L 210 325 L 211 323 L 211 321 L 212 320 L 212 310 L 207 300 L 205 298 L 204 298 L 200 295 L 198 295 L 197 294 L 188 294 L 187 295 L 179 295 L 178 296 L 173 296 L 172 297 L 167 297 L 166 298 L 161 298 L 160 299 L 155 299 L 154 300 L 152 300 L 151 299 L 151 231 L 152 230 L 152 225 L 153 224 L 160 224 L 161 223 L 187 223 L 188 224 L 198 224 L 199 223 L 204 222 L 206 220 L 210 218 L 214 213 L 215 211 L 215 209 L 216 208 L 216 201 L 215 200 L 215 197 L 213 193 L 206 187 L 203 187 L 202 186 L 191 186 L 190 187 L 171 187 L 170 188 L 154 188 L 153 187 L 153 181 L 154 180 L 154 172 L 156 168 L 176 168 L 177 169 L 187 169 L 191 167 L 196 162 L 198 158 L 198 156 L 199 155 L 199 149 L 198 148 L 197 143 L 195 141 L 195 140 Z M 351 130 L 351 134 L 350 135 L 350 139 L 349 140 L 349 144 L 348 145 L 348 147 L 344 150 L 340 150 L 339 151 L 335 151 L 334 152 L 326 152 L 325 153 L 313 153 L 312 154 L 299 154 L 298 155 L 278 155 L 277 154 L 272 154 L 271 153 L 271 136 L 275 134 L 278 134 L 279 133 L 283 133 L 284 132 L 287 132 L 288 131 L 293 131 L 294 130 L 299 130 L 300 129 L 307 129 L 308 128 L 325 128 L 326 127 L 330 127 L 331 128 L 348 128 Z M 570 87 L 570 98 L 588 98 L 589 99 L 582 105 L 581 105 L 577 109 L 576 109 L 572 113 L 570 114 L 570 124 L 608 124 L 608 113 L 590 113 L 589 112 L 592 109 L 593 109 L 596 106 L 597 106 L 601 102 L 602 102 L 606 98 L 608 97 L 608 87 Z M 359 76 L 359 80 L 358 81 L 358 87 L 357 88 L 357 90 L 351 93 L 348 93 L 347 94 L 344 94 L 343 95 L 339 95 L 338 96 L 333 96 L 332 97 L 324 97 L 323 98 L 311 98 L 310 99 L 298 99 L 297 100 L 273 100 L 271 98 L 271 80 L 275 78 L 278 78 L 279 77 L 283 77 L 284 76 L 288 76 L 289 75 L 294 75 L 295 74 L 302 74 L 303 73 L 316 73 L 317 72 L 331 72 L 332 73 L 345 73 L 346 74 L 353 74 L 354 75 L 358 75 Z M 570 56 L 570 67 L 608 67 L 608 56 Z M 407 27 L 405 27 L 404 26 L 401 26 L 400 25 L 396 25 L 395 24 L 380 24 L 379 25 L 375 25 L 374 26 L 372 26 L 364 30 L 362 32 L 358 34 L 356 34 L 353 36 L 351 36 L 347 38 L 344 38 L 343 39 L 340 39 L 339 40 L 335 40 L 334 41 L 329 41 L 328 42 L 319 42 L 318 43 L 306 43 L 305 44 L 293 44 L 292 45 L 275 45 L 274 44 L 274 42 L 275 41 L 275 33 L 274 32 L 274 30 L 273 28 L 270 25 L 264 22 L 261 22 L 260 21 L 244 21 L 243 22 L 238 23 L 232 26 L 230 28 L 229 28 L 222 35 L 221 37 L 221 39 L 226 47 L 226 50 L 227 51 L 227 60 L 228 61 L 228 137 L 227 138 L 227 182 L 226 183 L 226 214 L 225 215 L 225 239 L 224 240 L 224 260 L 223 261 L 223 276 L 222 277 L 222 288 L 221 289 L 221 298 L 220 299 L 220 305 L 219 306 L 219 311 L 218 312 L 218 317 L 217 318 L 217 322 L 216 323 L 215 330 L 214 331 L 214 333 L 213 334 L 212 339 L 210 342 L 210 344 L 207 348 L 207 349 L 204 352 L 204 353 L 216 363 L 217 363 L 228 371 L 231 372 L 233 374 L 241 378 L 244 378 L 245 379 L 255 379 L 256 378 L 259 378 L 263 376 L 270 370 L 270 369 L 274 364 L 280 361 L 282 359 L 283 359 L 291 353 L 294 352 L 299 348 L 302 347 L 304 345 L 326 334 L 330 330 L 332 329 L 332 328 L 337 322 L 338 320 L 338 318 L 339 317 L 339 312 L 340 311 L 340 307 L 339 306 L 339 301 L 338 300 L 337 295 L 333 295 L 329 297 L 326 297 L 325 298 L 318 299 L 314 301 L 311 301 L 310 302 L 307 302 L 306 303 L 299 304 L 295 306 L 292 306 L 291 307 L 288 307 L 287 308 L 284 308 L 283 309 L 280 309 L 279 310 L 276 310 L 275 311 L 273 311 L 271 308 L 271 254 L 270 253 L 270 192 L 271 191 L 274 191 L 278 189 L 281 189 L 282 188 L 285 188 L 286 187 L 290 187 L 291 186 L 295 186 L 296 185 L 301 185 L 302 184 L 309 184 L 310 183 L 326 183 L 327 182 L 330 182 L 331 183 L 335 183 L 337 184 L 340 187 L 341 187 L 343 189 L 344 189 L 355 197 L 358 198 L 360 200 L 364 202 L 366 202 L 367 203 L 370 203 L 371 204 L 386 204 L 387 203 L 389 203 L 395 200 L 401 194 L 401 192 L 402 191 L 402 181 L 399 175 L 399 173 L 398 172 L 398 169 L 397 168 L 397 144 L 398 143 L 398 135 L 399 134 L 399 127 L 400 126 L 400 120 L 401 119 L 401 112 L 402 111 L 402 105 L 403 104 L 403 99 L 404 98 L 404 92 L 405 91 L 405 85 L 406 84 L 406 79 L 407 78 L 407 73 L 408 72 L 408 68 L 409 67 L 409 63 L 410 62 L 411 55 L 412 54 L 412 52 L 417 42 L 417 37 L 416 36 L 416 34 L 411 29 Z M 222 67 L 221 66 L 215 66 L 214 65 L 210 65 L 209 64 L 205 64 L 204 63 L 200 63 L 199 62 L 196 62 L 192 60 L 189 60 L 188 59 L 186 59 L 185 58 L 180 57 L 175 54 L 173 54 L 167 51 L 165 49 L 162 48 L 160 46 L 161 44 L 161 36 L 160 35 L 160 31 L 157 25 L 155 23 L 155 22 L 152 19 L 151 19 L 149 17 L 148 17 L 144 14 L 142 14 L 139 12 L 136 12 L 135 11 L 126 10 L 126 14 L 127 15 L 127 21 L 126 22 L 126 27 L 125 28 L 124 33 L 120 41 L 115 47 L 115 48 L 107 56 L 106 56 L 103 59 L 102 59 L 97 63 L 89 67 L 87 67 L 84 69 L 82 69 L 81 70 L 79 70 L 78 71 L 76 71 L 75 72 L 73 72 L 69 74 L 62 75 L 61 76 L 57 76 L 56 77 L 52 77 L 51 78 L 47 78 L 46 79 L 41 79 L 40 80 L 34 80 L 33 81 L 25 81 L 24 82 L 15 82 L 14 83 L 8 83 L 12 87 L 13 87 L 16 90 L 17 90 L 25 97 L 23 101 L 23 106 L 22 107 L 22 114 L 23 115 L 23 118 L 24 119 L 24 121 L 25 123 L 31 129 L 35 131 L 37 131 L 38 132 L 57 132 L 58 131 L 63 131 L 64 130 L 68 130 L 69 129 L 72 129 L 73 128 L 76 128 L 77 127 L 79 127 L 80 126 L 85 125 L 88 123 L 90 123 L 93 121 L 95 121 L 103 117 L 105 115 L 110 113 L 112 111 L 116 109 L 119 106 L 120 106 L 123 103 L 124 103 L 141 86 L 141 85 L 144 82 L 144 81 L 148 76 L 149 78 L 148 79 L 148 82 L 146 85 L 146 88 L 161 103 L 162 103 L 165 106 L 166 106 L 174 112 L 180 115 L 185 116 L 186 117 L 190 117 L 191 118 L 198 118 L 199 117 L 202 117 L 203 116 L 205 116 L 207 114 L 208 114 L 213 109 L 215 105 L 215 102 L 216 101 L 216 93 L 215 92 L 215 88 L 214 87 L 214 85 L 212 81 L 212 78 L 222 68 Z M 723 63 L 723 64 L 724 64 L 726 66 L 730 68 L 735 73 L 739 81 L 739 83 L 740 84 L 740 87 L 741 88 L 741 91 L 742 92 L 742 96 L 743 97 L 743 103 L 744 104 L 744 114 L 745 115 L 745 149 L 744 150 L 744 162 L 746 166 L 750 170 L 754 172 L 772 172 L 773 171 L 777 171 L 778 170 L 781 170 L 782 169 L 789 168 L 793 166 L 800 165 L 801 164 L 803 164 L 807 162 L 810 162 L 811 161 L 813 161 L 814 160 L 816 160 L 817 159 L 819 159 L 820 158 L 822 158 L 823 157 L 825 157 L 829 155 L 832 155 L 833 154 L 835 154 L 839 152 L 841 152 L 842 153 L 842 181 L 841 182 L 837 182 L 836 183 L 833 183 L 832 184 L 823 185 L 822 186 L 819 186 L 818 187 L 809 188 L 808 189 L 805 189 L 804 190 L 800 190 L 799 191 L 795 191 L 794 192 L 791 192 L 790 193 L 786 193 L 785 194 L 781 194 L 780 195 L 777 195 L 776 196 L 771 196 L 770 197 L 765 197 L 764 198 L 756 198 L 755 199 L 729 199 L 728 198 L 723 198 L 721 197 L 722 200 L 724 202 L 725 205 L 727 207 L 727 208 L 730 212 L 729 213 L 729 216 L 728 217 L 728 221 L 727 222 L 727 240 L 728 241 L 728 243 L 729 245 L 733 250 L 737 252 L 739 252 L 740 253 L 752 253 L 753 252 L 757 252 L 758 251 L 761 251 L 762 250 L 764 250 L 766 249 L 776 239 L 776 238 L 778 236 L 779 237 L 778 239 L 778 241 L 776 243 L 773 249 L 759 263 L 758 263 L 753 268 L 752 268 L 745 274 L 744 274 L 733 282 L 725 286 L 723 288 L 706 296 L 717 307 L 716 308 L 716 322 L 717 323 L 717 327 L 721 335 L 728 342 L 729 342 L 733 345 L 735 345 L 739 347 L 753 347 L 754 346 L 759 345 L 763 343 L 768 339 L 769 339 L 784 324 L 784 323 L 787 320 L 787 319 L 795 308 L 800 298 L 802 296 L 811 278 L 811 276 L 814 271 L 814 269 L 817 264 L 817 262 L 819 259 L 819 257 L 822 251 L 822 248 L 823 247 L 823 245 L 824 244 L 824 240 L 825 239 L 825 223 L 827 221 L 834 218 L 836 216 L 840 214 L 841 215 L 841 227 L 840 228 L 840 268 L 839 269 L 839 307 L 838 308 L 838 319 L 837 320 L 837 327 L 836 328 L 835 337 L 834 338 L 834 340 L 833 341 L 833 344 L 831 347 L 831 349 L 828 355 L 825 358 L 825 359 L 821 362 L 821 363 L 839 378 L 840 378 L 851 386 L 854 387 L 856 389 L 858 390 L 860 390 L 861 391 L 863 391 L 864 392 L 874 392 L 875 391 L 877 391 L 878 390 L 880 390 L 882 389 L 885 386 L 886 386 L 891 380 L 893 376 L 893 374 L 894 373 L 894 370 L 895 369 L 895 364 L 896 363 L 899 363 L 900 364 L 907 364 L 908 365 L 938 365 L 939 364 L 946 364 L 947 363 L 953 363 L 954 362 L 959 362 L 960 361 L 963 361 L 964 360 L 968 360 L 969 359 L 972 359 L 973 358 L 980 357 L 981 356 L 983 356 L 984 355 L 986 355 L 987 354 L 992 353 L 997 350 L 999 350 L 1005 347 L 1007 345 L 1010 344 L 1012 342 L 1018 339 L 1021 336 L 1022 336 L 1025 333 L 1026 333 L 1030 329 L 1031 329 L 1040 320 L 1041 320 L 1049 324 L 1051 326 L 1058 329 L 1060 331 L 1070 336 L 1072 338 L 1075 339 L 1080 343 L 1083 344 L 1085 346 L 1086 346 L 1088 348 L 1089 348 L 1091 350 L 1092 350 L 1094 352 L 1095 352 L 1097 354 L 1098 354 L 1100 356 L 1101 356 L 1103 358 L 1107 360 L 1110 363 L 1111 363 L 1117 368 L 1118 368 L 1121 371 L 1122 371 L 1126 375 L 1127 375 L 1131 379 L 1132 379 L 1136 383 L 1137 383 L 1144 390 L 1145 390 L 1147 392 L 1147 387 L 1148 386 L 1148 370 L 1149 369 L 1149 367 L 1151 366 L 1160 357 L 1164 349 L 1164 347 L 1165 346 L 1165 331 L 1164 330 L 1164 327 L 1163 326 L 1163 324 L 1161 321 L 1161 319 L 1160 317 L 1158 315 L 1157 312 L 1148 302 L 1147 302 L 1143 298 L 1142 298 L 1139 295 L 1133 292 L 1131 290 L 1115 282 L 1113 282 L 1105 278 L 1103 278 L 1102 277 L 1097 276 L 1094 274 L 1092 274 L 1091 273 L 1088 273 L 1087 272 L 1082 271 L 1079 269 L 1081 264 L 1083 262 L 1095 238 L 1095 236 L 1099 229 L 1099 227 L 1101 224 L 1101 222 L 1103 219 L 1103 217 L 1105 214 L 1105 211 L 1106 210 L 1106 196 L 1105 195 L 1104 190 L 1099 184 L 1099 183 L 1098 183 L 1095 180 L 1089 177 L 1087 177 L 1086 176 L 1083 176 L 1082 175 L 1071 175 L 1070 176 L 1067 176 L 1066 177 L 1064 177 L 1063 178 L 1061 178 L 1057 180 L 1056 179 L 1054 179 L 1053 178 L 1048 177 L 1045 175 L 1045 181 L 1044 182 L 1044 189 L 1043 190 L 1043 194 L 1042 195 L 1042 198 L 1041 199 L 1041 201 L 1040 202 L 1040 205 L 1037 211 L 1036 216 L 1034 219 L 1034 221 L 1023 243 L 1021 245 L 1017 253 L 1014 256 L 1012 256 L 1011 255 L 1006 255 L 1005 254 L 999 254 L 998 253 L 991 253 L 990 252 L 983 252 L 982 251 L 975 251 L 974 250 L 965 250 L 964 249 L 956 249 L 952 251 L 949 251 L 948 250 L 952 247 L 953 247 L 955 245 L 956 245 L 958 243 L 959 243 L 965 237 L 966 237 L 978 224 L 978 223 L 983 218 L 983 217 L 987 213 L 987 212 L 993 205 L 995 201 L 998 198 L 998 197 L 1000 195 L 1000 194 L 1002 192 L 1002 191 L 1004 189 L 1004 188 L 1006 186 L 1006 185 L 1008 183 L 1008 182 L 1010 180 L 1011 177 L 1014 173 L 1021 173 L 1022 172 L 1031 172 L 1032 171 L 1045 171 L 1046 170 L 1091 170 L 1092 171 L 1104 171 L 1105 172 L 1114 172 L 1115 173 L 1122 173 L 1123 174 L 1129 174 L 1130 175 L 1134 175 L 1135 176 L 1139 176 L 1140 177 L 1145 177 L 1146 178 L 1165 178 L 1166 177 L 1168 177 L 1169 176 L 1171 176 L 1173 175 L 1175 173 L 1179 171 L 1182 168 L 1182 167 L 1185 164 L 1187 160 L 1187 157 L 1188 156 L 1188 146 L 1187 145 L 1187 142 L 1186 141 L 1185 136 L 1183 132 L 1181 130 L 1181 128 L 1183 125 L 1183 123 L 1185 120 L 1185 118 L 1186 117 L 1186 115 L 1187 113 L 1185 113 L 1181 115 L 1178 115 L 1177 116 L 1174 116 L 1173 117 L 1170 117 L 1169 118 L 1166 118 L 1165 119 L 1160 119 L 1159 120 L 1155 120 L 1154 121 L 1148 121 L 1147 122 L 1141 122 L 1140 123 L 1131 123 L 1130 124 L 1119 124 L 1118 125 L 1098 125 L 1097 126 L 1066 126 L 1065 127 L 1046 127 L 1045 128 L 1039 128 L 1038 126 L 1040 123 L 1040 121 L 1042 118 L 1042 116 L 1046 108 L 1046 106 L 1047 105 L 1047 103 L 1048 102 L 1048 100 L 1049 99 L 1049 97 L 1051 93 L 1051 90 L 1052 89 L 1052 86 L 1053 85 L 1053 82 L 1054 81 L 1054 78 L 1055 77 L 1055 73 L 1056 72 L 1056 65 L 1057 64 L 1057 47 L 1056 46 L 1056 42 L 1055 41 L 1055 39 L 1052 33 L 1046 27 L 1042 25 L 1040 25 L 1039 24 L 1036 24 L 1035 23 L 1030 23 L 1029 22 L 1015 22 L 1014 23 L 1009 23 L 1008 24 L 1006 24 L 1005 25 L 1002 25 L 999 27 L 997 27 L 991 30 L 989 32 L 988 32 L 986 34 L 982 36 L 972 46 L 972 47 L 973 46 L 977 46 L 981 50 L 982 52 L 982 54 L 983 55 L 983 59 L 984 60 L 984 81 L 983 82 L 983 90 L 982 91 L 982 98 L 981 99 L 981 105 L 980 106 L 980 110 L 979 111 L 979 116 L 978 117 L 978 121 L 977 122 L 977 125 L 976 126 L 976 130 L 975 132 L 971 134 L 960 134 L 959 135 L 944 135 L 943 136 L 906 136 L 905 135 L 896 135 L 895 134 L 894 134 L 894 135 L 900 145 L 900 148 L 898 151 L 898 153 L 897 154 L 897 157 L 896 158 L 896 163 L 895 164 L 895 173 L 896 174 L 897 179 L 903 186 L 904 186 L 906 188 L 910 190 L 912 190 L 913 191 L 916 191 L 917 192 L 936 192 L 937 191 L 941 191 L 942 190 L 944 190 L 945 189 L 947 189 L 951 187 L 953 187 L 954 188 L 951 194 L 949 196 L 948 199 L 946 201 L 946 202 L 944 204 L 942 208 L 939 211 L 939 212 L 935 216 L 935 217 L 928 224 L 928 225 L 921 232 L 920 232 L 914 238 L 913 238 L 912 240 L 909 243 L 908 245 L 908 247 L 907 248 L 907 258 L 908 259 L 908 261 L 911 265 L 917 268 L 919 268 L 927 272 L 932 273 L 935 275 L 940 276 L 943 278 L 945 278 L 946 279 L 948 279 L 949 280 L 954 281 L 957 283 L 962 284 L 965 286 L 967 286 L 970 288 L 972 288 L 975 290 L 977 290 L 980 292 L 982 292 L 984 293 L 985 295 L 979 301 L 978 301 L 969 309 L 968 309 L 960 315 L 957 316 L 955 318 L 948 321 L 946 323 L 944 324 L 942 324 L 926 332 L 924 332 L 918 335 L 916 335 L 915 336 L 913 336 L 912 337 L 910 337 L 909 338 L 907 338 L 906 339 L 904 339 L 900 341 L 897 341 L 893 343 L 892 342 L 892 329 L 891 328 L 891 307 L 890 306 L 890 277 L 889 276 L 889 236 L 888 235 L 888 124 L 889 123 L 889 85 L 890 84 L 890 63 L 891 62 L 891 50 L 892 49 L 892 43 L 893 42 L 893 38 L 894 37 L 894 34 L 895 33 L 895 27 L 896 26 L 895 24 L 895 19 L 891 13 L 883 9 L 880 9 L 879 8 L 865 8 L 864 9 L 861 9 L 860 10 L 858 10 L 852 13 L 850 15 L 849 15 L 842 22 L 842 23 L 840 25 L 839 28 L 842 32 L 844 36 L 844 38 L 845 39 L 845 76 L 844 77 L 844 112 L 843 113 L 838 113 L 837 114 L 832 114 L 831 115 L 826 115 L 825 116 L 820 116 L 819 117 L 814 117 L 813 118 L 808 118 L 807 119 L 802 119 L 801 120 L 793 120 L 792 119 L 792 114 L 791 113 L 791 105 L 790 104 L 790 98 L 789 97 L 789 92 L 788 91 L 788 86 L 787 85 L 787 81 L 786 80 L 786 77 L 785 76 L 785 74 L 784 73 L 784 71 L 783 70 L 782 65 L 780 61 L 778 59 L 778 58 L 775 55 L 775 54 L 774 54 L 771 51 L 767 49 L 757 49 L 756 50 L 754 50 L 748 53 L 741 59 L 739 59 L 738 60 L 734 60 L 733 61 L 730 61 L 729 62 L 726 62 L 725 63 Z" fill="#D1251A" fill-rule="evenodd"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="1128" height="378" viewBox="0 0 1128 378">
<title>银妆 INZUNG</title>
<path d="M 543 292 L 540 295 L 537 301 L 537 304 L 536 305 L 536 313 L 537 314 L 537 318 L 540 324 L 542 326 L 557 326 L 558 325 L 558 308 L 551 308 L 551 317 L 550 318 L 546 318 L 544 315 L 544 305 L 545 303 L 550 299 L 559 299 L 563 302 L 565 306 L 565 314 L 562 318 L 562 319 L 566 323 L 566 324 L 568 324 L 568 323 L 570 321 L 572 317 L 572 311 L 573 310 L 572 309 L 572 303 L 571 302 L 571 300 L 569 296 L 565 292 L 559 289 L 550 289 L 549 290 L 547 290 Z M 537 237 L 537 246 L 554 246 L 555 247 L 555 248 L 553 250 L 552 250 L 547 255 L 546 255 L 542 259 L 541 259 L 538 262 L 537 262 L 537 271 L 538 272 L 571 272 L 572 271 L 572 262 L 554 262 L 553 261 L 557 257 L 558 257 L 572 245 L 572 237 Z M 40 242 L 37 248 L 37 256 L 38 257 L 38 259 L 40 261 L 42 265 L 49 272 L 50 272 L 57 278 L 60 279 L 62 281 L 74 287 L 76 287 L 77 288 L 80 288 L 81 289 L 85 289 L 90 286 L 91 284 L 91 281 L 92 280 L 91 278 L 91 275 L 83 267 L 83 266 L 79 262 L 79 261 L 76 258 L 76 257 L 72 252 L 69 246 L 68 241 L 67 240 L 67 237 L 66 236 L 67 234 L 65 234 L 61 238 L 48 238 Z M 189 224 L 185 222 L 171 222 L 167 219 L 166 219 L 166 228 L 165 229 L 164 234 L 162 237 L 162 239 L 159 245 L 157 247 L 155 252 L 146 264 L 146 268 L 147 269 L 147 271 L 148 273 L 150 274 L 158 274 L 164 271 L 166 269 L 169 268 L 172 265 L 176 263 L 179 260 L 180 260 L 187 253 L 187 252 L 190 249 L 190 248 L 192 246 L 194 242 L 194 231 L 192 227 Z M 267 201 L 267 202 L 264 206 L 264 208 L 263 210 L 260 213 L 259 213 L 257 215 L 257 216 L 261 219 L 262 219 L 264 221 L 265 221 L 267 223 L 268 223 L 270 225 L 271 225 L 273 227 L 274 227 L 282 234 L 283 234 L 292 243 L 293 243 L 295 245 L 295 246 L 328 280 L 328 281 L 339 292 L 339 293 L 340 293 L 342 295 L 342 296 L 362 316 L 363 316 L 375 327 L 376 327 L 381 331 L 384 332 L 386 334 L 388 335 L 390 335 L 393 337 L 395 337 L 396 338 L 400 338 L 401 339 L 412 339 L 413 338 L 417 338 L 418 337 L 420 337 L 428 333 L 430 331 L 431 331 L 438 324 L 438 323 L 442 318 L 442 316 L 444 313 L 444 311 L 445 310 L 445 307 L 446 306 L 446 299 L 458 288 L 459 288 L 467 280 L 445 280 L 444 279 L 438 279 L 437 278 L 432 278 L 431 277 L 427 277 L 426 276 L 422 276 L 421 275 L 418 275 L 414 273 L 411 273 L 410 272 L 408 272 L 407 271 L 405 271 L 404 270 L 399 269 L 391 265 L 386 264 L 381 261 L 379 261 L 363 253 L 361 251 L 356 249 L 350 244 L 352 243 L 355 245 L 357 245 L 358 246 L 361 246 L 362 247 L 368 247 L 369 246 L 374 246 L 375 245 L 384 244 L 385 243 L 388 243 L 389 242 L 391 242 L 392 241 L 395 241 L 398 239 L 403 238 L 409 235 L 415 230 L 415 229 L 418 225 L 418 222 L 419 221 L 419 213 L 418 212 L 418 210 L 415 204 L 411 200 L 408 199 L 406 197 L 401 196 L 397 193 L 392 183 L 391 185 L 391 187 L 389 191 L 385 196 L 385 197 L 375 208 L 374 208 L 368 214 L 367 214 L 363 218 L 359 220 L 356 223 L 355 223 L 350 227 L 347 228 L 345 230 L 337 234 L 334 234 L 332 232 L 331 232 L 327 228 L 326 228 L 317 219 L 316 219 L 305 208 L 304 208 L 299 203 L 298 203 L 295 200 L 292 199 L 290 197 L 288 197 L 287 196 L 284 196 L 283 195 L 278 195 L 277 196 L 274 196 L 272 197 Z M 542 185 L 539 188 L 537 192 L 537 195 L 536 196 L 536 204 L 537 205 L 537 208 L 539 212 L 543 216 L 545 217 L 547 217 L 548 218 L 572 218 L 572 209 L 571 208 L 549 208 L 547 207 L 544 203 L 544 198 L 545 196 L 548 193 L 550 192 L 572 192 L 572 183 L 571 182 L 550 182 L 549 183 L 546 183 Z M 537 134 L 537 166 L 544 166 L 545 165 L 545 150 L 546 149 L 547 149 L 554 155 L 558 157 L 561 160 L 565 162 L 568 165 L 572 165 L 572 135 L 564 135 L 564 150 L 563 151 L 560 148 L 559 148 L 553 143 L 549 141 L 542 135 L 540 134 Z M 175 125 L 153 125 L 152 126 L 133 126 L 132 127 L 117 127 L 116 128 L 104 128 L 103 129 L 92 129 L 91 130 L 80 130 L 79 131 L 63 131 L 62 130 L 60 130 L 60 132 L 64 139 L 64 142 L 62 145 L 62 148 L 61 149 L 61 152 L 62 153 L 62 155 L 64 159 L 70 164 L 72 165 L 74 165 L 75 166 L 79 166 L 80 165 L 82 165 L 83 164 L 86 164 L 87 163 L 90 163 L 91 162 L 97 162 L 98 161 L 102 161 L 103 162 L 103 177 L 102 178 L 102 180 L 101 181 L 93 181 L 92 182 L 80 182 L 79 183 L 68 183 L 67 184 L 56 184 L 55 185 L 29 185 L 34 194 L 33 196 L 33 207 L 35 211 L 39 215 L 40 215 L 44 218 L 46 218 L 47 219 L 50 219 L 51 220 L 56 220 L 57 219 L 60 219 L 61 218 L 64 218 L 65 217 L 70 217 L 71 216 L 78 216 L 79 215 L 88 215 L 89 214 L 100 214 L 101 215 L 101 253 L 100 254 L 100 291 L 96 294 L 93 294 L 92 295 L 89 295 L 88 296 L 85 296 L 84 297 L 80 297 L 79 298 L 76 298 L 75 299 L 72 299 L 71 300 L 67 300 L 66 301 L 63 301 L 62 302 L 58 302 L 57 303 L 54 303 L 53 304 L 49 304 L 48 305 L 44 305 L 43 306 L 37 306 L 36 307 L 22 307 L 21 308 L 32 320 L 32 333 L 35 339 L 39 343 L 43 345 L 48 345 L 49 346 L 52 346 L 53 345 L 59 345 L 60 344 L 63 344 L 64 343 L 69 342 L 76 338 L 81 337 L 84 335 L 87 335 L 88 334 L 90 334 L 94 332 L 97 332 L 98 331 L 101 331 L 105 329 L 109 329 L 113 327 L 122 326 L 126 324 L 130 324 L 131 323 L 134 323 L 135 322 L 140 322 L 141 321 L 145 321 L 146 320 L 152 320 L 153 319 L 181 319 L 182 318 L 184 318 L 186 317 L 195 309 L 198 303 L 198 300 L 199 299 L 199 295 L 198 294 L 198 291 L 197 290 L 197 288 L 195 286 L 195 285 L 190 280 L 188 279 L 186 279 L 185 278 L 173 278 L 172 279 L 165 279 L 164 280 L 159 280 L 158 281 L 153 281 L 152 282 L 148 282 L 147 283 L 142 283 L 141 282 L 141 215 L 142 214 L 142 212 L 143 211 L 148 211 L 149 210 L 177 210 L 178 211 L 185 211 L 186 210 L 189 210 L 193 208 L 197 204 L 198 204 L 198 203 L 200 201 L 202 197 L 202 188 L 201 187 L 201 185 L 199 183 L 199 182 L 195 178 L 193 177 L 191 177 L 190 176 L 176 176 L 175 177 L 156 177 L 155 178 L 144 178 L 143 177 L 143 169 L 144 168 L 144 160 L 146 158 L 165 158 L 166 159 L 176 159 L 178 157 L 179 157 L 185 150 L 185 148 L 186 147 L 186 139 L 183 133 Z M 330 122 L 330 129 L 329 130 L 329 134 L 328 135 L 328 138 L 327 140 L 324 142 L 320 142 L 319 143 L 314 143 L 313 144 L 303 144 L 302 145 L 290 145 L 289 146 L 275 146 L 274 147 L 272 147 L 271 146 L 269 146 L 268 147 L 266 146 L 256 146 L 254 144 L 254 129 L 258 126 L 261 126 L 262 125 L 266 125 L 267 124 L 271 124 L 272 123 L 277 123 L 278 122 L 283 122 L 284 121 L 293 121 L 294 120 L 322 120 L 323 121 L 329 121 Z M 537 82 L 537 92 L 554 92 L 555 93 L 550 98 L 549 98 L 545 102 L 544 102 L 537 108 L 537 117 L 571 117 L 572 116 L 572 108 L 571 107 L 555 107 L 554 105 L 571 92 L 572 90 L 572 82 Z M 254 76 L 256 74 L 258 74 L 259 73 L 263 73 L 264 72 L 268 72 L 269 71 L 273 71 L 274 70 L 280 70 L 281 69 L 289 69 L 290 68 L 319 68 L 320 69 L 328 69 L 329 70 L 335 70 L 338 72 L 338 75 L 337 76 L 337 81 L 336 82 L 336 85 L 334 87 L 332 87 L 328 89 L 324 89 L 323 90 L 320 90 L 319 91 L 314 91 L 313 92 L 304 92 L 303 93 L 291 93 L 290 94 L 277 94 L 276 95 L 259 95 L 258 94 L 255 94 L 254 93 Z M 537 53 L 537 62 L 538 63 L 571 63 L 572 62 L 572 53 Z M 385 27 L 383 27 L 382 26 L 380 26 L 376 24 L 371 24 L 370 23 L 359 23 L 358 24 L 354 24 L 353 25 L 350 25 L 342 29 L 340 31 L 338 32 L 336 32 L 333 34 L 331 34 L 330 35 L 328 35 L 324 37 L 321 37 L 320 38 L 316 38 L 315 39 L 310 39 L 309 40 L 301 40 L 300 41 L 288 41 L 287 42 L 275 42 L 274 43 L 258 43 L 257 42 L 257 39 L 258 38 L 258 32 L 257 31 L 257 29 L 256 27 L 253 24 L 252 24 L 250 22 L 248 22 L 247 21 L 243 21 L 242 20 L 232 20 L 231 21 L 227 21 L 226 22 L 224 22 L 218 25 L 216 27 L 215 27 L 209 33 L 208 35 L 208 37 L 212 43 L 212 45 L 213 46 L 213 51 L 214 52 L 214 151 L 213 152 L 213 187 L 212 188 L 212 214 L 211 215 L 211 236 L 210 237 L 210 255 L 209 256 L 209 268 L 208 269 L 208 279 L 207 280 L 207 286 L 206 287 L 206 293 L 205 294 L 205 299 L 204 300 L 204 304 L 203 305 L 203 308 L 202 309 L 202 312 L 201 313 L 201 315 L 200 316 L 199 321 L 195 329 L 192 332 L 192 333 L 203 342 L 204 342 L 215 350 L 218 351 L 220 353 L 226 356 L 229 356 L 230 357 L 240 357 L 241 356 L 243 356 L 247 354 L 250 351 L 252 350 L 252 349 L 257 343 L 263 340 L 266 337 L 269 336 L 271 334 L 272 334 L 277 330 L 280 329 L 282 327 L 285 326 L 287 324 L 307 314 L 314 307 L 314 306 L 316 304 L 317 302 L 317 300 L 318 299 L 318 295 L 319 294 L 319 288 L 318 287 L 318 283 L 317 282 L 317 280 L 315 278 L 314 279 L 312 279 L 311 280 L 308 280 L 304 282 L 297 283 L 293 285 L 290 285 L 289 286 L 286 286 L 285 287 L 282 287 L 281 288 L 278 288 L 277 289 L 274 289 L 270 291 L 266 291 L 265 292 L 262 292 L 261 293 L 258 293 L 257 294 L 256 294 L 254 292 L 255 290 L 254 289 L 254 213 L 253 212 L 253 182 L 257 179 L 259 179 L 260 178 L 263 178 L 264 177 L 267 177 L 268 176 L 272 176 L 273 175 L 277 175 L 278 174 L 284 174 L 285 173 L 293 173 L 294 172 L 315 172 L 330 183 L 333 184 L 335 186 L 338 187 L 340 189 L 342 190 L 344 190 L 345 191 L 347 191 L 348 192 L 363 192 L 364 191 L 366 191 L 370 189 L 372 187 L 373 187 L 377 182 L 377 180 L 378 179 L 378 173 L 377 172 L 377 169 L 376 168 L 376 166 L 374 163 L 374 160 L 373 159 L 373 134 L 374 133 L 374 125 L 375 124 L 375 118 L 376 117 L 376 111 L 377 110 L 377 104 L 378 103 L 378 97 L 379 96 L 379 90 L 380 89 L 380 84 L 381 83 L 381 77 L 382 76 L 382 71 L 383 70 L 383 65 L 384 64 L 384 60 L 385 59 L 386 52 L 391 42 L 391 39 L 392 38 L 390 31 Z M 208 63 L 204 63 L 203 62 L 197 62 L 196 61 L 192 61 L 191 60 L 188 60 L 187 59 L 180 58 L 176 56 L 173 56 L 170 54 L 168 54 L 163 51 L 161 51 L 155 48 L 150 44 L 150 31 L 149 30 L 149 28 L 147 24 L 140 17 L 139 17 L 137 15 L 133 13 L 131 13 L 127 11 L 118 10 L 118 12 L 119 13 L 119 22 L 118 23 L 118 27 L 115 33 L 115 35 L 112 39 L 112 40 L 110 42 L 110 43 L 97 56 L 96 56 L 94 58 L 80 65 L 78 65 L 77 66 L 75 66 L 74 67 L 72 67 L 71 68 L 69 68 L 65 70 L 62 70 L 61 71 L 54 72 L 53 73 L 49 73 L 48 74 L 44 74 L 43 75 L 38 75 L 37 76 L 30 76 L 29 77 L 22 77 L 21 78 L 9 78 L 8 79 L 16 86 L 17 86 L 22 91 L 22 95 L 21 96 L 21 102 L 20 103 L 20 105 L 21 106 L 21 111 L 22 112 L 22 114 L 23 116 L 28 121 L 34 124 L 54 124 L 55 123 L 60 123 L 61 122 L 64 122 L 65 121 L 72 120 L 75 118 L 78 118 L 90 112 L 92 112 L 96 109 L 103 106 L 109 101 L 110 101 L 113 98 L 114 98 L 118 94 L 119 94 L 128 85 L 128 84 L 133 79 L 133 78 L 139 70 L 140 71 L 140 74 L 139 75 L 139 78 L 137 81 L 137 83 L 149 95 L 150 95 L 158 102 L 174 110 L 178 110 L 179 111 L 185 111 L 186 110 L 189 110 L 190 109 L 192 109 L 194 108 L 200 102 L 201 100 L 201 98 L 202 97 L 202 87 L 201 86 L 201 82 L 200 81 L 200 79 L 199 78 L 199 76 L 198 74 Z M 682 60 L 684 62 L 688 64 L 692 68 L 692 69 L 694 71 L 694 73 L 696 76 L 696 78 L 698 82 L 698 86 L 699 87 L 699 91 L 700 92 L 700 99 L 701 100 L 701 113 L 702 114 L 702 134 L 701 135 L 701 153 L 703 157 L 706 160 L 708 161 L 710 161 L 711 162 L 725 162 L 726 161 L 731 161 L 732 160 L 735 160 L 736 159 L 739 159 L 740 158 L 747 157 L 751 155 L 754 155 L 755 154 L 757 154 L 761 152 L 764 152 L 765 151 L 767 151 L 768 150 L 770 150 L 771 149 L 773 149 L 774 148 L 776 148 L 777 147 L 779 147 L 780 146 L 782 146 L 783 145 L 785 145 L 786 144 L 788 144 L 792 142 L 794 144 L 794 151 L 793 152 L 794 153 L 794 156 L 793 157 L 793 171 L 792 172 L 789 172 L 788 173 L 785 173 L 784 174 L 780 174 L 779 175 L 775 175 L 774 176 L 771 176 L 770 177 L 766 177 L 765 178 L 761 178 L 760 179 L 756 179 L 755 180 L 751 180 L 750 181 L 747 181 L 746 182 L 742 182 L 741 183 L 738 183 L 737 184 L 733 184 L 732 185 L 728 185 L 727 186 L 721 186 L 720 187 L 714 187 L 713 188 L 685 188 L 684 187 L 680 187 L 680 188 L 682 190 L 683 193 L 685 195 L 687 199 L 687 203 L 686 204 L 686 207 L 685 208 L 685 215 L 684 216 L 684 221 L 685 222 L 685 227 L 688 233 L 693 237 L 695 237 L 696 238 L 710 238 L 711 237 L 714 237 L 715 236 L 720 235 L 722 233 L 723 233 L 723 232 L 732 223 L 732 222 L 734 220 L 735 221 L 734 223 L 734 225 L 731 231 L 729 233 L 729 234 L 712 251 L 711 251 L 703 258 L 702 258 L 691 266 L 688 267 L 683 271 L 666 279 L 666 280 L 675 289 L 675 293 L 674 294 L 674 300 L 675 301 L 675 306 L 676 307 L 676 309 L 677 310 L 677 312 L 678 314 L 684 321 L 696 327 L 708 327 L 709 326 L 712 326 L 720 322 L 732 312 L 732 311 L 738 305 L 738 304 L 741 301 L 741 300 L 746 294 L 747 291 L 749 289 L 750 286 L 752 284 L 756 275 L 758 273 L 759 271 L 759 269 L 765 258 L 765 256 L 768 251 L 768 249 L 772 241 L 772 239 L 773 238 L 773 236 L 775 232 L 775 229 L 776 228 L 776 224 L 777 223 L 777 212 L 776 211 L 782 206 L 791 202 L 792 203 L 792 232 L 791 233 L 791 272 L 790 273 L 790 297 L 789 298 L 789 306 L 788 307 L 788 312 L 787 313 L 787 317 L 785 321 L 785 324 L 784 325 L 784 327 L 780 335 L 774 342 L 785 352 L 786 352 L 793 358 L 794 358 L 799 362 L 802 363 L 807 367 L 809 367 L 813 369 L 824 369 L 825 368 L 827 368 L 831 366 L 838 359 L 838 358 L 840 356 L 841 354 L 841 352 L 842 351 L 842 343 L 843 342 L 848 342 L 849 343 L 856 343 L 857 344 L 881 344 L 882 343 L 890 343 L 891 342 L 897 342 L 898 341 L 903 341 L 904 340 L 908 340 L 909 339 L 912 339 L 913 338 L 916 338 L 917 337 L 920 337 L 921 336 L 924 336 L 925 335 L 927 335 L 928 334 L 933 333 L 953 323 L 955 321 L 959 319 L 962 316 L 963 316 L 966 313 L 967 313 L 979 302 L 982 302 L 990 306 L 992 308 L 997 310 L 999 312 L 1004 314 L 1006 316 L 1009 317 L 1014 321 L 1019 323 L 1021 325 L 1022 325 L 1024 327 L 1025 327 L 1027 329 L 1028 329 L 1030 331 L 1031 331 L 1033 333 L 1034 333 L 1036 335 L 1040 337 L 1043 340 L 1047 342 L 1050 345 L 1054 347 L 1058 351 L 1059 351 L 1062 354 L 1063 354 L 1073 363 L 1074 363 L 1080 369 L 1080 366 L 1081 365 L 1080 364 L 1081 363 L 1081 348 L 1082 346 L 1084 344 L 1085 344 L 1092 337 L 1093 334 L 1095 332 L 1095 330 L 1097 326 L 1097 312 L 1096 311 L 1096 308 L 1095 307 L 1095 305 L 1091 297 L 1083 287 L 1082 287 L 1076 281 L 1075 281 L 1067 275 L 1047 265 L 1045 265 L 1042 263 L 1037 262 L 1034 260 L 1031 260 L 1030 259 L 1028 259 L 1027 258 L 1025 258 L 1021 256 L 1018 256 L 1016 254 L 1016 252 L 1019 248 L 1021 243 L 1023 241 L 1023 239 L 1025 237 L 1026 235 L 1026 233 L 1036 213 L 1036 211 L 1040 203 L 1040 201 L 1041 200 L 1041 196 L 1042 195 L 1042 188 L 1041 187 L 1041 184 L 1040 183 L 1040 181 L 1038 177 L 1031 170 L 1025 167 L 1023 167 L 1022 166 L 1006 166 L 998 170 L 995 170 L 994 169 L 992 169 L 991 168 L 989 168 L 988 167 L 986 167 L 984 166 L 984 176 L 983 177 L 983 182 L 982 183 L 981 190 L 980 191 L 980 193 L 979 194 L 979 196 L 978 197 L 977 202 L 975 205 L 975 207 L 973 210 L 973 212 L 971 214 L 971 216 L 967 224 L 965 226 L 959 238 L 955 242 L 953 241 L 947 241 L 946 240 L 940 240 L 939 239 L 932 239 L 931 238 L 924 238 L 923 237 L 916 237 L 915 236 L 907 236 L 906 235 L 902 235 L 901 236 L 899 236 L 898 237 L 895 237 L 894 238 L 891 238 L 890 239 L 888 239 L 887 238 L 897 233 L 903 228 L 904 228 L 919 213 L 919 212 L 929 201 L 929 200 L 934 194 L 934 193 L 938 189 L 938 188 L 940 186 L 940 185 L 948 174 L 949 171 L 955 163 L 959 163 L 960 162 L 969 162 L 970 161 L 983 161 L 984 160 L 1030 160 L 1031 161 L 1042 161 L 1043 162 L 1052 162 L 1053 163 L 1059 163 L 1060 164 L 1065 164 L 1066 165 L 1070 165 L 1071 166 L 1075 166 L 1076 167 L 1082 167 L 1083 168 L 1094 168 L 1095 167 L 1099 167 L 1100 166 L 1102 166 L 1106 164 L 1108 162 L 1109 162 L 1114 157 L 1114 156 L 1116 154 L 1118 150 L 1118 147 L 1119 146 L 1119 139 L 1118 138 L 1117 131 L 1112 122 L 1114 118 L 1114 116 L 1116 113 L 1117 108 L 1118 107 L 1116 108 L 1113 108 L 1109 110 L 1102 111 L 1101 112 L 1097 112 L 1096 113 L 1091 113 L 1090 114 L 1085 114 L 1084 115 L 1078 115 L 1077 116 L 1069 116 L 1068 117 L 1059 117 L 1058 118 L 1041 118 L 1040 119 L 1010 119 L 1009 120 L 988 120 L 987 121 L 978 121 L 977 119 L 982 109 L 983 104 L 985 101 L 985 99 L 986 98 L 986 96 L 987 95 L 987 93 L 988 92 L 988 90 L 989 89 L 989 87 L 991 83 L 991 80 L 992 79 L 992 76 L 993 75 L 993 70 L 994 69 L 994 64 L 995 63 L 995 43 L 994 42 L 994 39 L 993 38 L 993 36 L 991 32 L 985 26 L 981 24 L 978 24 L 977 23 L 974 23 L 973 22 L 968 22 L 967 21 L 960 21 L 959 22 L 952 22 L 951 23 L 948 23 L 947 24 L 942 25 L 937 28 L 935 28 L 933 30 L 930 31 L 927 34 L 926 34 L 917 42 L 917 43 L 920 43 L 924 46 L 926 50 L 926 53 L 927 54 L 927 79 L 926 80 L 926 88 L 925 89 L 925 95 L 924 96 L 924 101 L 923 102 L 923 107 L 922 108 L 922 112 L 921 113 L 921 117 L 920 118 L 919 125 L 918 126 L 915 126 L 914 127 L 901 127 L 900 128 L 881 128 L 880 129 L 859 129 L 858 128 L 845 128 L 843 127 L 848 137 L 848 140 L 846 143 L 846 145 L 845 146 L 845 149 L 844 150 L 844 157 L 843 158 L 843 160 L 844 161 L 844 166 L 846 170 L 852 176 L 858 179 L 860 179 L 861 180 L 865 180 L 866 181 L 880 181 L 881 180 L 888 179 L 889 178 L 891 178 L 895 176 L 898 176 L 899 177 L 898 180 L 896 182 L 892 190 L 890 192 L 888 196 L 885 199 L 885 200 L 881 204 L 881 205 L 875 211 L 875 212 L 873 214 L 872 214 L 872 215 L 870 217 L 869 217 L 869 218 L 867 219 L 857 229 L 855 233 L 855 236 L 854 237 L 855 244 L 857 248 L 859 250 L 863 252 L 865 252 L 870 255 L 872 255 L 875 257 L 877 257 L 878 258 L 880 258 L 881 259 L 883 259 L 884 260 L 889 261 L 892 263 L 894 263 L 900 266 L 905 267 L 908 269 L 910 269 L 918 273 L 923 274 L 927 276 L 929 278 L 924 283 L 923 283 L 914 291 L 913 291 L 902 299 L 897 301 L 895 303 L 883 309 L 881 309 L 876 312 L 874 312 L 871 314 L 869 314 L 866 316 L 864 316 L 863 317 L 861 317 L 860 318 L 858 318 L 857 319 L 855 319 L 854 320 L 852 320 L 848 322 L 841 323 L 840 322 L 840 314 L 839 313 L 839 294 L 838 293 L 838 268 L 837 267 L 837 226 L 836 225 L 836 114 L 837 113 L 837 78 L 838 77 L 838 57 L 839 56 L 839 46 L 840 45 L 840 39 L 841 38 L 841 34 L 843 30 L 843 20 L 840 14 L 832 9 L 828 9 L 827 8 L 815 8 L 814 9 L 811 9 L 810 10 L 808 10 L 802 13 L 799 16 L 798 16 L 792 23 L 791 25 L 791 27 L 795 33 L 795 35 L 796 36 L 796 40 L 797 41 L 797 44 L 796 45 L 796 79 L 795 80 L 795 105 L 793 107 L 789 107 L 788 108 L 784 108 L 783 109 L 778 109 L 777 110 L 771 110 L 770 111 L 765 111 L 764 112 L 759 112 L 758 113 L 752 113 L 751 114 L 746 114 L 745 113 L 745 104 L 744 103 L 744 96 L 743 95 L 743 89 L 742 88 L 742 83 L 741 82 L 741 78 L 740 77 L 740 74 L 738 70 L 738 67 L 732 55 L 729 52 L 729 51 L 728 51 L 725 48 L 723 47 L 712 47 L 711 48 L 709 48 L 707 49 L 705 51 L 701 53 L 698 56 L 696 56 L 695 57 L 692 57 L 691 58 L 688 58 L 687 59 L 684 59 Z" fill="#F4DC84" fill-rule="evenodd"/>
</svg>
const $ = (selector) => document.querySelector(selector);
const API_BASE = "/api/cms";
const MAX_UPLOAD_SIZE = 20 * 1024 * 1024;
let categories = [];
let editingSlug = null;
let editingStatus = "new-unsaved";
let dirty = false;
let previewTimer = null;
let previewRequest = 0;
async function api(path, options = {}) {
const endpoint = path.startsWith("/") ? path : `/${path}`;
const response = await fetch(`${API_BASE}${endpoint}`, {
headers: { "Content-Type": "application/json" },
...options,
body: options.body ? JSON.stringify(options.body) : undefined,
});
let data = {};
try { data = await response.json(); } catch {}
if (!response.ok) throw new Error(data.error || `请求失败(${response.status})`);
return data;
}
const show = (selector) => $(selector).classList.remove("hidden");
const hide = (selector) => $(selector).classList.add("hidden");
const refreshCaptcha = () => {
$("#captcha-image").src = `${API_BASE}/captcha?t=${Date.now()}`;
$("#captcha").value = "";
};
function showLogin() {
show("#login");
hide("#app");
refreshCaptcha();
$("#password").focus();
}
async function boot() {
const session = await api("/session").catch(() => ({ authed: false }));
if (session.authed) {
hide("#login"); show("#app");
await loadCategories();
loadArticles();
}
else showLogin();
}
$("#captcha-image").addEventListener("click", refreshCaptcha);
$("#login-form").addEventListener("submit", async (event) => {
event.preventDefault();
$("#login-error").textContent = "";
try {
await api("/session", { method: "POST", body: { password: $("#password").value, captcha: $("#captcha").value } });
$("#password").value = "";
$("#captcha").value = "";
hide("#login"); show("#app");
await loadCategories();
loadArticles();
} catch (error) {
$("#login-error").textContent = error.message;
refreshCaptcha();
}
});
$("#logout").addEventListener("click", async () => { await api("/session", { method: "DELETE" }); location.reload(); });
const statusMap = {
"new-unsaved": ["尚未保存", "new-draft", "填写完成后先保存草稿,线上网站不会发生变化。"],
"new-draft": ["新建未发布", "new-draft", "这篇文章目前只有草稿版本,发布后才会出现在官网。"],
"edited-draft": ["修改未发布", "edited-draft", "草稿已经保存,官网仍在展示上一次发布的版本。"],
live: ["已上线", "live", "当前编辑内容与官网展示的版本一致。"],
};
function applyStatus(element, status) {
const [label, className] = statusMap[status] || statusMap["new-draft"];
element.className = `status ${className}`;
element.textContent = label;
}
async function loadArticles() {
const articles = await api("/articles").catch((error) => { alert(error.message); return []; });
const list = $("#article-list");
list.innerHTML = "";
const pending = articles.filter((item) => item.publishStatus !== "live").length;
$("#pending-badge").textContent = `${pending} 篇草稿待发布`;
$("#pending-badge").classList.toggle("hidden", pending === 0);
if (!articles.length) { list.innerHTML = '<div class="empty">还没有文章,点击“新建文章”开始。</div>'; return; }
for (const article of articles) {
const card = document.createElement("article");
card.className = "article-row";
card.innerHTML = '<div class="article-summary"><h2></h2><p><span class="category"></span><span class="updated"></span><span class="status"></span></p></div><div class="row-actions"></div>';
card.querySelector("h2").textContent = article.title;
card.querySelector(".category").textContent = article.category;
card.querySelector(".updated").textContent = `更新于 ${article.updated}`;
applyStatus(card.querySelector(".status"), article.publishStatus);
const actions = card.querySelector(".row-actions");
const edit = listAction("编辑", "ghost small", () => openEditor(article.slug));
const publish = listAction("发布", "primary small", () => publishFromList(article));
publish.disabled = article.publishStatus === "live";
publish.title = publish.disabled ? "文章当前已上线" : "发布到官网";
actions.append(edit, publish, listMoreActions(article));
list.append(card);
}
}
function listAction(label, className, action) {
const button = document.createElement("button");
button.type = "button";
button.className = className;
button.textContent = label;
button.onclick = action;
return button;
}
function listMenuAction(label, className, action) {
return listAction(label, `menu-action ${className}`.trim(), async (event) => {
event.currentTarget.closest("details").open = false;
await action();
});
}
function listMoreActions(article) {
const details = document.createElement("details");
details.className = "more-actions row-more-actions";
const summary = document.createElement("summary");
summary.textContent = "更多";
summary.setAttribute("aria-label", `${article.title}的更多操作`);
const menu = document.createElement("div");
menu.className = "action-menu";
if (article.publishStatus === "edited-draft") {
menu.append(listMenuAction("放弃未发布修改", "", () => discardFromList(article)));
}
if (["live", "edited-draft"].includes(article.publishStatus)) {
menu.append(listMenuAction("下架文章", "danger", () => unpublishFromList(article)));
}
menu.append(listMenuAction("删除文章", "danger", () => deleteFromList(article)));
details.append(summary, menu);
details.addEventListener("toggle", () => {
if (!details.open) return;
document.querySelectorAll(".row-more-actions[open]").forEach((item) => {
if (item !== details) item.open = false;
});
});
return details;
}
async function publishFromList(article) {
try {
await triggerBuild(`/articles/${article.slug}/publish`);
await loadArticles();
} catch {}
}
async function discardFromList(article) {
if (!confirm(`确定放弃《${article.title}》未发布的修改,恢复为当前线上版本吗?`)) return;
try {
await api(`/articles/${article.slug}/draft`, { method: "DELETE" });
await loadArticles();
} catch (error) { alert(error.message); }
}
async function unpublishFromList(article) {
const detail = article.publishStatus === "edited-draft" ? "未发布的修改会继续保留为草稿。" : "文章内容会继续保留为草稿。";
if (!confirm(`确定将《${article.title}》从官网下架吗?${detail}`)) return;
try {
await triggerBuild(`/articles/${article.slug}/unpublish`, { action: "下架" });
await loadArticles();
} catch {}
}
async function deleteFromList(article) {
const prompt = article.publishStatus === "new-draft"
? `确定彻底删除草稿《${article.title}》吗?删除后无法恢复。`
: `确定彻底删除《${article.title}》吗?线上版本和草稿都会删除,且无法恢复。`;
if (!confirm(prompt)) return;
try {
await triggerBuild(`/articles/${article.slug}`, { action: "删除", method: "DELETE" });
await loadArticles();
} catch {}
}
function fillCategories(selected) {
const current = selected || $("#category").value || categories[0] || "";
const options = $("#category-options");
options.innerHTML = "";
categories.forEach((name) => {
const option = document.createElement("button");
option.type = "button";
option.className = "select-option";
option.dataset.value = name;
option.setAttribute("role", "option");
option.setAttribute("aria-selected", String(name === current));
option.innerHTML = '<span></span><span class="option-check" aria-hidden="true">✓</span>';
option.querySelector("span").textContent = name;
option.addEventListener("click", () => {
selectCategory(name, true);
closeCategoryMenu();
});
options.append(option);
});
selectCategory(categories.includes(current) ? current : categories[0] || "", false);
}
function selectCategory(name, markDirty) {
$("#category").value = name;
$("#category-value").textContent = name || "请选择分类";
$("#category-options").querySelectorAll(".select-option").forEach((option) => {
option.setAttribute("aria-selected", String(option.dataset.value === name));
});
if (markDirty) dirty = true;
}
function closeCategoryMenu() {
$("#category-menu").classList.add("hidden");
$("#category-trigger").setAttribute("aria-expanded", "false");
$("#category-select").classList.remove("open");
}
function toggleCategoryMenu() {
const opening = $("#category-menu").classList.contains("hidden");
if (!opening) return closeCategoryMenu();
$("#category-menu").classList.remove("hidden");
$("#category-trigger").setAttribute("aria-expanded", "true");
$("#category-select").classList.add("open");
$("#category-error").textContent = "";
}
$("#category-trigger").addEventListener("click", (event) => { event.stopPropagation(); toggleCategoryMenu(); });
$("#category-menu").addEventListener("click", (event) => event.stopPropagation());
document.addEventListener("click", closeCategoryMenu);
document.addEventListener("click", (event) => {
document.querySelectorAll(".row-more-actions[open]").forEach((details) => {
if (!details.contains(event.target)) details.open = false;
});
});
document.addEventListener("keydown", (event) => { if (event.key === "Escape") closeCategoryMenu(); });
async function createCategory() {
const input = $("#new-category");
const name = input.value.trim();
$("#category-error").textContent = "";
try {
await triggerBuild("/categories", { action: "添加分类", body: { name } });
await loadCategories();
input.value = "";
fillCategories(name);
dirty = true;
closeCategoryMenu();
if (!$("#category-modal").classList.contains("hidden")) await loadCategoryManager();
} catch (error) { $("#category-error").textContent = error.message; }
}
$("#add-category").addEventListener("click", createCategory);
$("#new-category").addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
event.preventDefault();
createCategory();
});
async function loadCategories() {
const result = await api("/categories");
categories = result.categories;
fillCategories($("#category").value);
return result;
}
async function loadCategoryManager() {
const result = await loadCategories();
renderCategoryManager(result.stats || []);
}
function clearCategoryPanels() {
$("#category-manager-list").querySelectorAll(".category-inline-panel").forEach((panel) => panel.remove());
}
function categoryPanel(row, text) {
clearCategoryPanels();
const panel = document.createElement("div");
panel.className = "category-inline-panel";
const note = document.createElement("p");
note.textContent = text;
panel.append(note);
row.append(panel);
return panel;
}
async function runCategoryBuild(action, method, body) {
$("#manager-category-error").textContent = "";
try {
await triggerBuild("/categories", { action, method, body });
await loadCategoryManager();
await loadArticles();
} catch (error) {
$("#manager-category-error").textContent = error.message;
await loadCategoryManager().catch(() => {});
}
}
function showRenameCategory(row, stat) {
const panel = categoryPanel(row, `重命名后,${stat.count} 篇文章会同步更新分类。`);
const form = document.createElement("div");
form.className = "category-rename-form";
const input = document.createElement("input");
input.maxLength = 20;
input.value = stat.name;
input.setAttribute("aria-label", `重命名 ${stat.name}`);
const save = document.createElement("button");
save.type = "button";
save.className = "primary";
save.textContent = "保存名称";
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "ghost";
cancel.textContent = "取消";
save.onclick = () => {
const name = input.value.trim();
if (!name || name === stat.name) return clearCategoryPanels();
runCategoryBuild("更新分类", "PATCH", { current: stat.name, name });
};
cancel.onclick = clearCategoryPanels;
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") { event.preventDefault(); save.click(); }
if (event.key === "Escape") clearCategoryPanels();
});
form.append(input, save, cancel);
panel.append(form);
input.focus();
input.select();
}
function showDeleteCategory(row, stat) {
const panel = categoryPanel(
row,
stat.count ? `该分类下有 ${stat.count} 篇文章,请选择删除后要迁移到的分类。` : "该分类下没有文章,可以直接删除。",
);
const actions = document.createElement("div");
actions.className = stat.count ? "replacement-options" : "category-inline-actions";
const choices = stat.count ? categories.filter((name) => name !== stat.name) : [""];
choices.forEach((replacement) => {
const button = document.createElement("button");
button.type = "button";
button.className = stat.count ? "" : "confirm-danger";
button.textContent = stat.count ? `迁移到“${replacement}”并删除` : "确认删除分类";
button.onclick = () => runCategoryBuild("删除分类", "DELETE", { name: stat.name, replacement });
actions.append(button);
});
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "ghost";
cancel.textContent = "取消";
cancel.onclick = clearCategoryPanels;
actions.append(cancel);
panel.append(actions);
}
function renderCategoryManager(stats) {
const list = $("#category-manager-list");
list.innerHTML = "";
stats.forEach((stat) => {
const row = document.createElement("div");
row.className = "category-manager-row";
const main = document.createElement("div");
main.className = "category-row-main";
const name = document.createElement("div");
name.className = "category-row-name";
const strong = document.createElement("strong");
strong.textContent = stat.name;
const count = document.createElement("span");
count.textContent = stat.count ? `${stat.count} 篇文章` : "暂无文章";
name.append(strong, count);
const actions = document.createElement("div");
actions.className = "category-row-actions";
const rename = document.createElement("button");
rename.type = "button";
rename.className = "ghost";
rename.textContent = "重命名";
rename.onclick = () => showRenameCategory(row, stat);
const remove = document.createElement("button");
remove.type = "button";
remove.className = "ghost danger";
remove.textContent = "删除";
remove.disabled = categories.length <= 1;
remove.title = remove.disabled ? "至少需要保留一个分类" : "删除分类";
remove.onclick = () => showDeleteCategory(row, stat);
actions.append(rename, remove);
main.append(name, actions);
row.append(main);
list.append(row);
});
}
async function openCategoryManager() {
$("#manager-category-error").textContent = "";
show("#category-modal");
try { await loadCategoryManager(); }
catch (error) { $("#manager-category-error").textContent = error.message; }
}
$("#manage-categories").addEventListener("click", openCategoryManager);
$("#close-category-modal").addEventListener("click", () => hide("#category-modal"));
$("#manager-add-category").addEventListener("click", async () => {
const input = $("#manager-new-category");
const name = input.value.trim();
$("#manager-category-error").textContent = "";
try {
await triggerBuild("/categories", { action: "添加分类", body: { name } });
input.value = "";
await loadCategoryManager();
} catch (error) { $("#manager-category-error").textContent = error.message; }
});
$("#manager-new-category").addEventListener("keydown", (event) => {
if (event.key === "Enter") { event.preventDefault(); $("#manager-add-category").click(); }
});
function setView(editing) {
$("#list-view").classList.toggle("hidden", editing);
$("#edit-view").classList.toggle("hidden", !editing);
}
function setEditorStatus(status) {
editingStatus = status;
applyStatus($("#editor-status"), status);
$("#version-note").textContent = statusMap[status]?.[2] || "";
const persisted = status !== "new-unsaved";
$("#more-actions").classList.toggle("hidden", !persisted);
$("#more-actions").open = false;
$("#discard").classList.toggle("hidden", status !== "edited-draft");
$("#unpublish").classList.toggle("hidden", !["live", "edited-draft"].includes(status));
$("#delete-article").classList.toggle("hidden", !persisted);
}
async function openEditor(slug) {
editingSlug = slug;
dirty = false;
fillCategories();
$("#preview").innerHTML = "";
$("#message").textContent = "";
$("#upload-status").textContent = "";
if (slug) {
const article = await api(`/articles/${slug}`);
$("#edit-heading").textContent = "编辑文章";
$("#title").value = article.title;
fillCategories(article.category);
$("#body").value = article.body;
setEditorStatus(article.publishStatus);
} else {
$("#edit-heading").textContent = "新建文章";
$("#title").value = "";
$("#body").value = "";
setEditorStatus("new-unsaved");
}
setView(true);
schedulePreview(0);
$("#title").focus();
}
$("#new-article").addEventListener("click", () => openEditor(null));
$("#back").addEventListener("click", () => {
if (dirty && !confirm("还有未保存的内容,确定返回文章列表吗?")) return;
setView(false);
loadArticles();
});
function payload() {
return {
title: $("#title").value.trim(),
category: $("#category").value,
body: $("#body").value,
};
}
async function save() {
const result = editingSlug
? await api(`/articles/${editingSlug}`, { method: "PUT", body: payload() })
: await api("/articles", { method: "POST", body: payload() });
editingSlug = result.slug;
dirty = false;
setEditorStatus(result.publishStatus);
return result.slug;
}
$("#edit-form").addEventListener("submit", async (event) => {
event.preventDefault();
$("#message").textContent = "正在保存…";
try {
await save();
$("#message").textContent = editingStatus === "live" ? "内容与线上版本一致。" : "草稿已保存,线上版本没有改变。";
} catch (error) { $("#message").textContent = error.message; }
});
$("#publish").addEventListener("click", async () => {
$("#message").textContent = "正在保存草稿…";
try {
const slug = await save();
await triggerBuild(`/articles/${slug}/publish`);
setEditorStatus("live");
$("#message").textContent = "发布成功,官网已更新。";
} catch (error) { $("#message").textContent = error.message; }
});
$("#discard").addEventListener("click", async () => {
if (!editingSlug) return;
if (!confirm("确定放弃未发布的修改,恢复为当前线上版本吗?")) return;
try {
const result = await api(`/articles/${editingSlug}/draft`, { method: "DELETE" });
dirty = false;
if (result.removed) { setView(false); loadArticles(); }
else await openEditor(editingSlug);
} catch (error) { $("#message").textContent = error.message; }
});
$("#unpublish").addEventListener("click", async () => {
if (!editingSlug) return;
const detail = editingStatus === "edited-draft" ? "未发布的修改会继续保留为草稿。" : "文章内容会继续保留为草稿。";
if (!confirm(`确定将这篇文章从官网下架吗?${detail}`)) return;
$("#more-actions").open = false;
$("#message").textContent = "正在下架文章…";
try {
await triggerBuild(`/articles/${editingSlug}/unpublish`, { action: "下架" });
setEditorStatus("new-draft");
$("#message").textContent = "文章已下架,内容已保留为草稿。";
} catch (error) { $("#message").textContent = error.message; }
});
$("#delete-article").addEventListener("click", async () => {
if (!editingSlug) return;
const prompt = editingStatus === "new-draft"
? "确定彻底删除这篇草稿吗?删除后无法恢复。"
: "确定彻底删除这篇文章吗?线上版本和草稿都会删除,且无法恢复。";
if (!confirm(prompt)) return;
$("#more-actions").open = false;
$("#message").textContent = "正在删除文章…";
try {
await triggerBuild(`/articles/${editingSlug}`, { action: "删除", method: "DELETE" });
dirty = false;
editingSlug = null;
setView(false);
await loadArticles();
} catch (error) { $("#message").textContent = error.message; }
});
async function renderPreview() {
const body = $("#body").value.trim();
if (!body) { $("#preview").innerHTML = ""; return; }
const request = ++previewRequest;
try {
const result = await api("/preview", { method: "POST", body: { body } });
if (request === previewRequest) $("#preview").innerHTML = result.html;
} catch {
if (request === previewRequest) $("#preview").textContent = "预览暂时不可用";
}
}
function schedulePreview(delay = 350) {
clearTimeout(previewTimer);
previewTimer = setTimeout(renderPreview, delay);
}
$("#title").addEventListener("input", () => { dirty = true; });
$("#body").addEventListener("input", () => { dirty = true; schedulePreview(); });
$("#upload-button").addEventListener("click", () => $("#file").click());
$("#file").addEventListener("change", async (event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (file.size > MAX_UPLOAD_SIZE) {
$("#upload-status").textContent = "图片不能超过 20MB";
return;
}
$("#upload-status").textContent = "上传中…";
try {
const dataUrl = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
const result = await api("/uploads", { method: "POST", body: { dataUrl } });
const textarea = $("#body");
const start = textarea.selectionStart;
const markdown = `\n![${file.name}](${result.url})\n`;
textarea.value = textarea.value.slice(0, start) + markdown + textarea.value.slice(textarea.selectionEnd);
textarea.dispatchEvent(new Event("input"));
$("#upload-status").textContent = "图片已插入";
} catch (error) { $("#upload-status").textContent = error.message; }
});
async function triggerBuild(endpoint, { action = "发布", method = "POST", body } = {}) {
show("#build-modal");
hide("#close-modal");
$("#build-title").textContent = `正在${action}…`;
$("#build-log").textContent = "系统正在更新官网,请稍候。";
try {
await api(endpoint, { method, body });
} catch (error) {
$("#build-title").textContent = `${action}失败`;
$("#build-log").textContent = error.message;
show("#close-modal");
throw error;
}
while (true) {
await new Promise((resolve) => setTimeout(resolve, 1000));
const state = await api("/build");
if (state.status === "building") continue;
$("#build-title").textContent = state.ok ? `${action}成功` : `${action}失败`;
$("#build-log").textContent = state.ok ? "静态页面已经生成,官网内容已更新。" : (state.log || `${action}失败,请联系技术人员。`);
show("#close-modal");
if (!state.ok) throw new Error(`${action}失败,原文章已恢复,请检查操作日志。`);
return;
}
}
$("#close-modal").addEventListener("click", () => hide("#build-modal"));
boot();
:root {
--primary: #D1251A;
--primary-dark: #8E1C17;
--deep: #6F1511;
--light: #F4E6D6;
--bg: #F7F3EA;
--surface: #fff;
--text: #1E1916;
--muted: #6b7280;
--border: #E8E0D6;
--danger: #c04444;
font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; color: var(--text); background: var(--bg); }
.hidden { display: none !important; }
button, input, textarea, select { font: inherit; }
button, .button { min-height: 40px; padding: 0 17px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 999px; cursor: pointer; text-decoration: none; transition: .18s ease; }
.primary { color: white; background: var(--primary-dark); }
.primary:hover { background: var(--deep); transform: translateY(-1px); }
.ghost { color: var(--primary-dark); background: transparent; border: 1px solid var(--border); }
.ghost:hover { border-color: var(--primary); }
.small { min-height: 34px; padding-inline: 13px; font-size: 12px; }
.error { min-height: 20px; color: var(--danger); font-size: 13px; }
.login { min-height: 100vh; padding: 24px; display: grid; place-items: center; background: linear-gradient(135deg, #faf8f4, var(--light)); }
.login-card { width: min(420px, 100%); padding: 42px; display: grid; gap: 16px; background: white; border: 1px solid var(--border); border-radius: 22px; box-shadow: 0 24px 60px rgba(75,34,26,.12); }
.brand-mark { color: var(--primary); font-size: 14px; font-weight: 700; letter-spacing: .18em; }
.login-card h1 { margin: 0; font-size: 30px; }
.login-card p { margin: -8px 0 4px; color: var(--muted); }
.login-card label, .field-panel label, .field-label { display: grid; gap: 7px; color: var(--muted); font-size: 13px; }
.captcha-row { display: flex; align-items: stretch; gap: 10px; }
.captcha-row input { min-width: 0; flex: 1; text-transform: uppercase; }
#captcha-image { width: 132px; height: 46px; flex: 0 0 auto; cursor: pointer; background: var(--light); border: 1px solid var(--border); border-radius: 9px; }
input, textarea, select { width: 100%; padding: 11px 13px; color: var(--text); background: white; border: 1px solid var(--border); border-radius: 9px; }
input:focus, textarea:focus, select:focus { outline: 2px solid rgba(209,37,26,.14); border-color: var(--primary); }
.topbar { min-height: 68px; padding: 12px 28px; position: sticky; top: 0; z-index: 10; display: flex; align-items: center; justify-content: space-between; gap: 22px; color: white; background: #1E1916; }
.topbar > div:first-child { display: flex; align-items: center; gap: 13px; }
.topbar strong { color: var(--primary); font-size: 19px; }
.topbar span { font-size: 13px; opacity: .76; }
.top-actions { display: flex; align-items: center; gap: 9px; }
.topbar .ghost { color: white; border-color: rgba(255,255,255,.24); }
.pending-badge { padding: 5px 10px; color: #ffe49b; background: rgba(255,206,94,.12); border: 1px solid rgba(255,206,94,.3); border-radius: 999px; }
.view { width: min(1120px, calc(100% - 40px)); margin: 0 auto; padding: 44px 0 70px; }
.view-head { margin-bottom: 26px; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.view-head p { margin: 0 0 5px; color: var(--primary-dark); font-size: 11px; font-weight: 700; letter-spacing: .16em; }
.view-head h1 { margin: 0; font-size: 30px; }
.view-actions { display: flex; gap: 9px; }
.article-list { display: grid; gap: 10px; }
.article-row { padding: 18px 20px; display: flex; align-items: center; justify-content: space-between; gap: 20px; background: white; border: 1px solid var(--border); border-radius: 14px; }
.article-summary { min-width: 0; flex: 1; }
.article-row h2 { margin: 0 0 8px; font-size: 17px; }
.article-row p { margin: 0; display: flex; flex-wrap: wrap; gap: 8px 13px; align-items: center; color: var(--muted); font-size: 12px; }
.article-row code { color: #8d9995; }
.status { padding: 2px 8px; border-radius: 999px; }
.status.live { color: #188367; background: #e2f6ef; }
.status.new-draft { color: #916300; background: #fff2c7; }
.status.edited-draft { color: #9c4c22; background: #ffeadc; }
.row-actions { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
.row-actions button:disabled { cursor: default; opacity: .48; transform: none; }
.row-more-actions summary { min-height: 34px; padding-inline: 13px; font-size: 12px; }
.row-more-actions .action-menu { top: calc(100% + 8px); bottom: auto; z-index: 12; }
.empty { padding: 70px; color: var(--muted); text-align: center; background: white; border: 1px dashed var(--border); border-radius: 14px; }
#edit-form { display: grid; gap: 18px; }
.field-panel, .editor-panel { padding: 24px; background: white; border: 1px solid var(--border); border-radius: 16px; }
.field-panel { display: grid; gap: 15px; }
.field-panel small { font-weight: 400; opacity: .8; }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
.custom-select { position: relative; }
.select-trigger { width: 100%; padding: 0 13px; justify-content: space-between; color: var(--text); background: white; border: 1px solid var(--border); border-radius: 9px; text-align: left; }
.select-trigger:hover, .custom-select.open .select-trigger { border-color: var(--primary); }
.custom-select.open .select-trigger { outline: 2px solid rgba(209,37,26,.14); }
.select-chevron { color: var(--muted); transition: transform .18s ease; }
.custom-select.open .select-chevron { transform: rotate(180deg); }
.select-menu { width: 100%; margin-top: 7px; position: absolute; z-index: 20; overflow: hidden; background: white; border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 18px 45px rgba(75,34,26,.16); }
.select-options { max-height: 220px; padding: 7px; display: grid; gap: 2px; overflow-y: auto; }
.select-option { width: 100%; min-height: 40px; padding: 0 11px; justify-content: space-between; color: var(--text); background: transparent; border-radius: 8px; }
.select-option:hover { background: #FAF3E8; }
.select-option[aria-selected="true"] { color: var(--primary-dark); background: #FBE7E3; font-weight: 600; }
.option-check { opacity: 0; color: var(--primary-dark); }
.select-option[aria-selected="true"] .option-check { opacity: 1; }
.category-create { padding: 12px; background: #FAF7F1; border-top: 1px solid var(--border); }
.category-create > span { display: block; margin-bottom: 8px; color: var(--text); font-weight: 600; }
.category-create > div { display: flex; gap: 8px; }
.category-create input { min-width: 0; }
.category-create button { min-width: 68px; flex: 0 0 auto; white-space: nowrap; }
.category-create .error { min-height: 0; margin: 6px 2px 0; }
.editor-titlebar { align-items: flex-end; }
.editor-titlebar > div { display: grid; gap: 12px; }
.editor-titlebar > .status { margin-bottom: 4px; padding: 6px 12px; font-size: 13px; }
.back-button { min-height: auto; width: max-content; padding: 0; color: var(--muted); background: transparent; border-radius: 0; }
.back-button:hover { color: var(--primary-dark); }
.version-note { margin: -12px 0 18px; padding: 13px 16px; color: #52625e; background: #F8EEE6; border: 1px solid var(--border); border-radius: 11px; font-size: 13px; }
.auto-meta { margin: 0; padding: 11px 13px; color: var(--muted); background: #FAF7F1; border-radius: 9px; font-size: 12px; }
.editor-head { margin-bottom: 13px; display: flex; align-items: center; justify-content: space-between; gap: 15px; }
.editor-head > div { display: flex; align-items: center; gap: 9px; }
.editor-head small { color: var(--muted); font-size: 12px; font-weight: 400; }
#upload-status { color: var(--muted); font-size: 12px; }
.editor-grid { min-height: 480px; display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
#body { min-height: 480px; resize: vertical; font-family: inherit; font-size: 15px; line-height: 1.8; }
.preview { padding: 22px; overflow: auto; background: #fbfcfb; border: 1px solid var(--border); border-radius: 9px; line-height: 1.8; }
.preview:empty::before { content: "文章预览会显示在这里"; color: var(--muted); }
.preview h1, .preview h2, .preview h3 { color: var(--primary-dark); }
.preview img { max-width: 100%; }
.edit-actions { padding: 15px 18px; position: sticky; bottom: 14px; z-index: 5; display: flex; align-items: center; gap: 10px; background: rgba(255,255,255,.96); border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 12px 32px rgba(75,34,26,.1); backdrop-filter: blur(10px); }
.save-button { background: white; }
.more-actions { position: relative; }
.more-actions summary { min-height: 40px; padding: 0 15px; display: flex; align-items: center; color: var(--muted); background: white; border: 1px solid var(--border); border-radius: 999px; cursor: pointer; font-size: 13px; list-style: none; }
.more-actions summary::-webkit-details-marker { display: none; }
.more-actions summary::after { content: "⌄"; margin-left: 7px; transition: transform .18s ease; }
.more-actions[open] summary::after { transform: rotate(180deg); }
.more-actions[open] summary { color: var(--primary-dark); border-color: var(--primary); }
.action-menu { min-width: 180px; padding: 7px; position: absolute; right: 0; bottom: calc(100% + 8px); display: grid; gap: 2px; background: white; border: 1px solid var(--border); border-radius: 11px; box-shadow: 0 14px 36px rgba(75,34,26,.16); }
.menu-action { width: 100%; min-height: 38px; padding: 0 11px; justify-content: flex-start; color: var(--text); background: transparent; border-radius: 7px; font-size: 13px; }
.menu-action:hover { background: #FAF1EA; }
.menu-action.danger { color: var(--danger); }
.menu-action.danger:hover { background: #fff0f0; }
#message { margin-left: 8px; color: var(--muted); font-size: 13px; }
.modal { position: fixed; inset: 0; z-index: 30; padding: 20px; display: grid; place-items: center; background: rgba(0,0,0,.6); }
.modal-card { width: min(780px, 100%); max-height: 82vh; background: white; border-radius: 16px; overflow: hidden; }
.modal-card > div { padding: 15px 18px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); }
.category-manager { width: min(680px, 100%); }
.modal-card > .modal-head { padding: 18px 20px; }
.modal-head > div { display: grid; gap: 4px; }
.modal-head small { color: var(--muted); font-size: 12px; font-weight: 400; }
.modal-card > .category-manager-body { max-height: 68vh; padding: 20px; display: block; overflow-y: auto; border: 0; }
.manager-create { display: flex; gap: 9px; }
.manager-create input { min-width: 0; }
.manager-create button { flex: 0 0 auto; white-space: nowrap; }
.category-manager-list { margin-top: 8px; display: grid; gap: 8px; }
.category-manager-row { padding: 13px 14px; background: #FAF7F1; border: 1px solid var(--border); border-radius: 11px; }
.category-row-main { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.category-row-name { min-width: 0; display: grid; gap: 3px; }
.category-row-name strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.category-row-name span { color: var(--muted); font-size: 12px; }
.category-row-actions { display: flex; gap: 5px; }
.category-row-actions button { min-height: 32px; padding-inline: 10px; font-size: 12px; }
.category-row-actions .danger { color: var(--danger); }
.category-row-actions button:disabled { cursor: not-allowed; opacity: .45; }
.category-inline-panel { margin-top: 11px; padding-top: 11px; display: grid; gap: 9px; border-top: 1px solid var(--border); }
.category-inline-panel p { margin: 0; color: var(--muted); font-size: 12px; }
.category-rename-form { display: flex; gap: 8px; }
.category-rename-form input { min-width: 0; }
.category-inline-actions, .replacement-options { display: flex; flex-wrap: wrap; gap: 7px; }
.category-inline-actions button, .replacement-options button { min-height: 32px; padding-inline: 11px; font-size: 12px; }
.confirm-danger { color: white; background: var(--danger); }
.replacement-options button { color: var(--primary-dark); background: white; border: 1px solid var(--border); }
.replacement-options button:hover { border-color: var(--primary); background: #FBEAE6; }
.build-card { width: min(780px, 100%); }
#build-log { max-height: 62vh; margin: 0; padding: 18px; overflow: auto; color: var(--muted); background: #f7f9f8; white-space: pre-wrap; font-size: 12px; }
@media (max-width: 760px) {
.topbar, .view-head, .article-row { align-items: flex-start; flex-direction: column; }
.top-actions { width: 100%; flex-wrap: wrap; }
.view-actions { width: 100%; }
.view-actions button { flex: 1; }
.field-row, .editor-grid { grid-template-columns: 1fr; }
.view { width: min(100% - 28px, 1120px); padding-top: 28px; }
.row-actions { width: 100%; }
.row-actions > button, .row-more-actions { flex: 1; }
.row-more-actions summary { width: 100%; justify-content: center; }
.edit-actions { flex-wrap: wrap; }
#message { width: 100%; margin: 4px 0 0; }
.login-card { padding: 30px 24px; }
.manager-create, .category-row-main, .category-rename-form { align-items: stretch; flex-direction: column; }
}
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
<rect width="1200" height="630" fill="#F7F3EA"/>
<path d="M0 0h260L0 260zM1200 630H940l260-260z" fill="#D1251A"/>
<circle cx="968" cy="122" r="62" fill="none" stroke="#C6A84B" stroke-width="2"/>
<text x="105" y="260" fill="#1E1916" font-family="serif" font-size="92" font-weight="700">银妆银饰</text>
<text x="110" y="335" fill="#D1251A" font-family="sans-serif" font-size="30" letter-spacing="8">INZUNG SILVER</text>
<line x1="110" y1="390" x2="850" y2="390" stroke="#C6A84B"/>
<text x="110" y="455" fill="#4B4541" font-family="sans-serif" font-size="31">匠心锻银,承接每一份独一无二的定制构想</text>
</svg>
User-agent: *
Allow: /
Disallow: /admin/
Disallow: /api/
Sitemap: https://www.inzung.cn/sitemap-index.xml
{
"name": "银妆银饰",
"short_name": "银妆",
"description": "足银首饰、非遗手作与私人定制",
"start_url": "/",
"display": "standalone",
"background_color": "#F7F3EA",
"theme_color": "#D1251A"
}
import { buildSiteAtomic, withSiteBuildLock } from "./site-build.mjs";
try {
const build = () => buildSiteAtomic({ onOutput: (text) => process.stdout.write(text) });
if (process.argv.includes("--inside-lock")) {
if (process.env.YINZHUANG_SITE_LOCK_HELD !== "1") throw new Error("build:inside-lock 只能在共享构建锁中运行");
await build();
} else {
await withSiteBuildLock(build);
}
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
}
#!/usr/bin/env bash
set -Eeuo pipefail
deploy_root=${1:?缺少部署根目录}
pipeline_release=${2:-}
case "$deploy_root" in
/*) ;;
*) echo "部署根目录必须是绝对路径" >&2; exit 2 ;;
esac
deploy_root=$(readlink -f "$deploy_root")
releases_dir="$deploy_root/releases"
current_link="$deploy_root/current"
previous_marker="$deploy_root/shared/previous-release"
if [ -n "$pipeline_release" ]; then
pipeline_release=$(readlink -f "$pipeline_release")
case "$pipeline_release" in
"$releases_dir"/*) ;;
*) echo "本 pipeline 的 release 路径越界" >&2; exit 2 ;;
esac
fi
if [ ! -d "$releases_dir" ]; then
echo "[cleanup] Releases 目录不存在,无需清理"
exit 0
fi
current_target=$(readlink -f "$current_link" 2>/dev/null || true)
previous_target=""
if [ -f "$previous_marker" ]; then
previous_target=$(sed -n '1p' "$previous_marker")
if [ -n "$previous_target" ] && [ -d "$previous_target" ]; then
previous_target=$(readlink -f "$previous_target")
fi
fi
echo "[cleanup] 保留当前版本:${current_target:-}"
echo "[cleanup] 保留上一成功版本:${previous_target:-}"
for candidate in "$releases_dir"/*; do
[ -e "$candidate" ] || continue
if [ -L "$candidate" ] || [ ! -d "$candidate" ]; then
echo "[cleanup] 跳过非普通目录:$candidate"
continue
fi
resolved=$(readlink -f "$candidate")
case "$resolved" in
"$releases_dir"/*) ;;
*) echo "[cleanup] 跳过越界路径:$candidate"; continue ;;
esac
if [ "$resolved" = "$current_target" ] || { [ -n "$previous_target" ] && [ "$resolved" = "$previous_target" ]; }; then
echo "[cleanup] 保留:$resolved"
continue
fi
if [ "$resolved" = "$pipeline_release" ] || [ -f "$resolved/.deploy-success" ]; then
echo "[cleanup] 删除旧 Release:$resolved"
rm -rf -- "$resolved"
else
echo "[cleanup] 跳过未完成 Release(可能属于其他 pipeline):$resolved"
fi
done
echo "[cleanup] 清理完成,当前 Releases:"
find "$releases_dir" -mindepth 1 -maxdepth 1 -type d -print | sort
#!/usr/bin/env bash
set -Eeuo pipefail
deploy_root=${1:?缺少部署根目录}
release_dir=${2:?缺少 release 目录}
app_name=${3:?缺少 PM2 应用名}
pm2_bin=${4:?缺少 PM2 可执行文件路径}
pm2_home=${5:?缺少 PM2_HOME}
pm2_use_sudo=${6:-1}
healthcheck_url=${7:-http://127.0.0.1:${CMS_PORT:-8790}/}
pipeline_id=${8:?缺少 CI pipeline ID}
case "$deploy_root" in
/*) ;;
*) echo "部署根目录必须是绝对路径" >&2; exit 2 ;;
esac
case "$release_dir" in
"$deploy_root"/releases/*) ;;
*) echo "release 目录必须位于 $deploy_root/releases/ 下" >&2; exit 2 ;;
esac
case "$app_name" in
*[!A-Za-z0-9_.-]*|'') echo "PM2 应用名包含不安全字符" >&2; exit 2 ;;
esac
case "$pm2_bin" in
/*) ;;
*) echo "PM2 可执行文件必须是绝对路径" >&2; exit 2 ;;
esac
case "$pm2_home" in
/*) ;;
*) echo "PM2_HOME 必须是绝对路径" >&2; exit 2 ;;
esac
case "$pm2_use_sudo" in
0|1) ;;
*) echo "PM2_USE_SUDO 只能是 0 或 1" >&2; exit 2 ;;
esac
case "$pipeline_id" in
*[!0-9]*|'') echo "CI pipeline ID 必须是数字" >&2; exit 2 ;;
esac
shared_dir="$deploy_root/shared"
shared_env="$shared_dir/.env"
current_link="$deploy_root/current"
if [ "${YINZHUANG_SITE_LOCK_HELD:-}" != "1" ]; then
echo "部署必须通过 run-with-site-lock.mjs 执行" >&2
exit 2
fi
if [ ! -d "$release_dir" ] || [ ! -f "$release_dir/package-lock.json" ]; then
echo "release 目录不完整:$release_dir" >&2
exit 2
fi
if [ ! -f "$shared_env" ]; then
echo "缺少生产环境配置:$shared_env" >&2
exit 2
fi
case "${CMS_DATA_DIR:-}" in
"$shared_dir"/*) ;;
*) echo "CMS_DATA_DIR 必须位于 $shared_dir/ 下" >&2; exit 2 ;;
esac
case "${CMS_BUILD_LOCK:-}" in
"$shared_dir"/*) ;;
*) echo "CMS_BUILD_LOCK 必须位于 $shared_dir/ 下" >&2; exit 2 ;;
esac
if [ -e "$current_link" ] && [ ! -L "$current_link" ]; then
echo "$current_link 必须是软链接,拒绝覆盖真实目录" >&2
exit 2
fi
command -v curl >/dev/null
test -x "$pm2_bin"
if [ ! -f "$release_dir/.pipeline-id" ] || [ "$(cat "$release_dir/.pipeline-id")" != "$pipeline_id" ]; then
echo "release 的 pipeline 标识不匹配" >&2
exit 2
fi
pm2_command() {
if [ "$pm2_use_sudo" = "1" ]; then
sudo -n env PM2_HOME="$pm2_home" "$pm2_bin" "$@"
else
env PM2_HOME="$pm2_home" "$pm2_bin" "$@"
fi
}
# 只删除并重建 APP_NAME 对应的一个进程;不执行 pm2 kill、delete all、restart all 或 pm2 save。
start_current_app() {
if pm2_command describe "$app_name" >/dev/null 2>&1; then
pm2_command delete "$app_name"
fi
pm2_command start "$current_link/server.mjs" \
--name "$app_name" \
--cwd "$current_link" \
--node-args="--env-file=$shared_env"
}
previous_target=""
if [ -L "$current_link" ]; then
previous_target=$(readlink -f "$current_link")
fi
# GitLab 11.7 没有 resource_group。若较新的 pipeline 已上线,拒绝旧 pipeline 覆盖它。
if [ -n "$previous_target" ] && [ -f "$previous_target/.pipeline-id" ]; then
current_pipeline_id=$(cat "$previous_target/.pipeline-id")
case "$current_pipeline_id" in
*[!0-9]*|'') echo "当前版本的 pipeline 标识不合法" >&2; exit 2 ;;
esac
if [ "$current_pipeline_id" -gt "$pipeline_id" ]; then
echo "较新的 pipeline $current_pipeline_id 已部署,拒绝旧 pipeline $pipeline_id 覆盖" >&2
exit 3
fi
if [ "$current_pipeline_id" -eq "$pipeline_id" ] && [ "$previous_target" = "$release_dir" ]; then
echo "该 pipeline 已经部署,无需重复切换"
exit 0
fi
fi
switched=0
rollback() {
exit_code=$?
trap - ERR
if [ "$switched" = "1" ]; then
echo "新版本部署失败,正在恢复上一版本……" >&2
rollback_link="$deploy_root/.current-rollback-$$"
if [ -n "$previous_target" ] && [ -d "$previous_target" ]; then
ln -s "$previous_target" "$rollback_link"
mv -Tf "$rollback_link" "$current_link"
start_current_app || true
else
if pm2_command describe "$app_name" >/dev/null 2>&1; then
pm2_command delete "$app_name" || true
fi
echo "没有可恢复的上一版本,已停止本项目进程" >&2
fi
fi
exit "$exit_code"
}
trap rollback ERR
cd "$release_dir"
echo "[deploy] 使用共享用户内容执行最终构建"
npm run build:inside-lock
next_link="$deploy_root/.current-next-$$"
ln -s "$release_dir" "$next_link"
mv -Tf "$next_link" "$current_link"
switched=1
start_current_app
healthy=0
for attempt in $(seq 1 30); do
if curl --fail --silent --show-error --max-time 3 "$healthcheck_url" >/dev/null; then
healthy=1
break
fi
echo "等待服务启动... ($attempt/30)"
sleep 1
done
if [ "$healthy" != "1" ]; then
echo "健康检查失败:$healthcheck_url" >&2
pm2_command logs "$app_name" --lines 30 --nostream || true
false
fi
trap - ERR
previous_marker_tmp="$shared_dir/.previous-release-$$"
printf '%s\n' "$previous_target" > "$previous_marker_tmp"
mv -f "$previous_marker_tmp" "$shared_dir/previous-release"
printf '%s\n' "$pipeline_id" > "$release_dir/.deploy-success"
echo "部署成功:$release_dir"
import { spawn } from "node:child_process";
import { withSiteBuildLock } from "./site-build.mjs";
const separator = process.argv.indexOf("--");
const command = separator >= 0 ? process.argv.slice(separator + 1) : [];
if (!command.length) {
console.error("用法:node scripts/run-with-site-lock.mjs -- <部署命令> [参数]");
process.exit(2);
}
await withSiteBuildLock(() => new Promise((resolve, reject) => {
const child = spawn(command[0], command.slice(1), {
stdio: "inherit",
env: { ...process.env, YINZHUANG_SITE_LOCK_HELD: "1" },
});
child.on("error", reject);
child.on("exit", (code, signal) => {
if (code === 0) resolve();
else reject(new Error(signal ? `部署命令被信号 ${signal} 终止` : `部署命令退出码:${code}`));
});
})).catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
export interface BuildOptions {
onOutput?: (text: string) => void;
}
export class SiteBuildLockBusyError extends Error {}
export function tryAcquireSiteBuildLock(): Promise<(() => Promise<void>) | null>;
export function withSiteBuildLock<T>(task: () => Promise<T>, options?: { timeoutMs?: number }): Promise<T>;
export function buildSiteAtomic(options?: BuildOptions): Promise<string>;
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
const ROOT = process.cwd();
const RUNTIME_DIR = path.join(ROOT, ".runtime");
const DEFAULT_LOCK = process.env.CMS_DATA_DIR
? path.join(path.resolve(process.env.CMS_DATA_DIR), ".site-build.lock")
: path.join(RUNTIME_DIR, "site-build.lock");
const LOCK_FILE = path.resolve(process.env.CMS_BUILD_LOCK || DEFAULT_LOCK);
const RECOVERY_LOCK_FILE = `${LOCK_FILE}.recovery`;
const ASTRO_BIN = path.join(ROOT, "node_modules", "astro", "astro.js");
const DIST_DIR = path.join(ROOT, "dist");
const MAX_LOG_LENGTH = 2 * 1024 * 1024;
export class SiteBuildLockBusyError extends Error {
constructor() {
super("网站正在部署或生成静态页面,请稍后重试");
this.name = "SiteBuildLockBusyError";
}
}
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
async function fileExists(file) {
try { await fs.access(file); return true; } catch { return false; }
}
function processIsAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try { process.kill(pid, 0); return true; } catch { return false; }
}
async function removeStaleLock() {
let recoveryHandle;
try {
recoveryHandle = await fs.open(RECOVERY_LOCK_FILE, "wx");
const raw = await fs.readFile(LOCK_FILE, "utf8");
const lock = JSON.parse(raw);
const sameHost = lock.hostname === os.hostname();
if (sameHost && !processIsAlive(Number(lock.pid))) {
await fs.unlink(LOCK_FILE);
return true;
}
} catch (error) {
if (error.code === "ENOENT") return true;
if (error.code === "EEXIST") return false;
} finally {
if (recoveryHandle) {
await recoveryHandle.close().catch(() => {});
await fs.unlink(RECOVERY_LOCK_FILE).catch(() => {});
}
}
return false;
}
async function acquireSiteBuildLock(timeoutMs = 15 * 60 * 1000) {
await fs.mkdir(path.dirname(LOCK_FILE), { recursive: true });
const started = Date.now();
const token = crypto.randomBytes(12).toString("hex");
while (true) {
try {
const handle = await fs.open(LOCK_FILE, "wx");
await handle.writeFile(JSON.stringify({ token, pid: process.pid, hostname: os.hostname(), startedAt: new Date().toISOString() }));
await handle.close();
return async () => {
try {
const current = JSON.parse(await fs.readFile(LOCK_FILE, "utf8"));
if (current.token === token) await fs.unlink(LOCK_FILE);
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
};
} catch (error) {
if (error.code !== "EEXIST") throw error;
if (await removeStaleLock()) continue;
if (Date.now() - started >= timeoutMs) throw new SiteBuildLockBusyError();
await delay(300);
}
}
}
export async function tryAcquireSiteBuildLock() {
try {
return await acquireSiteBuildLock(0);
} catch (error) {
if (error instanceof SiteBuildLockBusyError) return null;
throw error;
}
}
export async function withSiteBuildLock(task, options = {}) {
const release = await acquireSiteBuildLock(options.timeoutMs);
try { return await task(); } finally { await release(); }
}
function runAstro(command, env, onOutput) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [ASTRO_BIN, command], { cwd: ROOT, env: { ...process.env, ...env } });
let log = "";
const append = (chunk) => {
const text = chunk.toString();
log = `${log}${text}`.slice(-MAX_LOG_LENGTH);
onOutput?.(text);
};
child.stdout.on("data", append);
child.stderr.on("data", append);
child.on("error", reject);
child.on("exit", (code) => code === 0 ? resolve(log) : reject(Object.assign(new Error(`astro ${command} 执行失败`), { log })));
});
}
export async function buildSiteAtomic(options = {}) {
await fs.mkdir(RUNTIME_DIR, { recursive: true });
const id = `${Date.now()}-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
const temporary = path.join(RUNTIME_DIR, `dist-${id}`);
const backup = path.join(RUNTIME_DIR, `dist-backup-${id}`);
let movedCurrent = false;
let log = "";
try {
log += await runAstro("check", {}, options.onOutput);
log += await runAstro("build", { YINZHUANG_BUILD_OUT_DIR: temporary }, options.onOutput);
if (await fileExists(DIST_DIR)) {
await fs.rename(DIST_DIR, backup);
movedCurrent = true;
}
try {
await fs.rename(temporary, DIST_DIR);
} catch (error) {
if (movedCurrent) await fs.rename(backup, DIST_DIR);
throw error;
}
if (movedCurrent) {
await fs.rm(backup, { recursive: true, force: true }).catch((error) => {
options.onOutput?.(`\n旧构建目录清理失败,可稍后手动清理:${error.message}\n`);
});
}
return log;
} catch (error) {
await fs.rm(temporary, { recursive: true, force: true });
if (movedCurrent && !await fileExists(DIST_DIR) && await fileExists(backup)) await fs.rename(backup, DIST_DIR);
if (error.log) error.log = `${log}${error.log}`.slice(-MAX_LOG_LENGTH);
else error.log = log;
throw error;
}
}
process.env.PORT ||= process.env.CMS_PORT || "8790";
process.env.HOST ||= "127.0.0.1";
await import("./dist/server/entry.mjs");
---
import { fmtDate, paginationWindow, type Article, type CategoryInfo } from "../lib/articles";
interface Props {
items: Article[];
categories: CategoryInfo[];
totalCount: number;
activeSlug: string | null;
currentPage: number;
lastPage: number;
basePath: string;
}
const { items, categories, totalCount, activeSlug, currentPage, lastPage, basePath } = Astro.props as Props;
const pageHref = (page: number) => page <= 1 ? `${basePath}/` : `${basePath}/page/${page}/`;
const pages = paginationWindow(currentPage, lastPage);
---
<nav class="article-tabs" aria-label="银饰志分类">
<a class:list={["article-tab", { active: activeSlug === null }]} href="/articles/">全部 <span>{totalCount}</span></a>
{categories.map((category) => (
<a class:list={["article-tab", { active: activeSlug === category.slug }]} href={`/articles/topic/${category.slug}/`}>
{category.name} <span>{category.count}</span>
</a>
))}
</nav>
{items.length ? (
<div class="article-grid">
{items.map((item, index) => (
<article class="article-card" data-reveal>
<a href={`/articles/${item.id}/`} aria-label={`阅读:${item.data.title}`}>
<div class="article-card__meta">
<span>{item.data.category}</span><time datetime={fmtDate(item.data.date)}>{fmtDate(item.data.date)}</time>
</div>
<div class="article-card__index">{String((currentPage - 1) * 9 + index + 1).padStart(2, "0")}</div>
<h2>{item.data.title}</h2>
<p>{item.data.excerpt}</p>
<strong>阅读全文 <span>↗</span></strong>
</a>
</article>
))}
</div>
) : <p class="article-empty">该分类暂时还没有文章。</p>}
{lastPage > 1 && (
<nav class="article-pagination" aria-label="资讯分页">
{currentPage > 1 ? <a href={pageHref(currentPage - 1)} rel="prev">←</a> : <span aria-hidden="true">←</span>}
{pages.map((page) => page === 0
? <span class="gap">…</span>
: page === currentPage
? <span class="active" aria-current="page">{page}</span>
: <a href={pageHref(page)}>{page}</a>
)}
{currentPage < lastPage ? <a href={pageHref(currentPage + 1)} rel="next">→</a> : <span aria-hidden="true">→</span>}
</nav>
)}
---
import Base from "../layouts/Base.astro";
import Header from "./Header.astro";
import Footer from "./Footer.astro";
import ArticleListing from "./ArticleListing.astro";
import { site } from "../data/site";
import { PAGE_SIZE, type Article, type CategoryInfo } from "../lib/articles";
interface Props { items: Article[]; categories: CategoryInfo[]; totalCount: number; activeSlug: string | null; activeName: string | null; currentPage: number; lastPage: number; basePath: string; }
const { items, categories, totalCount, activeSlug, activeName, currentPage, lastPage, basePath } = Astro.props as Props;
const heading = activeName ?? "银饰志";
const pageSuffix = currentPage > 1 ? `|第${currentPage}页` : "";
const title = `${heading}${pageSuffix}|${site.brand.name}`;
const description = activeName ? `银妆${activeName}分类文章,分享银饰选购、佩戴养护、传统工艺与定制灵感。` : "银妆银饰志:关于银饰选购、日常养护、传统银作工艺与私人定制的实用分享。";
const pageUrl = new URL(`${basePath}${currentPage > 1 ? `/page/${currentPage}` : ""}/`, Astro.site).href;
const jsonLd = [{ "@context": "https://schema.org", "@type": "CollectionPage", name: heading, description, url: pageUrl, inLanguage: "zh-CN", mainEntity: { "@type": "ItemList", itemListElement: items.map((item, index) => ({ "@type": "ListItem", position: (currentPage - 1) * PAGE_SIZE + index + 1, name: item.data.title, url: new URL(`/articles/${item.id}/`, Astro.site).href })) } }, { "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: [{ "@type": "ListItem", position: 1, name: "首页", item: Astro.site?.href }, { "@type": "ListItem", position: 2, name: "银饰志", item: new URL("/articles/", Astro.site).href }, ...(activeName ? [{ "@type": "ListItem", position: 3, name: activeName, item: pageUrl }] : [])] }];
---
<Base {title} {description} {jsonLd}><Header /><main id="main-content"><section class="page-hero article-hero" data-word="JOURNAL"><div class="container page-hero__inner" data-reveal><div class="breadcrumb"><a href="/">首页</a> / <a href="/articles/">银饰志</a>{activeName && ` / ${activeName}`}</div><div class="eyebrow"><span></span>INZUNG JOURNAL</div><h1>{heading}</h1><p>{description}</p></div></section><section class="section-pad section-cream"><div class="container"><ArticleListing {items} {categories} {totalCount} {activeSlug} {currentPage} {lastPage} {basePath} /></div></section></main><Footer /></Base>
---
import { site } from "../data/site";
interface Props { title?: string; text?: string; }
const { title = "让你的构想,成为可以长久佩戴的心意", text = "告诉我们用途、预算与喜欢的风格,银妆首饰顾问将一对一为你梳理定制方案。" } = Astro.props;
---
<section class="cta-band"><div class="container cta-band__inner" data-reveal><div><div class="eyebrow eyebrow--gold"><span></span>INZUNG CUSTOM</div><h2>{title}</h2><p>{text}</p></div><div class="cta-band__actions"><a class="button button--gold" href="tel:4008113922">致电 {site.contact.phone}</a><a class="text-link text-link--light" href="/contact/">查看联系信息 <span>↗</span></a></div></div></section>
---
import logo from "../../pic/LOGO-白底黄字.svg";
import { site } from "../data/site";
---
<footer class="site-footer">
<div class="container footer-grid">
<div class="footer-brand">
<a href="/" class="footer-logo" aria-label="银妆银饰首页"><img src={logo.src} width="1197" height="401" loading="lazy" alt="银妆银饰" /></a>
<p>让传统锻银技艺承载每一份值得珍藏的心意。</p>
<div class="footer-tags"><span>足银可复检</span><span>私人定制</span><span>终身养护</span></div>
</div>
<div><h2>探索银妆</h2><a href="/about/">品牌故事</a><a href="/services/">产品与定制</a><a href="/articles/">银饰志</a><a href="/faq/">常见问题</a></div>
<div><h2>银饰系列</h2>{site.products.map((item) => <a href={`/services/#${item.slug}`}>{item.name}</a>)}</div>
<div class="footer-contact"><h2>联系与到店</h2><a class="footer-phone" href="tel:4008113922">{site.contact.phone}</a><address>{site.contact.address}</address><a class="footer-arrow" href="/contact/">查看联系方式 <span>↗</span></a></div>
</div>
<div class="container footer-bottom"><p>© {new Date().getFullYear()} {site.brand.legalName}</p><p>网站展示价格与服务以实际门店沟通为准</p></div>
</footer>
---
import logo from "../../pic/LOGO-白底红字.png";
import { Image } from "astro:assets";
import { site } from "../data/site";
import { categoryToSlug, getCategoryNames } from "../lib/articles";
const path = Astro.url.pathname;
const isActive = (href: string) => href === "/" ? path === "/" : path.startsWith(href);
const categoryChildren = (await getCategoryNames()).map((label) => ({ href: `/articles/topic/${categoryToSlug(label)}/`, label }));
const nav = site.nav.map((item) => item.href === "/articles/" ? { ...item, children: [{ href: "/articles/", label: "全部文章" }, ...categoryChildren] } : item);
---
<div class="top-note">
<div class="container top-note__inner">
<span>十四年专注手工银饰 · 足银可复检 · 终身免费养护</span>
<a href="tel:4008113922">全国客服热线:{site.contact.phone}</a>
</div>
</div>
<header class="site-header" data-header>
<div class="container site-header__inner">
<a class="brand" href="/" aria-label="银妆银饰首页"><Image src={logo} width={308} height={119} format="webp" quality={90} alt="银妆银饰 INZUNG" /></a>
<nav class="desktop-nav" aria-label="主导航">
<ul>{nav.map((item) => <li class="nav-item">
<a class="nav-link" href={item.href} aria-current={isActive(item.href) ? "page" : undefined}>{item.label}<span class="nav-chevron" aria-hidden="true">⌄</span></a>
<div class="nav-dropdown"><ul>{item.children.map((child) => <li><a href={child.href}>{child.label}<span aria-hidden="true">↗</span></a></li>)}</ul></div>
</li>)}</ul>
</nav>
<a class="button button--small header-cta" href="tel:4008113922">立即咨询</a>
<details class="mobile-nav">
<summary aria-label="打开导航菜单"><span></span><span></span><span></span></summary>
<nav data-mobile-nav aria-label="移动端导航">{nav.map((item) => <div class="mobile-nav__group">
<a class="mobile-nav__primary" href={item.href} aria-current={isActive(item.href) ? "page" : undefined}>{item.label}</a>
<div class="mobile-subnav">{item.children.map((child) => <a href={child.href}>{child.label}</a>)}</div>
</div>)}<a class="button" href="tel:4008113922">拨打客服热线</a></nav>
</details>
</div>
</header>
---
import p1 from "../../pic/product/优先-画板 1.jpg";
import p2 from "../../pic/product/优先-画板 1(1).jpg";
import p3 from "../../pic/product/优先-画板 1(2).jpg";
import p4 from "../../pic/product/优先-画板 1(3).jpg";
import p5 from "../../pic/product/微信图片_20260508131930_560_199.jpg";
import p6 from "../../pic/product/微信图片_20260508131932_561_199.jpg";
import p7 from "../../pic/product/微信图片_20260508131934_564_199.jpg";
import p8 from "../../pic/product/微信图片_20260508131938_567_199.jpg";
import p9 from "../../pic/product/微信图片_20260508131941_571_199.jpg";
import p10 from "../../pic/product/微信图片_20260508131943_573_199.jpg";
import p11 from "../../pic/product/微信图片_20260508131948_578_199.jpg";
import p12 from "../../pic/product/22.jpg";
import p13 from "../../pic/product/800.jpg";
import p14 from "../../pic/product/WW .jpg";
import p15 from "../../pic/product/微信图片_20260330115633_1336_12.jpg";
import { Image } from "astro:assets";
const items = [
[p1, "三生三世银手镯", "足银手镯"], [p2, "猫屿寻珠手链", "银饰手链"], [p3, "亮面闭口竹节手镯", "足银手镯"], [p4, "平安喜乐宝宝对镯", "宝宝银饰"],
[p5, "鎏金花纹开口手镯", "古法工艺"], [p6, "简约双头开口手镯", "日常佩戴"], [p7, "鎏金錾花圆镯", "手工錾刻"], [p8, "蓝调极简推拉手镯", "现代银饰"],
[p9, "古法福纹实心手镯", "古法工艺"], [p10, "如意金纹开口手镯", "祝福礼赠"], [p11, "云纹轻盈开口手镯", "日常佩戴"], [p12, "珍珠鎏金耳钉", "银耳饰"],
[p13, "蜻蜓珍珠耳钉", "银耳饰"], [p14, "珍珠光环耳钉", "银耳饰"], [p15, "珍珠圆环耳钉", "银耳饰"],
] as const;
---
<div class="product-carousel" data-carousel data-reveal>
<div class="product-carousel__viewport"><div class="product-carousel__track" data-carousel-track>
{items.map(([image, name, category], index) => <article class="product-slide"><div class="product-slide__image"><Image src={image} widths={[320, 480, 720]} sizes="(max-width: 760px) 78vw, 390px" format="webp" quality={84} alt={name} loading={index < 4 ? "eager" : "lazy"} /></div><div class="product-slide__meta"><span>{category}</span><h3>{name}</h3></div></article>)}
</div></div>
<div class="product-carousel__controls"><div class="carousel-progress"><span data-carousel-progress></span></div><div><button type="button" data-carousel-prev aria-label="上一组产品">←</button><button type="button" data-carousel-next aria-label="下一组产品">→</button></div></div>
</div>
<script>
document.querySelectorAll('[data-carousel]').forEach((carousel) => {
const track = carousel.querySelector<HTMLElement>('[data-carousel-track]'); const progress = carousel.querySelector<HTMLElement>('[data-carousel-progress]');
const move = (direction: number) => { if (!track) return; track.scrollBy({ left: direction * Math.min(track.clientWidth * .82, 860), behavior: 'smooth' }); };
carousel.querySelector('[data-carousel-prev]')?.addEventListener('click', () => move(-1)); carousel.querySelector('[data-carousel-next]')?.addEventListener('click', () => move(1));
const sync = () => { if (!track || !progress) return; const ratio = track.scrollWidth <= track.clientWidth ? 1 : (track.scrollLeft + track.clientWidth) / track.scrollWidth; progress.style.width = `${Math.max(12, ratio * 100)}%`; };
track?.addEventListener('scroll', sync, { passive: true }); addEventListener('resize', sync); sync();
});
</script>
---
interface Props { eyebrow: string; title: string; description?: string; align?: "left" | "center"; light?: boolean; }
const { eyebrow, title, description, align = "left", light = false } = Astro.props;
---
<div class:list={["section-heading", `section-heading--${align}`, { "section-heading--light": light }]} data-reveal>
<div class="eyebrow"><span></span>{eyebrow}</div><h2>{title}</h2>{description && <p>{description}</p>}
</div>
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
const articleSchema = z.object({
title: z.string(),
slug: z.string(),
date: z.coerce.date(),
updated: z.coerce.date(),
category: z.string(),
author: z.string(),
excerpt: z.string(),
status: z.enum(["draft", "published"]),
});
const articles = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/articles" }),
schema: articleSchema,
});
const published = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/published" }),
schema: articleSchema,
});
export const collections = { articles, published };
---
title: "一件专属银饰,是怎样从想法变成实物的"
slug: "custom-silver-process"
date: 2026-07-26
updated: 2026-07-26
category: "定制灵感"
author: "银妆银饰"
excerpt: "从用途、预算到图案与工艺,理解银饰定制流程,能让沟通更高效,也让最终成品更接近心里的样子。"
status: "published"
---
银饰定制往往从一句很生活化的话开始:想把宝宝的名字刻在手镯上,想把旧银重新做成一对戒指,或想为长辈准备一件不撞款的礼物。
## 第一步:说清用途与期待
先说明佩戴者、使用场景、预算、交付时间与喜欢的风格。参考图可以帮助表达方向,但还需要结合银的材质特点、尺寸和工艺重新判断。
## 第二步:确认材质与结构
根据耐用性、佩戴舒适度与造型需要,确认使用足银 999、足银 9999 或 S925 等材质,并确定克重、圈口、链长、开合方式等结构信息。
## 第三步:敲定图案与工艺
刻字、錾刻、浮雕、花丝、磨砂与局部鎏金呈现出的质感不同。复杂图案还要考虑最小线宽、转折和佩戴磨损,不能只看平面效果。
## 第四步:制作、质检与交付
匠人完成锻打、焊接、修整和抛光后,会检查尺寸、结构与表面状态。交付时再确认成品重量、材质信息与后续养护方式。
好的定制不是把参考图原样复制,而是在想法、材质与手工之间找到最合适的平衡。
---
title: "挑选足银手镯,先看懂这四件事"
slug: "how-to-choose-silver-bracelet"
date: 2026-07-28
updated: 2026-07-28
category: "银饰选购"
author: "银妆银饰"
excerpt: "材质标识、克重结构、圈口尺寸与工艺价格,是挑选足银手镯时最值得先确认的四个维度。"
status: "published"
---
挑手镯不只是挑一个喜欢的花纹。材质是否说得清、圈口是否合适、结构是否适合日常佩戴,都会影响后续体验。
## 一、先看材质标识
足银产品应明确标注材质,并配套相应的质量信息。常见的足银 999、足银 9999 与 S925 银在含银量、硬度和适用款式上各有不同,不必只追求更高数字,而应结合产品结构和佩戴需求选择。
## 二、克重与结构一起看
足银质地较软。同样的克重,实心、空心、开口、闭口和推拉结构的受力方式并不相同。日常通勤佩戴时,可以重点询问容易受力的位置、是否方便调整以及后续整形方式。
## 三、圈口要实际测量
手掌最宽处、腕围和个人佩戴松紧偏好都会影响圈口选择。第一次购买,建议到店试戴或在顾问指导下测量,不要只凭身高体重猜尺寸。
## 四、价格要拆开理解
成品银饰通常由银料、克重与工艺共同构成。光面素圈与古法、錾刻、浮雕、花丝等复杂工艺所需的人工和损耗不同。确认计价时,可分别了解银料、工费和附加定制内容。
把这四件事问清楚,再去比较款式,往往更容易选到适合长期佩戴的手镯。
---
title: "银饰为什么会发黑?日常养护这样做"
slug: "why-silver-tarnishes"
date: 2026-07-27
updated: 2026-07-27
category: "佩戴养护"
author: "银妆银饰"
excerpt: "银饰氧化并不等于材质有问题。了解发黑原因和正确的收纳、清洁方式,能让银饰更长久地保持光泽。"
status: "published"
---
银会与空气中的含硫物质发生反应,表面逐渐出现灰暗或黑色氧化层。汗液、化妆品、温泉水、潮湿环境和长期裸露收纳,都可能加快这一过程。
## 佩戴时减少化学接触
洗澡、游泳、泡温泉或做家务时,可以暂时取下银饰。香水、发胶和护肤品使用后,待皮肤表面干燥再佩戴。
## 不佩戴时单独密封
用柔软布料擦去表面汗液后,放入密封袋或首饰盒。不同硬度、不同表面工艺的首饰尽量分开,避免互相摩擦。
## 清洁方式要看工艺
光面素银可用专业擦银布轻拭;做旧、鎏金、珐琅、珍珠或镶嵌款,不建议自行使用强效清洁液,以免影响原有效果。拿不准时,优先交给专业门店处理。
银妆为所售银饰提供长期清洗、抛光与去氧化养护。日常轻柔佩戴、正确收纳,再配合专业养护,银饰就能一直陪伴重要时刻。
[
"银饰选购",
"佩戴养护",
"传统工艺",
"定制灵感"
]
---
title: "一件专属银饰,是怎样从想法变成实物的"
slug: "custom-silver-process"
date: 2026-07-26
updated: 2026-07-26
category: "定制灵感"
author: "银妆银饰"
excerpt: "从用途、预算到图案与工艺,理解银饰定制流程,能让沟通更高效,也让最终成品更接近心里的样子。"
status: "published"
---
银饰定制往往从一句很生活化的话开始:想把宝宝的名字刻在手镯上,想把旧银重新做成一对戒指,或想为长辈准备一件不撞款的礼物。
## 第一步:说清用途与期待
先说明佩戴者、使用场景、预算、交付时间与喜欢的风格。参考图可以帮助表达方向,但还需要结合银的材质特点、尺寸和工艺重新判断。
## 第二步:确认材质与结构
根据耐用性、佩戴舒适度与造型需要,确认使用足银 999、足银 9999 或 S925 等材质,并确定克重、圈口、链长、开合方式等结构信息。
## 第三步:敲定图案与工艺
刻字、錾刻、浮雕、花丝、磨砂与局部鎏金呈现出的质感不同。复杂图案还要考虑最小线宽、转折和佩戴磨损,不能只看平面效果。
## 第四步:制作、质检与交付
匠人完成锻打、焊接、修整和抛光后,会检查尺寸、结构与表面状态。交付时再确认成品重量、材质信息与后续养护方式。
好的定制不是把参考图原样复制,而是在想法、材质与手工之间找到最合适的平衡。
---
title: "挑选足银手镯,先看懂这四件事"
slug: "how-to-choose-silver-bracelet"
date: 2026-07-28
updated: 2026-07-28
category: "银饰选购"
author: "银妆银饰"
excerpt: "材质标识、克重结构、圈口尺寸与工艺价格,是挑选足银手镯时最值得先确认的四个维度。"
status: "published"
---
挑手镯不只是挑一个喜欢的花纹。材质是否说得清、圈口是否合适、结构是否适合日常佩戴,都会影响后续体验。
## 一、先看材质标识
足银产品应明确标注材质,并配套相应的质量信息。常见的足银 999、足银 9999 与 S925 银在含银量、硬度和适用款式上各有不同,不必只追求更高数字,而应结合产品结构和佩戴需求选择。
## 二、克重与结构一起看
足银质地较软。同样的克重,实心、空心、开口、闭口和推拉结构的受力方式并不相同。日常通勤佩戴时,可以重点询问容易受力的位置、是否方便调整以及后续整形方式。
## 三、圈口要实际测量
手掌最宽处、腕围和个人佩戴松紧偏好都会影响圈口选择。第一次购买,建议到店试戴或在顾问指导下测量,不要只凭身高体重猜尺寸。
## 四、价格要拆开理解
成品银饰通常由银料、克重与工艺共同构成。光面素圈与古法、錾刻、浮雕、花丝等复杂工艺所需的人工和损耗不同。确认计价时,可分别了解银料、工费和附加定制内容。
把这四件事问清楚,再去比较款式,往往更容易选到适合长期佩戴的手镯。
---
title: "银饰为什么会发黑?日常养护这样做"
slug: "why-silver-tarnishes"
date: 2026-07-27
updated: 2026-07-27
category: "佩戴养护"
author: "银妆银饰"
excerpt: "银饰氧化并不等于材质有问题。了解发黑原因和正确的收纳、清洁方式,能让银饰更长久地保持光泽。"
status: "published"
---
银会与空气中的含硫物质发生反应,表面逐渐出现灰暗或黑色氧化层。汗液、化妆品、温泉水、潮湿环境和长期裸露收纳,都可能加快这一过程。
## 佩戴时减少化学接触
洗澡、游泳、泡温泉或做家务时,可以暂时取下银饰。香水、发胶和护肤品使用后,待皮肤表面干燥再佩戴。
## 不佩戴时单独密封
用柔软布料擦去表面汗液后,放入密封袋或首饰盒。不同硬度、不同表面工艺的首饰尽量分开,避免互相摩擦。
## 清洁方式要看工艺
光面素银可用专业擦银布轻拭;做旧、鎏金、珐琅、珍珠或镶嵌款,不建议自行使用强效清洁液,以免影响原有效果。拿不准时,优先交给专业门店处理。
银妆为所售银饰提供长期清洗、抛光与去氧化养护。日常轻柔佩戴、正确收纳,再配合专业养护,银饰就能一直陪伴重要时刻。
export const site = {
brand: {
name: "银妆银饰",
shortName: "银妆",
englishName: "INZUNG",
legalName: "长沙银妆文化发展有限公司",
tagline: "匠心锻银,承接每一份独一无二的定制构想",
industry: "足银首饰 · 非遗手作 · 私人定制 · 终身养护",
},
home: {
eyebrow: "十四年专注手工银饰",
headline: "把重要的心意,锻造成独一无二的银饰",
subhead:
"从足银首饰零售、来料加工到专属雕刻与 DIY 编织,银妆以透明用料、匠人手作和长效售后,让每一件银饰都经得起时间。",
stats: [
{ value: "14年", label: "银饰行业积淀" },
{ value: "200+", label: "线下直营门店" },
{ value: "100名", label: "非遗手工匠人" },
{ value: "200万+", label: "累计服务消费者" },
],
},
products: [
{
slug: "silver-bracelet",
index: "01",
name: "足银手镯",
intro: "足银 999/9999 精工打造,多种圈口、克重与工艺可选,支持刻字、錾刻与来料改造。",
features: ["国标足银,可复检", "圈口与克重丰富", "刻字、图案与旧银改造"],
specification: "儿童镯 30–46mm;成人常用圈口 52–64mm;光面、古法、磨砂、浮雕、錾刻、花丝等工艺。",
},
{
slug: "diy-weaving",
index: "02",
name: "银饰 DIY 编织",
intro: "自选银珠、寓意配件与多色线材,现场测量手围、亲手编织,把参与感也留在礼物里。",
features: ["数十种线材配色", "足银配件自由组合", "终身免费换绳修护"],
specification: "棉绳、玉线、蜡绳、金刚绳等多种材质,适配儿童、女士与男士手围。",
},
{
slug: "silver-earrings",
index: "03",
name: "银耳环",
intro: "S925 与足银系列覆盖耳钉、耳圈、耳坠,兼顾日常穿搭、纪念定制与节日送礼。",
features: ["双银材质可选", "敏感肌友好工艺", "造型与刻字定制"],
specification: "耳针无镍无铅,并做防过敏处理;款式、花纹与吊牌可按需求搭配。",
},
{
slug: "silver-necklace",
index: "04",
name: "银项链",
intro: "O 字链、肖邦链、蛇骨链等多种链型,搭配丰富银吊坠,长短可选并支持专属镌刻。",
features: ["链型与长度丰富", "吊坠自由搭配", "名字、日期与图案镌刻"],
specification: "S925、足银 999 双材质可选,每件产品配套质检证书与长期养护服务。",
},
],
about: {
heading: "不只卖一件银饰,更为一段记忆负责",
story: [
"银妆深耕银饰行业 14 年,集原创设计、足银首饰零售、来料私人加工与银饰 DIY 编织于一体。我们直面银饰纯度难核验、价格不透明、量产款同质化和售后养护缺失等问题,让消费者更安心地挑选、定制与长期佩戴银饰。",
"品牌采用可复检的足银 999/9999 原料,每件产品配套质检证书,银价与工费清晰公示。百名非遗手工匠人与五百名专业首饰顾问,持续为自戴、送礼、婚恋、生辰、长辈纪念和旧银改造等需求提供一站式服务。",
"我们相信,银饰真正的价值不只在克重与工艺,更在它承载的情感。银妆希望以传统锻银技艺,让亲情、爱情与成长记忆拥有可以长久佩戴的形状。",
],
advantages: [
{ title: "一站式定制", desc: "设计、打制、交付与售后贯通,支持现场手作、来料加工和专属雕刻。" },
{ title: "专业匠人团队", desc: "100 名非遗手工匠人与 500 名首饰顾问,为不同需求提供专业建议。" },
{ title: "终身养护", desc: "提供终身免费清洗、抛光与翻新养护,氧化和旧色均可到店处理。" },
],
},
promises: [
{ number: "01", title: "真材实料", desc: "采用足银 999/9999 国标银料,产品附质检证书,支持复检。" },
{ number: "02", title: "透明计价", desc: "银料、克重与工费清晰说明,现场称重,不虚标、不隐瞒。" },
{ number: "03", title: "专属定制", desc: "从刻字、錾刻到旧银改造,以手工技艺实现每一份独特构想。" },
{ number: "04", title: "长久陪伴", desc: "终身免费清洗、抛光、去氧化和基础整形,陪银饰历久弥新。" },
],
scenes: [
{ title: "生辰纪念", desc: "把名字、日期与祝福刻进宝宝银镯、成长手绳或成人礼银饰。" },
{ title: "情侣信物", desc: "从对戒到专属吊牌,双向参与设计,让纪念不止一种样子。" },
{ title: "长辈贺礼", desc: "以足银手镯、纪念银器承载祝寿与感恩,兼具心意与分量。" },
{ title: "旧银新生", desc: "将闲置旧银重新设计、熔炼打制,让熟悉的材质延续新的故事。" },
],
faq: [
{
q: "你们的银饰是真银吗?",
a: "银妆采用足银 999/9999 与 S925 等明确标识的银料,每件产品配套质检证书。购买时会说明材质、克重与工艺,足银产品支持复检。",
},
{
q: "银饰氧化发黑或佩戴不适怎么办?",
a: "银饰在佩戴过程中氧化属于正常现象。银妆提供终身免费清洗、抛光与去氧化服务,并配套日常养护建议。敏感肌建议到店试戴,并根据实际情况选择合适材质。",
},
{
q: "银手镯多少钱一克?",
a: "成品价格由当日银料、克重和工艺共同构成,没有统一固定单价。光面素圈与古法、雕花、推拉等复杂工艺的人工和损耗不同,到店会现场称重并把银料与工费说明清楚。",
},
{
q: "质量有问题如何处理?",
a: "货品本身存在做工瑕疵等质量问题,可凭购买凭证按门店售后规则处理。人为磕碰、拉扯造成的损坏可评估维修;氧化清洗与基础养护长期免费。",
},
{
q: "足银手镯容易变形或断裂吗?",
a: "足银质地较软,稳定性与克重、结构和佩戴方式有关。日常佩戴避免用力掰折和重压即可;如有轻微变形,可携带至门店检查和调整。",
},
{
q: "可以拿自己的旧银来加工吗?",
a: "可以。门店会先确认来料状态、重量和可实现的工艺,再沟通款式、损耗、工费与交付时间。复杂图案或特殊结构建议提前预约。",
},
],
contact: {
website: "www.inzung.cn",
phone: "400-811-3922",
address: "湖南省长沙市芙蓉区火星街道万家丽中路一段3号建安大厦1705房",
locality: "芙蓉区",
region: "湖南省长沙市",
heading: "赴一场银饰之约",
intro: "到店体验古法锻银、DIY 编织,或在线沟通你的定制构想。银妆首饰顾问将一对一为你梳理材质、款式、预算与交付方案。",
douyin: ["银妆福运银饰", "银妆福运银饰福利号"],
},
nav: [
{ href: "/", label: "首页", children: [{ href: "/#products", label: "银饰系列" }, { href: "/#craft", label: "匠心工艺" }, { href: "/#stories", label: "定制场景" }] },
{ href: "/services/", label: "产品与定制", children: [{ href: "/services/#silver-bracelet", label: "足银手镯" }, { href: "/services/#diy-weaving", label: "DIY 编织" }, { href: "/services/#silver-earrings", label: "银耳环" }, { href: "/services/#silver-necklace", label: "银项链" }] },
{ href: "/about/", label: "关于银妆", children: [{ href: "/about/#story", label: "品牌故事" }, { href: "/about/#advantages", label: "核心优势" }, { href: "/about/#values", label: "品牌初心" }] },
{ href: "/articles/", label: "银饰志", children: [{ href: "/articles/", label: "全部文章" }] },
{ href: "/join/", label: "门店与服务", children: [{ href: "/join/#service-promise", label: "服务承诺" }, { href: "/join/#service-flow", label: "定制流程" }, { href: "/contact/", label: "联系门店" }] },
{ href: "/faq/", label: "常见问题", children: [{ href: "/faq/", label: "选购与定制 FAQ" }] },
{ href: "/contact/", label: "联系我们", children: [{ href: "/contact/", label: "联系方式" }, { href: "tel:4008113922", label: "拨打客服热线" }] },
],
} as const;
---
import "../styles/global.css";
import logo from "../../pic/LOGO-白底红字.png";
import favicon from "../../pic/LOGO-红底白字-底较大.png";
import { getImage } from "astro:assets";
import { site } from "../data/site";
interface Props {
title: string;
description: string;
image?: string;
ogType?: "website" | "article";
noindex?: boolean;
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
}
const { title, description, image = logo.src, ogType = "website", noindex = false, jsonLd = [] } = Astro.props;
const optimizedLogo = await getImage({ src: logo, width: 320, format: "webp", quality: 90 });
const optimizedFavicon = await getImage({ src: favicon, width: 128, height: 128, format: "png" });
const canonical = new URL(Astro.url.pathname, Astro.site).href;
const ogImage = new URL(image, Astro.site).href;
const organizationLd = {
"@context": "https://schema.org",
"@type": ["JewelryStore", "Organization"],
"@id": new URL("/#organization", Astro.site).href,
name: site.brand.name,
alternateName: [site.brand.shortName, site.brand.englishName],
legalName: site.brand.legalName,
url: Astro.site?.href,
logo: new URL(optimizedLogo.src, Astro.site).href,
slogan: site.brand.tagline,
description: site.home.subhead,
telephone: site.contact.phone,
address: {
"@type": "PostalAddress",
streetAddress: site.contact.address,
addressLocality: site.contact.locality,
addressRegion: site.contact.region,
addressCountry: "CN",
},
};
const pageLd = Array.isArray(jsonLd) ? jsonLd : [jsonLd];
const allLd = [organizationLd, ...pageLd.filter((item) => Object.keys(item).length > 0)];
---
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#F7F3EA" />
<meta name="color-scheme" content="light" />
<title>{title}</title>
<meta name="description" content={description} />
<meta name="robots" content={noindex ? "noindex,nofollow" : "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1"} />
<link rel="canonical" href={canonical} />
<link rel="icon" href={optimizedFavicon.src} type="image/png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="alternate" hreflang="zh-CN" href={canonical} />
<link rel="alternate" hreflang="x-default" href={canonical} />
<meta property="og:type" content={ogType} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={ogImage} />
<meta property="og:site_name" content={site.brand.name} />
<meta property="og:locale" content="zh_CN" />
<meta name="twitter:card" content="summary_large_image" />
<script type="application/ld+json" set:html={JSON.stringify(allLd)} is:inline></script>
</head>
<body>
<a class="skip-link" href="#main-content">跳到主要内容</a>
<slot />
<script is:inline>
const header = document.querySelector('[data-header]');
const syncHeader = () => header?.classList.toggle('is-scrolled', window.scrollY > 20);
addEventListener('scroll', syncHeader, { passive: true }); syncHeader();
const observer = 'IntersectionObserver' in window ? new IntersectionObserver((entries) => entries.forEach((entry) => {
if (entry.isIntersecting) { entry.target.classList.add('is-visible'); observer.unobserve(entry.target); }
}), { threshold: 0.1 }) : null;
document.querySelectorAll('[data-reveal]').forEach((el) => observer ? observer.observe(el) : el.classList.add('is-visible'));
document.querySelectorAll('[data-mobile-nav] a').forEach((link) => link.addEventListener('click', () => link.closest('details')?.removeAttribute('open')));
</script>
</body>
</html>
import crypto from "node:crypto";
import path from "node:path";
import fs from "node:fs/promises";
import matter from "gray-matter";
import { marked } from "marked";
const ROOT = process.cwd();
const DATA_ROOT = process.env.CMS_DATA_DIR ? path.resolve(process.env.CMS_DATA_DIR) : null;
const SOURCE_ARTICLES_DIR = path.join(ROOT, "src", "content", "articles");
const SOURCE_PUBLISHED_DIR = path.join(ROOT, "src", "content", "published");
export const ARTICLES_DIR = DATA_ROOT ? path.join(DATA_ROOT, "articles") : path.join(ROOT, "src", "content", "articles");
export const PUBLISHED_DIR = DATA_ROOT ? path.join(DATA_ROOT, "published") : path.join(ROOT, "src", "content", "published");
export const CATEGORIES_FILE = DATA_ROOT ? path.join(DATA_ROOT, "categories.json") : path.join(ROOT, "src", "content", "categories.json");
export const UPLOADS_DIR = DATA_ROOT ? path.join(DATA_ROOT, "uploads") : path.join(ROOT, "public", "uploads");
const DEFAULT_CATEGORIES = ["银饰选购", "佩戴养护", "传统工艺", "定制灵感"];
const DEFAULT_AUTHOR = "银妆银饰";
const CATEGORY_SLUGS: Record<string, string> = {
银饰选购: "silver-guide",
佩戴养护: "silver-care",
传统工艺: "silver-craft",
定制灵感: "custom-stories",
};
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export type PublishStatus = "new-draft" | "edited-draft" | "live";
export interface StoredArticle {
slug: string;
title: string;
date: string;
updated: string;
category: string;
author: string;
excerpt: string;
status: "draft" | "published";
body: string;
}
export interface Article {
id: string;
data: {
title: string;
slug: string;
date: Date;
updated: Date;
category: string;
author: string;
excerpt: string;
status: "published";
};
body: string;
}
export interface CategoryStat {
name: string;
count: number;
}
export interface ContentSnapshot {
restore: () => Promise<void>;
}
export class StoreError extends Error {
status: number;
details?: Record<string, unknown>;
constructor(message: string, status = 400, details?: Record<string, unknown>) {
super(message);
this.status = status;
this.details = details;
}
}
export const safeSlug = (slug: unknown): string | null => typeof slug === "string" && SLUG_RE.test(slug) ? slug : null;
export const fmtDate = (value?: string | Date): string => {
const date = value instanceof Date ? value : new Date(value || Date.now());
return Number.isNaN(date.valueOf()) ? new Date().toISOString().slice(0, 10) : date.toISOString().slice(0, 10);
};
const autoExcerpt = (body: string, fallback: string): string => {
const plain = String(body || "")
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/<[^>]+>/g, " ")
.replace(/[`*_>#~|\-]+/g, " ")
.replace(/\s+/g, " ")
.trim();
const text = plain || String(fallback || "").trim();
return text.length > 120 ? `${text.slice(0, 120).trim()}…` : text;
};
function serialize(data: StoredArticle, body: string): string {
const quote = (value: unknown) => JSON.stringify(value == null ? "" : String(value));
return [
"---",
`title: ${quote(data.title)}`,
`slug: ${quote(data.slug)}`,
`date: ${fmtDate(data.date)}`,
`updated: ${fmtDate(data.updated || data.date)}`,
`category: ${quote(data.category)}`,
`author: ${quote(data.author || DEFAULT_AUTHOR)}`,
`excerpt: ${quote(data.excerpt)}`,
`status: ${quote(data.status || "draft")}`,
"---",
"",
String(body || "").trim(),
"",
].join("\n");
}
async function fileExists(file: string): Promise<boolean> {
try { await fs.access(file); return true; } catch { return false; }
}
async function writeAtomic(file: string, content: string | Buffer): Promise<void> {
await fs.mkdir(path.dirname(file), { recursive: true });
const temporary = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
try {
await fs.writeFile(temporary, content);
await fs.rename(temporary, file);
} catch (error) {
await fs.unlink(temporary).catch(() => {});
throw error;
}
}
async function ensureStore(): Promise<void> {
await Promise.all([
fs.mkdir(ARTICLES_DIR, { recursive: true }),
fs.mkdir(PUBLISHED_DIR, { recursive: true }),
fs.mkdir(UPLOADS_DIR, { recursive: true }),
]);
if (DATA_ROOT) {
const seedDirectory = async (source: string, target: string) => {
const current = await fs.readdir(target);
if (current.some((file) => file.endsWith(".md"))) return;
const seeds = await fs.readdir(source).catch(() => [] as string[]);
await Promise.all(seeds.filter((file) => file.endsWith(".md")).map((file) => fs.copyFile(path.join(source, file), path.join(target, file))));
};
await Promise.all([
seedDirectory(SOURCE_ARTICLES_DIR, ARTICLES_DIR),
seedDirectory(SOURCE_PUBLISHED_DIR, PUBLISHED_DIR),
]);
}
if (!await fileExists(CATEGORIES_FILE)) {
await writeAtomic(CATEGORIES_FILE, `${JSON.stringify(DEFAULT_CATEGORIES, null, 2)}\n`);
}
}
export async function getCategoryNames(): Promise<string[]> {
await ensureStore();
try {
const parsed: unknown = JSON.parse(await fs.readFile(CATEGORIES_FILE, "utf8"));
const names = Array.isArray(parsed)
? parsed.map((value) => String(value).replace(/\s+/g, " ").trim()).filter(Boolean)
: [];
return [...new Set(names)].length ? [...new Set(names)] : [...DEFAULT_CATEGORIES];
} catch {
return [...DEFAULT_CATEGORIES];
}
}
async function saveCategories(categories: string[]): Promise<void> {
await writeAtomic(CATEGORIES_FILE, `${JSON.stringify(categories, null, 2)}\n`);
}
function normalizeCategoryName(value: unknown): string {
const name = String(value || "").replace(/\s+/g, " ").trim();
if (!name) throw new StoreError("请输入分类名称");
if (name.length > 20) throw new StoreError("分类名称不能超过 20 个字");
return name;
}
export async function addCategory(value: unknown): Promise<string[]> {
const name = normalizeCategoryName(value);
const categories = await getCategoryNames();
if (categories.includes(name)) throw new StoreError("该分类已经存在", 409);
const next = [...categories, name];
await saveCategories(next);
return next;
}
export async function readArticleFrom(directory: string, slug: string): Promise<StoredArticle> {
const raw = await fs.readFile(path.join(directory, `${slug}.md`), "utf8");
const { data, content } = matter(raw);
return {
slug,
title: String(data.title || ""),
date: fmtDate(data.date),
updated: fmtDate(data.updated || data.date),
category: String(data.category || ""),
author: String(data.author || DEFAULT_AUTHOR),
excerpt: String(data.excerpt || ""),
status: data.status === "draft" ? "draft" : "published",
body: content.trim(),
};
}
export const readWorkingArticle = (slug: string) => readArticleFrom(ARTICLES_DIR, slug);
async function markdownFiles(directory: string): Promise<string[]> {
await ensureStore();
return (await fs.readdir(directory)).filter((file) => file.endsWith(".md"));
}
async function snapshotDirectory(directory: string): Promise<Map<string, string>> {
const snapshot = new Map<string, string>();
for (const filename of await markdownFiles(directory)) {
snapshot.set(filename, await fs.readFile(path.join(directory, filename), "utf8"));
}
return snapshot;
}
async function restoreDirectory(directory: string, snapshot: Map<string, string>): Promise<void> {
for (const filename of await markdownFiles(directory)) {
if (!snapshot.has(filename)) await fs.unlink(path.join(directory, filename));
}
for (const [filename, content] of snapshot) await writeAtomic(path.join(directory, filename), content);
}
export async function createContentSnapshot(): Promise<ContentSnapshot> {
await ensureStore();
const articles = await snapshotDirectory(ARTICLES_DIR);
const published = await snapshotDirectory(PUBLISHED_DIR);
const categories = await fs.readFile(CATEGORIES_FILE, "utf8");
return {
restore: async () => {
await restoreDirectory(ARTICLES_DIR, articles);
await restoreDirectory(PUBLISHED_DIR, published);
await writeAtomic(CATEGORIES_FILE, categories);
},
};
}
const sameArticleContent = (left: StoredArticle, right: StoredArticle): boolean =>
["slug", "title", "date", "category", "author", "excerpt", "body"]
.every((key) => String(left[key as keyof StoredArticle] ?? "") === String(right[key as keyof StoredArticle] ?? ""));
export async function getPublishStatus(article: StoredArticle): Promise<PublishStatus> {
const liveFile = path.join(PUBLISHED_DIR, `${article.slug}.md`);
if (!await fileExists(liveFile)) return "new-draft";
const liveArticle = await readArticleFrom(PUBLISHED_DIR, article.slug);
return sameArticleContent(article, liveArticle) ? "live" : "edited-draft";
}
export async function listWorkingArticles(): Promise<Array<Omit<StoredArticle, "body"> & { publishStatus: PublishStatus }>> {
const articles = await Promise.all((await markdownFiles(ARTICLES_DIR)).map(async (file) => {
const article = await readWorkingArticle(file.replace(/\.md$/, ""));
const { body: _body, ...meta } = article;
return { ...meta, publishStatus: await getPublishStatus(article) };
}));
return articles.sort((a, b) => b.updated.localeCompare(a.updated) || b.date.localeCompare(a.date));
}
export async function writeArticle(slug: string, input: Record<string, unknown>, isNew = false): Promise<StoredArticle> {
await ensureStore();
const file = path.join(ARTICLES_DIR, `${slug}.md`);
if (isNew && await fileExists(file)) throw new StoreError("该网址标识已存在", 409);
const existing = !isNew && await fileExists(file) ? await readWorkingArticle(slug) : null;
const categories = await getCategoryNames();
const body = String(input.body || "").trim();
const normalized: StoredArticle = {
title: String(input.title || "").trim(),
slug,
date: existing?.date || fmtDate(),
updated: fmtDate(),
category: categories.includes(String(input.category)) ? String(input.category) : categories[0],
author: DEFAULT_AUTHOR,
excerpt: autoExcerpt(body, String(input.title || "")),
status: "draft",
body,
};
const liveFile = path.join(PUBLISHED_DIR, `${slug}.md`);
if (await fileExists(liveFile)) {
const liveArticle = await readArticleFrom(PUBLISHED_DIR, slug);
if (sameArticleContent(normalized, liveArticle)) {
normalized.status = "published";
normalized.updated = liveArticle.updated;
}
}
await writeAtomic(file, serialize(normalized, body));
return normalized;
}
export async function generateSlug(category: unknown, date = fmtDate()): Promise<string> {
await ensureStore();
const prefix = CATEGORY_SLUGS[String(category)] || "silver-journal";
const base = `${prefix}-${date.replaceAll("-", "")}`;
let slug = base;
let suffix = 2;
while (await fileExists(path.join(ARTICLES_DIR, `${slug}.md`)) || await fileExists(path.join(PUBLISHED_DIR, `${slug}.md`))) {
slug = `${base}-${suffix++}`;
}
return slug;
}
async function restoreFile(file: string, content: string | null): Promise<void> {
if (content === null) await fs.unlink(file).catch(() => {});
else await writeAtomic(file, content);
}
export async function publishArticle(slug: string): Promise<void> {
const article = await readWorkingArticle(slug);
const published: StoredArticle = { ...article, status: "published", updated: fmtDate() };
const text = serialize(published, article.body);
const draftFile = path.join(ARTICLES_DIR, `${slug}.md`);
const liveFile = path.join(PUBLISHED_DIR, `${slug}.md`);
const previousDraft = await fs.readFile(draftFile, "utf8");
const previousLive = await fileExists(liveFile) ? await fs.readFile(liveFile, "utf8") : null;
try {
await writeAtomic(liveFile, text);
await writeAtomic(draftFile, text);
} catch (error) {
await restoreFile(draftFile, previousDraft);
await restoreFile(liveFile, previousLive);
throw error;
}
}
export async function unpublishArticle(slug: string): Promise<void> {
const draftFile = path.join(ARTICLES_DIR, `${slug}.md`);
const liveFile = path.join(PUBLISHED_DIR, `${slug}.md`);
const previousDraft = await fs.readFile(draftFile, "utf8");
const previousLive = await fs.readFile(liveFile, "utf8");
const article = await readWorkingArticle(slug);
try {
await writeAtomic(draftFile, serialize({ ...article, status: "draft" }, article.body));
await fs.unlink(liveFile);
} catch (error) {
await restoreFile(draftFile, previousDraft);
await restoreFile(liveFile, previousLive);
throw error;
}
}
export async function deleteArticle(slug: string): Promise<void> {
const files = [path.join(ARTICLES_DIR, `${slug}.md`), path.join(PUBLISHED_DIR, `${slug}.md`)];
const backups: Array<{ file: string; content: string }> = [];
for (const file of files) if (await fileExists(file)) backups.push({ file, content: await fs.readFile(file, "utf8") });
if (!backups.length) throw new StoreError("文章不存在", 404);
try {
for (const { file } of backups) await fs.unlink(file);
} catch (error) {
for (const { file, content } of backups) await writeAtomic(file, content);
throw error;
}
}
export async function discardDraft(slug: string): Promise<{ removed: boolean; publishStatus: PublishStatus | null }> {
const liveFile = path.join(PUBLISHED_DIR, `${slug}.md`);
const draftFile = path.join(ARTICLES_DIR, `${slug}.md`);
if (await fileExists(liveFile)) {
await fs.copyFile(liveFile, draftFile);
return { removed: false, publishStatus: "live" };
}
await fs.unlink(draftFile);
return { removed: true, publishStatus: null };
}
async function categoryArticleFiles(category: string): Promise<Array<{ file: string; article: StoredArticle; raw: string }>> {
const results: Array<{ file: string; article: StoredArticle; raw: string }> = [];
for (const directory of [ARTICLES_DIR, PUBLISHED_DIR]) {
for (const filename of await markdownFiles(directory)) {
const slug = filename.replace(/\.md$/, "");
const article = await readArticleFrom(directory, slug);
if (article.category === category) {
const file = path.join(directory, filename);
results.push({ file, article, raw: await fs.readFile(file, "utf8") });
}
}
}
return results;
}
export async function getCategoryStats(): Promise<CategoryStat[]> {
const categories = await getCategoryNames();
const seen = new Map<string, Set<string>>(categories.map((name) => [name, new Set()]));
for (const directory of [ARTICLES_DIR, PUBLISHED_DIR]) {
for (const filename of await markdownFiles(directory)) {
const slug = filename.replace(/\.md$/, "");
const article = await readArticleFrom(directory, slug);
if (!seen.has(article.category)) seen.set(article.category, new Set());
seen.get(article.category)?.add(slug);
}
}
return categories.map((name) => ({ name, count: seen.get(name)?.size || 0 }));
}
async function updateCategoryArticles(current: string, replacement: string, nextCategories: string[]): Promise<void> {
const affected = await categoryArticleFiles(current);
const previousCategories = await fs.readFile(CATEGORIES_FILE, "utf8");
try {
for (const item of affected) {
const updated = { ...item.article, category: replacement };
await writeAtomic(item.file, serialize(updated, updated.body));
}
await saveCategories(nextCategories);
} catch (error) {
for (const item of affected) await writeAtomic(item.file, item.raw);
await writeAtomic(CATEGORIES_FILE, previousCategories);
throw error;
}
}
export async function renameCategory(currentValue: unknown, nextValue: unknown): Promise<string[]> {
const current = normalizeCategoryName(currentValue);
const nextName = normalizeCategoryName(nextValue);
const categories = await getCategoryNames();
if (!categories.includes(current)) throw new StoreError("分类不存在", 404);
if (current !== nextName && categories.includes(nextName)) throw new StoreError("该分类已经存在", 409);
if (current === nextName) return categories;
const next = categories.map((name) => name === current ? nextName : name);
await updateCategoryArticles(current, nextName, next);
return next;
}
export async function removeCategory(value: unknown, replacementValue: unknown): Promise<string[]> {
const name = normalizeCategoryName(value);
const categories = await getCategoryNames();
if (!categories.includes(name)) throw new StoreError("分类不存在", 404);
if (categories.length <= 1) throw new StoreError("至少需要保留一个文章分类", 409);
const affected = await categoryArticleFiles(name);
const count = new Set(affected.map((item) => item.article.slug)).size;
const replacement = String(replacementValue || "").trim();
if (count > 0 && (!replacement || !categories.includes(replacement) || replacement === name)) {
throw new StoreError("请先选择这些文章要迁移到的分类", 409, { requiresReplacement: true, count });
}
const next = categories.filter((category) => category !== name);
await updateCategoryArticles(name, replacement, next);
return next;
}
export async function getPublishedArticles(): Promise<Article[]> {
const items = await Promise.all((await markdownFiles(PUBLISHED_DIR)).map(async (file) => {
const article = await readArticleFrom(PUBLISHED_DIR, file.replace(/\.md$/, ""));
return {
id: article.slug,
data: {
title: article.title,
slug: article.slug,
date: new Date(article.date),
updated: new Date(article.updated),
category: article.category,
author: article.author,
excerpt: article.excerpt,
status: "published" as const,
},
body: article.body,
};
}));
return items.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
}
export async function getPublishedArticle(slug: string): Promise<(Article & { html: string }) | null> {
if (!safeSlug(slug)) return null;
try {
const article = await readArticleFrom(PUBLISHED_DIR, slug);
return {
id: article.slug,
data: {
title: article.title,
slug: article.slug,
date: new Date(article.date),
updated: new Date(article.updated),
category: article.category,
author: article.author,
excerpt: article.excerpt,
status: "published",
},
body: article.body,
html: await marked.parse(article.body),
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
}
export async function saveUpload(dataUrl: unknown): Promise<string> {
await ensureStore();
const extensions: Record<string, string> = { "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", "image/gif": "gif" };
const match = String(dataUrl || "").match(/^data:([^;]+);base64,(.+)$/s);
if (!match || !extensions[match[1]]) throw new StoreError("仅支持 PNG、JPG、WEBP 或 GIF 图片");
const buffer = Buffer.from(match[2], "base64");
if (buffer.length > 20 * 1024 * 1024) throw new StoreError("图片不能超过 20MB", 413);
const name = `${Date.now()}-${crypto.randomBytes(4).toString("hex")}.${extensions[match[1]]}`;
await writeAtomic(path.join(UPLOADS_DIR, name), buffer);
return `/uploads/${name}`;
}
import { getCategoryNames as readCategoryNames, getPublishedArticles as readPublishedArticles, type Article } from "./article-store";
export type { Article };
export const PAGE_SIZE = 9;
const CATEGORY_SLUGS: Record<string, string> = {
银饰选购: "silver-guide",
佩戴养护: "silver-care",
传统工艺: "silver-craft",
定制灵感: "custom-stories",
};
function fallbackSlug(category: string): string {
const ascii = category.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
if (ascii) return ascii;
let hash = 0;
for (const char of category) hash = (hash * 31 + (char.codePointAt(0) ?? 0)) >>> 0;
return `c${hash.toString(36)}`;
}
export const categoryToSlug = (category: string) => CATEGORY_SLUGS[category] ?? fallbackSlug(category);
export function slugToCategory(slug: string, categories: string[]): string | undefined {
return categories.find((category) => categoryToSlug(category) === slug);
}
export async function getPublishedArticles(): Promise<Article[]> {
return readPublishedArticles();
}
export async function getCategoryNames(): Promise<string[]> {
return readCategoryNames();
}
export interface CategoryInfo {
name: string;
slug: string;
count: number;
}
export function getCategories(items: Article[], categoryNames: string[] = []): CategoryInfo[] {
const counts = new Map<string, number>(categoryNames.map((name) => [name, 0]));
for (const item of items) counts.set(item.data.category, (counts.get(item.data.category) ?? 0) + 1);
return [...counts.entries()]
.map(([name, count]) => ({ name, slug: categoryToSlug(name), count }))
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name, "zh-CN"));
}
export const lastPageOf = (total: number, pageSize = PAGE_SIZE) => Math.max(1, Math.ceil(total / pageSize));
export const pageSlice = <T>(items: T[], page: number, pageSize = PAGE_SIZE) => items.slice((page - 1) * pageSize, page * pageSize);
export const fmtDate = (date: Date) => date.toISOString().slice(0, 10);
export function paginationWindow(current: number, last: number, span = 2): number[] {
const pages = new Set<number>([1, last]);
for (let page = current - span; page <= current + span; page++) if (page >= 1 && page <= last) pages.add(page);
const sorted = [...pages].sort((a, b) => a - b);
const result: number[] = [];
let previous = 0;
for (const page of sorted) {
if (previous && page - previous > 1) result.push(0);
result.push(page);
previous = page;
}
return result;
}
import crypto from "node:crypto";
import { marked } from "marked";
import {
StoreError,
addCategory,
createContentSnapshot,
deleteArticle,
discardDraft,
generateSlug,
getCategoryNames,
getCategoryStats,
getPublishStatus,
listWorkingArticles,
publishArticle,
readWorkingArticle,
removeCategory,
renameCategory,
safeSlug,
saveUpload,
unpublishArticle,
writeArticle,
} from "./article-store";
import { buildSiteAtomic, tryAcquireSiteBuildLock, withSiteBuildLock } from "../../scripts/site-build.mjs";
const PASSWORD = process.env.CMS_PASSWORD || "admin";
const SECRET = process.env.CMS_SECRET || crypto.randomBytes(32).toString("hex");
const API_KEY = process.env.CMS_API_KEY || "";
const CAPTCHA_TTL = 5 * 60 * 1000;
const CAPTCHA_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const MAX_LOGIN_FAILURES = 5;
const LOGIN_FAILURE_WINDOW = 10 * 60 * 1000;
const LOGIN_BLOCK_DURATION = 10 * 60 * 1000;
const loginAttempts = new Map<string, { count: number; first: number; blockedUntil: number }>();
type BuildState = {
status: "idle" | "building" | "success" | "error";
ok: boolean | null;
log: string;
startedAt: string | null;
finishedAt: string | null;
};
let buildState: BuildState = { status: "idle", ok: null, log: "", startedAt: null, finishedAt: null };
const json = (data: unknown, status = 200, headers?: HeadersInit) => Response.json(data, { status, headers });
const safeEqual = (leftValue: unknown, rightValue: unknown): boolean => {
const left = Buffer.from(String(leftValue));
const right = Buffer.from(String(rightValue));
return left.length === right.length && crypto.timingSafeEqual(left, right);
};
const sessionToken = () => crypto.createHmac("sha256", SECRET).update("yinzhuang-cms-v1").digest("hex");
function cookies(request: Request): Record<string, string> {
return Object.fromEntries((request.headers.get("cookie") || "").split(";").filter(Boolean).map((part) => {
const index = part.indexOf("=");
return [part.slice(0, index).trim(), decodeURIComponent(part.slice(index + 1).trim())];
}));
}
function apiKeyValid(request: Request): boolean {
if (!API_KEY) return false;
const auth = request.headers.get("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1];
const key = auth || request.headers.get("x-api-key");
return key ? safeEqual(key, API_KEY) : false;
}
const authed = (request: Request): boolean =>
Boolean(cookies(request).cms_session && safeEqual(cookies(request).cms_session, sessionToken())) || apiKeyValid(request);
async function bodyOf(request: Request): Promise<Record<string, unknown>> {
try { return await request.json(); } catch { return {}; }
}
function cookieHeader(request: Request, name: string, value: string, maxAge: number): string {
const forwarded = request.headers.get("x-forwarded-proto");
const secure = new URL(request.url).protocol === "https:" || forwarded === "https";
return `${name}=${value}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}${secure ? "; Secure" : ""}`;
}
function loginBlockedFor(ip: string): number {
const state = loginAttempts.get(ip);
return state?.blockedUntil && state.blockedUntil > Date.now()
? Math.ceil((state.blockedUntil - Date.now()) / 1000)
: 0;
}
function loginFailed(ip: string): void {
const now = Date.now();
let state = loginAttempts.get(ip);
if (!state || now - state.first > LOGIN_FAILURE_WINDOW) {
state = { count: 0, first: now, blockedUntil: 0 };
}
state.count += 1;
if (state.count >= MAX_LOGIN_FAILURES) state.blockedUntil = now + LOGIN_BLOCK_DURATION;
loginAttempts.set(ip, state);
}
function randomCaptcha(length = 4): string {
let code = "";
for (let index = 0; index < length; index += 1) {
code += CAPTCHA_CHARS[crypto.randomInt(CAPTCHA_CHARS.length)];
}
return code;
}
function captchaMac(code: unknown, expiresAt: number): string {
return crypto.createHmac("sha256", SECRET)
.update(`${String(code).toUpperCase()}|${expiresAt}`)
.digest("hex");
}
function captchaValid(request: Request, answer: unknown): boolean {
const value = cookies(request).cms_captcha;
if (!value || !answer) return false;
const separator = value.lastIndexOf(".");
const expiresAt = Number(value.slice(0, separator));
const mac = value.slice(separator + 1);
return Boolean(expiresAt && Date.now() <= expiresAt && safeEqual(mac, captchaMac(answer, expiresAt)));
}
function captchaSvg(code: string): string {
const width = 132;
const height = 46;
const colors = ["#11897b", "#0e727c", "#16ae9c", "#243532"];
const randomBetween = (min: number, max: number) => crypto.randomInt(min, max + 1);
const pick = <T>(items: T[]): T => items[crypto.randomInt(items.length)];
let content = `<rect width="${width}" height="${height}" fill="#e4efea"/>`;
for (let index = 0; index < 4; index += 1) {
content += `<line x1="${randomBetween(0, width)}" y1="${randomBetween(0, height)}" x2="${randomBetween(0, width)}" y2="${randomBetween(0, height)}" stroke="${pick(colors)}" stroke-width="1" opacity="0.45"/>`;
}
[...code].forEach((character, index) => {
const x = 16 + index * 28 + randomBetween(-3, 3);
const y = 31 + randomBetween(-4, 4);
const rotation = randomBetween(-26, 26);
content += `<text x="${x}" y="${y}" font-family="Georgia,serif" font-weight="700" font-size="${randomBetween(24, 30)}" fill="${pick(colors)}" transform="rotate(${rotation} ${x} ${y})">${character}</text>`;
});
for (let index = 0; index < 26; index += 1) {
content += `<circle cx="${randomBetween(0, width)}" cy="${randomBetween(0, height)}" r="1" fill="${pick(colors)}" opacity="0.5"/>`;
}
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${content}</svg>`;
}
function requireSlug(value: string | undefined): string {
const slug = safeSlug(value);
if (!slug) throw new StoreError("网址标识不合法");
return slug;
}
async function startContentBuild(change: () => Promise<void>): Promise<boolean> {
if (buildState.status === "building") return false;
const releaseLock = await tryAcquireSiteBuildLock();
if (!releaseLock) return false;
buildState = { status: "building", ok: null, log: "", startedAt: new Date().toISOString(), finishedAt: null };
void (async () => {
const snapshot = await createContentSnapshot();
try {
await change();
const log = await buildSiteAtomic();
buildState = { status: "success", ok: true, log, startedAt: buildState.startedAt, finishedAt: new Date().toISOString() };
} catch (error) {
let rollbackMessage = "";
try { await snapshot.restore(); } catch (rollbackError) {
rollbackMessage = `\n内容回滚失败:${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`;
}
const message = error instanceof Error ? error.message : String(error);
const log = typeof error === "object" && error && "log" in error ? String(error.log || "") : "";
buildState = {
status: "error",
ok: false,
log: `${log}${log ? "\n" : ""}${message}${rollbackMessage}`,
startedAt: buildState.startedAt,
finishedAt: new Date().toISOString(),
};
}
})().finally(releaseLock).catch((error) => {
buildState = {
status: "error",
ok: false,
log: error instanceof Error ? error.message : String(error),
startedAt: buildState.startedAt,
finishedAt: new Date().toISOString(),
};
});
return true;
}
async function startOrConflict(change: () => Promise<void>): Promise<Response> {
if (!await startContentBuild(change)) {
return json({ error: "网站正在部署或生成静态页面,本次操作未受理,请稍后重试" }, 409, { "Retry-After": "5" });
}
return json({ ok: true, status: "building" });
}
export async function handleCmsApi(request: Request, routeValue: string, clientAddress = "unknown"): Promise<Response> {
const route = routeValue.replace(/^\/+|\/+$/g, "");
const parts = route.split("/").filter(Boolean);
const method = request.method.toUpperCase();
try {
if (route === "captcha" && method === "GET") {
const code = randomCaptcha();
const expiresAt = Date.now() + CAPTCHA_TTL;
return new Response(captchaSvg(code), {
headers: {
"Cache-Control": "no-store",
"Content-Type": "image/svg+xml; charset=utf-8",
"Set-Cookie": cookieHeader(request, "cms_captcha", `${expiresAt}.${captchaMac(code, expiresAt)}`, CAPTCHA_TTL / 1000),
},
});
}
if (route === "session") {
if (method === "GET") return json({ authed: authed(request) });
if (method === "POST") {
const blockedFor = loginBlockedFor(clientAddress);
if (blockedFor) return json({ error: `尝试过于频繁,请 ${Math.ceil(blockedFor / 60)} 分钟后再试` }, 429);
const body = await bodyOf(request);
const clearCaptcha = cookieHeader(request, "cms_captcha", "", 0);
if (!captchaValid(request, body.captcha)) {
return json({ error: "验证码错误或已过期" }, 400, { "Set-Cookie": clearCaptcha });
}
if (!safeEqual(body.password || "", PASSWORD)) {
loginFailed(clientAddress);
return json({ error: "管理密码错误" }, 401, { "Set-Cookie": clearCaptcha });
}
loginAttempts.delete(clientAddress);
const headers = new Headers();
headers.append("Set-Cookie", clearCaptcha);
headers.append("Set-Cookie", cookieHeader(request, "cms_session", sessionToken(), 86400));
return json({ ok: true }, 200, headers);
}
if (method === "DELETE") return json({ ok: true }, 200, { "Set-Cookie": cookieHeader(request, "cms_session", "", 0) });
}
if (!authed(request)) throw new StoreError("请先登录文章后台", 401);
if (route === "categories") {
if (method === "GET") return json({ categories: await getCategoryNames(), stats: await getCategoryStats() });
const body = await bodyOf(request);
if (method === "POST") {
return startOrConflict(async () => { await addCategory(body.name); });
}
if (method === "PATCH") {
return startOrConflict(async () => { await renameCategory(body.current, body.name); });
}
if (method === "DELETE") {
return startOrConflict(async () => { await removeCategory(body.name, body.replacement); });
}
}
if (route === "build" && method === "GET") return json(buildState);
if (route === "articles") {
if (method === "GET") return json(await listWorkingArticles());
if (method === "POST") {
const body = await bodyOf(request);
if (!String(body.title || "").trim()) throw new StoreError("请填写文章标题");
if (!String(body.body || "").trim()) throw new StoreError("请填写文章正文");
const slug = await generateSlug(body.category);
const article = await withSiteBuildLock(() => writeArticle(slug, body, true));
return json({ ok: true, slug, publishStatus: await getPublishStatus(article) }, 201);
}
}
if (parts[0] === "articles" && parts[1]) {
const slug = requireSlug(parts[1]);
if (parts.length === 3 && parts[2] === "draft" && method === "DELETE") {
return json({ ok: true, ...await withSiteBuildLock(() => discardDraft(slug)) });
}
if (parts.length === 3 && parts[2] === "publish" && method === "POST") {
return startOrConflict(async () => { await publishArticle(slug); });
}
if (parts.length === 3 && parts[2] === "unpublish" && method === "POST") {
return startOrConflict(async () => { await unpublishArticle(slug); });
}
if (parts.length === 2 && method === "GET") {
const article = await readWorkingArticle(slug);
return json({ ...article, publishStatus: await getPublishStatus(article) });
}
if (parts.length === 2 && method === "PUT") {
const body = await bodyOf(request);
if (!String(body.title || "").trim()) throw new StoreError("请填写文章标题");
if (!String(body.body || "").trim()) throw new StoreError("请填写文章正文");
const article = await withSiteBuildLock(() => writeArticle(slug, body));
return json({ ok: true, slug, publishStatus: await getPublishStatus(article) });
}
if (parts.length === 2 && method === "DELETE") {
return startOrConflict(async () => { await deleteArticle(slug); });
}
}
if (route === "preview" && method === "POST") {
const body = await bodyOf(request);
return json({ html: await marked.parse(String(body.body || "")) });
}
if (route === "uploads" && method === "POST") {
const body = await bodyOf(request);
return json({ ok: true, url: await saveUpload(body.dataUrl) });
}
return json({ error: "接口不存在" }, 404);
} catch (error) {
if (error instanceof StoreError) return json({ error: error.message, ...error.details }, error.status);
if ((error as NodeJS.ErrnoException).code === "ENOENT") return json({ error: "文章不存在" }, 404);
console.error(error);
return json({ error: error instanceof Error ? error.message : "服务器处理失败" }, 500);
}
}
---
import Base from "../layouts/Base.astro"; import Header from "../components/Header.astro"; import Footer from "../components/Footer.astro";
---
<Base title="页面未找到|银妆银饰" description="你访问的页面不存在。" noindex><Header /><main id="main-content"><section class="not-found"><div class="container" data-reveal><span>404</span><div class="eyebrow"><span></span>PAGE NOT FOUND</div><h1>这一页暂时找不到了</h1><p>可以返回首页,继续探索银妆产品与定制服务。</p><a class="button" href="/">返回首页</a></div></section></main><Footer /></Base>
---
import Base from "../../layouts/Base.astro"; import Header from "../../components/Header.astro"; import Footer from "../../components/Footer.astro"; import SectionHeading from "../../components/SectionHeading.astro"; import CTA from "../../components/CTA.astro"; import { site } from "../../data/site"; import image from "../../../pic/product/微信图片_20260508131934_564_199.jpg";
import { Image } from "astro:assets";
const title = `关于银妆|${site.brand.name}`; const description = "了解银妆14年银饰行业积淀、非遗匠人团队、透明经营与终身养护服务初心。";
---
<Base {title} {description}><Header /><main id="main-content">
<section class="page-hero" data-word="INZUNG"><div class="container page-hero__inner" data-reveal><div class="breadcrumb"><a href="/">首页</a> / 关于银妆</div><div class="eyebrow"><span></span>ABOUT INZUNG</div><h1>不只卖一件银饰<br/><em>更为一段记忆负责</em></h1><p>十四年行业积淀,让真材实料、手作温度与长期陪伴成为银妆最朴素的坚持。</p></div></section>
<section id="story" class="section-pad section-cream"><div class="container about-story-grid"><div data-reveal><Image src={image} widths={[480, 720, 960]} sizes="(max-width: 760px) calc(100vw - 32px), 42vw" format="webp" quality={86} alt="银妆古法足银手镯" /></div><div><SectionHeading eyebrow="OUR STORY" title={site.about.heading} />{site.about.story.map((paragraph) => <p class="story-paragraph" data-reveal>{paragraph}</p>)}</div></div></section>
<section class="stat-band"><div class="container stat-grid">{site.home.stats.map((stat) => <div data-reveal><strong>{stat.value}</strong><p>{stat.label}</p></div>)}</div></section>
<section id="advantages" class="section-pad section-white"><div class="container"><SectionHeading eyebrow="WHY INZUNG" title="从选购到养护,把每一步都交代清楚" description="银妆的专业,不止体现在成品,也体现在你能看见、能理解的全过程。" /><div class="advantage-grid">{site.about.advantages.map((item, index) => <article data-reveal><span>0{index + 1}</span><h3>{item.title}</h3><p>{item.desc}</p></article>)}</div></div></section>
<section id="values" class="section-pad values-section"><div class="container values-grid"><SectionHeading eyebrow="BRAND BELIEF" title="让传统手艺,回到今天的生活" description="坚守透明经营与终身养护,不把传统工艺放在橱窗里,而是让它成为每个人都能参与、佩戴和传承的日常。" light /><blockquote data-reveal>“银有光,手有温度,<br/>心意才有了长久的形状。”</blockquote></div></section>
<CTA />
</main><Footer /></Base>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex,nofollow" />
<title>银妆 · 银饰志后台</title>
<link rel="stylesheet" href="/admin/style.css" />
</head>
<body>
<section id="login" class="login hidden">
<form id="login-form" class="login-card">
<div class="brand-mark">银妆</div>
<h1>银饰志后台</h1>
<p>输入管理密码和图片验证码,管理官网文章内容。</p>
<label>管理密码<input id="password" type="password" autocomplete="current-password" required /></label>
<label>图片验证码</label>
<div class="captcha-row">
<input id="captcha" autocomplete="off" maxlength="4" inputmode="text" aria-label="图片验证码" required />
<img id="captcha-image" alt="图片验证码,点击可更换" title="看不清?点击换一张" />
</div>
<button class="primary" type="submit">登录后台</button>
<div id="login-error" class="error"></div>
</form>
</section>
<div id="app" class="hidden">
<header class="topbar">
<div><strong>银妆</strong><span>银饰志后台</span></div>
<div class="top-actions">
<span id="pending-badge" class="pending-badge hidden"></span>
<a class="button ghost" href="/articles/" target="_blank" rel="noopener">查看前台</a>
<button id="logout" class="ghost">退出</button>
</div>
</header>
<main id="list-view" class="view">
<div class="view-head"><div><p>CONTENT</p><h1>银饰志</h1></div><div class="view-actions"><button id="manage-categories" class="ghost">管理分类</button><button id="new-article" class="primary">+ 新建文章</button></div></div>
<div id="article-list" class="article-list"></div>
</main>
<main id="edit-view" class="view hidden">
<div class="view-head editor-titlebar">
<div><button id="back" class="back-button" type="button">← 返回文章列表</button><h1 id="edit-heading">编辑文章</h1></div>
<span id="editor-status" class="status"></span>
</div>
<div id="version-note" class="version-note"></div>
<form id="edit-form">
<section class="field-panel">
<label>文章标题<input id="title" required /></label>
<div class="field-label">
<span>文章分类</span>
<div id="category-select" class="custom-select">
<input id="category" type="hidden" />
<button id="category-trigger" class="select-trigger" type="button" aria-haspopup="listbox" aria-expanded="false">
<span id="category-value"></span><span class="select-chevron" aria-hidden="true"></span>
</button>
<div id="category-menu" class="select-menu hidden">
<div id="category-options" class="select-options" role="listbox" aria-label="文章分类"></div>
<div class="category-create">
<span>添加新分类</span>
<div><input id="new-category" maxlength="20" placeholder="输入分类名称" /><button id="add-category" class="primary small" type="button">添加</button></div>
<p id="category-error" class="error"></p>
</div>
</div>
</div>
</div>
<p class="auto-meta">文章网址、摘要、作者和 SEO 信息将由系统自动生成,无需填写。</p>
</section>
<section class="editor-panel">
<div class="editor-head"><div><strong>文章正文</strong><small>右侧会自动显示预览</small></div><div><span id="upload-status"></span><button id="upload-button" class="ghost small" type="button">插入图片</button></div></div>
<input id="file" class="hidden" type="file" accept="image/png,image/jpeg,image/webp,image/gif" />
<div class="editor-grid"><textarea id="body" required spellcheck="false" placeholder="在这里撰写文章正文……"></textarea><div id="preview" class="preview" aria-live="polite"></div></div>
</section>
<div class="edit-actions">
<button class="ghost save-button" type="submit">保存草稿</button>
<button id="publish" class="primary" type="button">发布到官网</button>
<details id="more-actions" class="more-actions hidden">
<summary>更多操作</summary>
<div class="action-menu">
<button id="discard" class="menu-action hidden" type="button">放弃未发布修改</button>
<button id="unpublish" class="menu-action danger hidden" type="button">下架文章</button>
<button id="delete-article" class="menu-action danger hidden" type="button">删除文章</button>
</div>
</details>
<span id="message"></span>
</div>
</form>
</main>
</div>
<div id="category-modal" class="modal hidden">
<div class="modal-card category-manager">
<div class="modal-head"><div><strong>文章分类管理</strong><small>重命名会同步更新该分类下的文章</small></div><button id="close-category-modal" class="ghost small" type="button">关闭</button></div>
<div class="category-manager-body">
<div class="manager-create"><input id="manager-new-category" maxlength="20" placeholder="输入新分类名称" /><button id="manager-add-category" class="primary" type="button">添加分类</button></div>
<p id="manager-category-error" class="error"></p>
<div id="category-manager-list" class="category-manager-list"></div>
</div>
</div>
</div>
<div id="build-modal" class="modal hidden">
<div class="modal-card build-card">
<div><strong id="build-title">正在生成静态页面</strong><button id="close-modal" class="ghost small hidden" type="button">关闭</button></div>
<pre id="build-log">系统正在更新官网,请稍候。</pre>
</div>
</div>
<script is:inline src="/admin/app.js"></script>
</body>
</html>
import type { APIRoute } from "astro";
import { handleCmsApi } from "../../../lib/cms-api";
export const prerender = false;
const handler: APIRoute = ({ request, params, clientAddress }) =>
handleCmsApi(request, params.route || "", clientAddress);
export const GET = handler;
export const POST = handler;
export const PUT = handler;
export const PATCH = handler;
export const DELETE = handler;
---
import Base from "../../layouts/Base.astro"; import Header from "../../components/Header.astro"; import Footer from "../../components/Footer.astro"; import { site } from "../../data/site"; import { getPublishedArticle, getPublishedArticles } from "../../lib/article-store"; import { fmtDate } from "../../lib/articles";
type ArticleDetail = NonNullable<Awaited<ReturnType<typeof getPublishedArticle>>>;
export async function getStaticPaths() { const articles = await getPublishedArticles(); return Promise.all(articles.map(async (article) => ({ params: { slug: article.id }, props: { entry: await getPublishedArticle(article.id) } }))); }
const { entry } = Astro.props as { entry: ArticleDetail }; const article = entry.data; const date = fmtDate(article.date); const title = `${article.title}|银饰志|${site.brand.name}`; const pageUrl = new URL(`/articles/${entry.id}/`, Astro.site).href;
const jsonLd = [{ "@context": "https://schema.org", "@type": "Article", headline: article.title, description: article.excerpt, datePublished: date, dateModified: fmtDate(article.updated || article.date), articleSection: article.category, inLanguage: "zh-CN", author: { "@type": "Organization", name: article.author }, publisher: { "@id": new URL("/#organization", Astro.site).href }, mainEntityOfPage: { "@type": "WebPage", "@id": pageUrl }, url: pageUrl }, { "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: [{ "@type": "ListItem", position: 1, name: "首页", item: Astro.site?.href }, { "@type": "ListItem", position: 2, name: "银饰志", item: new URL("/articles/", Astro.site).href }, { "@type": "ListItem", position: 3, name: article.title, item: pageUrl }] }];
---
<Base {title} description={article.excerpt} ogType="article" {jsonLd}><Header /><main id="main-content" class="article-detail-page"><article><header class="article-detail-head"><div class="container article-detail-head__inner"><div class="breadcrumb"><a href="/">首页</a> / <a href="/articles/">银饰志</a> / {article.category}</div><span class="article-detail-category">{article.category}</span><h1>{article.title}</h1><p>{article.excerpt}</p><div class="article-detail-meta"><time datetime={date}>{date}</time><span>{article.author}</span></div></div></header><div class="container article-detail-layout"><aside><a href="/articles/">← 返回银饰志</a><p>银饰材质、价格与售后规则请以实际产品标识和购买门店说明为准。</p></aside><div class="article-prose" set:html={entry.html}></div></div></article></main><Footer /></Base>
---
import ArticlesView from "../../components/ArticlesView.astro";
import { getPublishedArticles, getCategoryNames, getCategories, pageSlice, lastPageOf } from "../../lib/articles";
const all = await getPublishedArticles();
const categories = getCategories(all, await getCategoryNames());
const items = pageSlice(all, 1);
---
<ArticlesView items={items} categories={categories} totalCount={all.length} activeSlug={null} activeName={null} currentPage={1} lastPage={lastPageOf(all.length)} basePath="/articles" />
---
import ArticlesView from "../../../components/ArticlesView.astro";
import { getPublishedArticles, getCategoryNames, getCategories, pageSlice, lastPageOf } from "../../../lib/articles";
export async function getStaticPaths() {
const articles = await getPublishedArticles();
return Array.from({ length: Math.max(0, lastPageOf(articles.length) - 1) }, (_, index) => ({
params: { page: String(index + 2) },
}));
}
const all = await getPublishedArticles();
const page = Number(Astro.params.page);
---
<ArticlesView items={pageSlice(all, page)} categories={getCategories(all, await getCategoryNames())} totalCount={all.length} activeSlug={null} activeName={null} currentPage={page} lastPage={lastPageOf(all.length)} basePath="/articles" />
---
import ArticlesView from "../../../../components/ArticlesView.astro";
import { categoryToSlug, getPublishedArticles, getCategoryNames, getCategories, slugToCategory, pageSlice, lastPageOf } from "../../../../lib/articles";
export async function getStaticPaths() {
return (await getCategoryNames()).map((categoryName) => ({
params: { slug: categoryToSlug(categoryName) },
props: { categoryName },
}));
}
const all = await getPublishedArticles();
const categories = getCategories(all, await getCategoryNames());
const categoryName = String(Astro.props.categoryName || slugToCategory(Astro.params.slug!, categories.map((item) => item.name)) || "");
const filtered = all.filter((item) => item.data.category === categoryName);
---
<ArticlesView items={pageSlice(filtered, 1)} categories={categories} totalCount={all.length} activeSlug={Astro.params.slug!} activeName={categoryName} currentPage={1} lastPage={lastPageOf(filtered.length)} basePath={`/articles/topic/${Astro.params.slug}`} />
---
import ArticlesView from "../../../../../components/ArticlesView.astro";
import { getPublishedArticles, getCategoryNames, getCategories, slugToCategory, pageSlice, lastPageOf } from "../../../../../lib/articles";
export async function getStaticPaths() {
const articles = await getPublishedArticles();
const categories = getCategories(articles, await getCategoryNames());
const paths: Array<{ params: { slug: string; page: string }; props: { categoryName: string } }> = [];
for (const category of categories) {
for (let page = 2; page <= lastPageOf(category.count); page += 1) {
paths.push({ params: { slug: category.slug, page: String(page) }, props: { categoryName: category.name } });
}
}
return paths;
}
const all = await getPublishedArticles();
const categories = getCategories(all, await getCategoryNames());
const categoryName = String(Astro.props.categoryName || slugToCategory(Astro.params.slug!, categories.map((item) => item.name)) || "");
const filtered = all.filter((item) => item.data.category === categoryName);
const page = Number(Astro.params.page);
---
<ArticlesView items={pageSlice(filtered, page)} categories={categories} totalCount={all.length} activeSlug={Astro.params.slug!} activeName={categoryName} currentPage={page} lastPage={lastPageOf(filtered.length)} basePath={`/articles/topic/${Astro.params.slug}`} />
---
import Base from "../../layouts/Base.astro"; import Header from "../../components/Header.astro"; import Footer from "../../components/Footer.astro"; import { site } from "../../data/site";
const title = `联系我们|${site.brand.name}`; const description = "联系银妆银饰,咨询足银首饰、私人定制、旧银加工、DIY编织、门店与售后服务。全国客服400-811-3922。";
---
<Base {title} {description}><Header /><main id="main-content">
<section class="contact-hero"><div class="container contact-hero__grid"><div data-reveal><div class="breadcrumb"><a href="/">首页</a> / 联系我们</div><div class="eyebrow eyebrow--gold"><span></span>CONTACT INZUNG</div><h1>{site.contact.heading}</h1><p>{site.contact.intro}</p><a class="button button--gold" href="tel:4008113922">立即致电</a></div><div class="contact-seal" data-reveal><strong>银</strong><span>以银为信<br/>以心相见</span></div></div></section>
<section class="section-pad section-cream"><div class="container contact-grid"><article data-reveal><span>01</span><h2>全国客服热线</h2><a class="contact-big-link" href="tel:4008113922">{site.contact.phone}</a><p>产品咨询、定制沟通、门店与售后服务</p></article><article data-reveal><span>02</span><h2>公司地址</h2><address>{site.contact.address}</address><p>建议到访前先致电确认接待安排</p></article><article data-reveal><span>03</span><h2>抖音矩阵</h2>{site.contact.douyin.map((name) => <strong class="social-name">{name}</strong>)}<p>关注产品上新与门店内容</p></article><article data-reveal><span>04</span><h2>官方网站</h2><a class="contact-big-link contact-big-link--small" href="https://www.inzung.cn">{site.contact.website}</a><p>银妆银饰官方信息窗口</p></article></div></section>
<section class="section-pad section-white"><div class="container visit-grid"><div><div class="eyebrow"><span></span>BEFORE YOU VISIT</div><h2>带上一个想法,就可以开始</h2></div><ol><li><span>01</span><div><strong>告诉我们用途</strong><p>自戴、送礼、纪念还是旧银改造</p></div></li><li><span>02</span><div><strong>准备参考信息</strong><p>喜欢的风格、尺寸、文字或图案</p></div></li><li><span>03</span><div><strong>确认预算时间</strong><p>便于顾问给出更合适的材质与工艺建议</p></div></li></ol></div></section>
<section class="contact-bottom"><div class="container"><p>INZUNG · SILVER MADE PERSONAL</p><h2>让银饰成为<br/>你想留住的那一刻</h2><a class="button button--gold" href="tel:4008113922">咨询定制方案</a></div></section>
</main><Footer /></Base>
---
import Base from "../../layouts/Base.astro"; import Header from "../../components/Header.astro"; import Footer from "../../components/Footer.astro"; import CTA from "../../components/CTA.astro"; import { site } from "../../data/site";
const title = `常见问题|${site.brand.name}`; const description = "银妆银饰材质、氧化养护、手镯计价、质量售后、日常佩戴与旧银加工常见问题解答。";
const jsonLd = { "@context": "https://schema.org", "@type": "FAQPage", mainEntity: site.faq.map((item) => ({ "@type": "Question", name: item.q, acceptedAnswer: { "@type": "Answer", text: item.a } })) };
---
<Base {title} {description} {jsonLd}><Header /><main id="main-content">
<section class="page-hero" data-word="FAQ"><div class="container page-hero__inner" data-reveal><div class="breadcrumb"><a href="/">首页</a> / 常见问题</div><div class="eyebrow"><span></span>FREQUENTLY ASKED</div><h1>关于银饰<br/><em>你可能想知道</em></h1><p>从材质真伪、价格构成到日常养护,把重要的问题一次说清楚。</p></div></section>
<section class="section-pad section-cream"><div class="container faq-layout"><aside data-reveal><span>01 — 06</span><h2>选购与定制 FAQ</h2><p>若你的问题不在其中,欢迎拨打客服热线与首饰顾问直接沟通。</p><a class="button button--outline" href="tel:4008113922">拨打 {site.contact.phone}</a></aside><div class="faq-list">{site.faq.map((item, index) => <details data-reveal open={index === 0}><summary><span>{String(index + 1).padStart(2, "0")}</span><strong>{item.q}</strong><i aria-hidden="true"></i></summary><div><p>{item.a}</p></div></details>)}</div></div></section>
<CTA />
</main><Footer /></Base>
---
import Base from "../layouts/Base.astro";
import Header from "../components/Header.astro";
import Footer from "../components/Footer.astro";
import SectionHeading from "../components/SectionHeading.astro";
import ProductCarousel from "../components/ProductCarousel.astro";
import CTA from "../components/CTA.astro";
import { site } from "../data/site";
import { getPublishedArticles } from "../lib/articles";
import { fmtDate } from "../lib/articles";
import hero from "../../pic/product/微信图片_20260508131943_573_199.jpg";
import detail from "../../pic/product/优先-画板 1(3).jpg";
import logoRed from "../../pic/LOGO-红底白字-底较大.png";
import { Image } from "astro:assets";
const latest = (await getPublishedArticles()).slice(0, 3);
const title = `银妆银饰|足银首饰、手工定制与终身养护`;
const description = "银妆深耕银饰行业14年,提供足银手镯、银项链、银耳环、来料加工、专属雕刻与银饰DIY编织服务。";
const jsonLd = { "@context": "https://schema.org", "@type": "WebSite", name: site.brand.name, url: Astro.site?.href, inLanguage: "zh-CN" };
---
<Base {title} {description} {jsonLd} image={hero.src}>
<Header />
<main id="main-content">
<section class="hero">
<div class="hero-pattern" aria-hidden="true"></div>
<div class="container hero__grid">
<div class="hero__content" data-reveal>
<div class="eyebrow"><span></span>{site.home.eyebrow}</div>
<h1>把重要的心意,<em>锻造成银</em></h1>
<p>{site.home.subhead}</p>
<div class="hero__actions"><a class="button" href="tel:4008113922">立即咨询</a><a class="button button--outline" href="/services/">探索银饰系列</a></div>
<div class="hero__trust"><span>足银 999/9999</span><span>透明计价</span><span>终身养护</span></div>
</div>
<div class="hero__visual" data-reveal>
<div class="hero__image"><Image src={hero} widths={[480, 720, 960]} sizes="(max-width: 760px) calc(100vw - 60px), 48vw" format="webp" quality={88} alt="银妆古法鎏金足银手镯" fetchpriority="high" /></div>
<div class="hero-seal"><Image src={logoRed} width={148} height={149} format="webp" quality={88} alt="银妆" /><span>INZUNG<br/>SILVER</span></div>
<div class="hero-caption"><span>01</span><div><strong>古法錾金</strong><small>手工温度,历久弥新</small></div></div>
</div>
</div>
<div class="hero-scroll"><span></span>向下探索</div>
</section>
<section class="stat-band"><div class="container stat-grid">{site.home.stats.map((stat) => <div data-reveal><strong>{stat.value}</strong><p>{stat.label}</p></div>)}</div></section>
<section id="products" class="section-pad section-cream">
<div class="container">
<div class="section-intro-row"><SectionHeading eyebrow="SILVER COLLECTION" title="匠心银作,专属信物" description="从手镯、项链、耳饰到 DIY 编织,以足银为载体,融合古法手工与个性化定制。" /><a class="text-link" href="/services/">查看全部产品 <span>↗</span></a></div>
<div class="product-category-grid">{site.products.map((product) => <a class="product-category-card" href={`/services/#${product.slug}`} data-reveal><span>{product.index}</span><h3>{product.name}</h3><p>{product.intro}</p><strong>了解详情 ↗</strong></a>)}</div>
</div>
</section>
<section class="section-pad section-white gallery-section">
<div class="container"><div class="section-intro-row"><SectionHeading eyebrow="SELECTED PIECES" title="每一面光泽,都有手作留下的痕迹" description="精选银妆产品实拍,横向滑动探索更多款式。" /><span class="section-index">01 — 15</span></div></div>
<div class="container container--wide"><ProductCarousel /></div>
</section>
<section id="craft" class="section-pad craft-section">
<div class="container craft-grid">
<div class="craft-visual" data-reveal><Image src={detail} widths={[480, 720, 960]} sizes="(max-width: 760px) calc(100vw - 62px), 42vw" format="webp" quality={86} alt="平安喜乐宝宝足银对镯" loading="lazy" /><div class="craft-visual__stamp">手作<br/>有温度</div></div>
<div class="craft-content"><SectionHeading eyebrow="CRAFT & CUSTOM" title="一双手,一件银,一段只属于你的故事" description="从沟通构想到交付养护,每一步都把透明、专业与专属做到细处。" light />
<ol class="craft-steps">{[
["01", "沟通构想", "确认用途、预算、材质、尺寸与风格偏好"], ["02", "设计定稿", "梳理图案、刻字与工艺细节,明确交付方案"], ["03", "匠人打制", "由专业匠人完成锻打、錾刻、焊接与抛光"], ["04", "交付养护", "配套质检与长期养护,让心意一直焕新"],
].map(([num, name, desc]) => <li data-reveal><span>{num}</span><div><h3>{name}</h3><p>{desc}</p></div></li>)}</ol>
<a class="button button--gold" href="/services/">了解定制服务</a>
</div>
</div>
</section>
<section class="section-pad section-cream brand-story-section">
<div class="container brand-story-grid">
<div><SectionHeading eyebrow="ABOUT INZUNG" title="十四年,只为让银饰回到真诚与温度" description={site.about.story[0]} /><a class="text-link" href="/about/">走进银妆 <span>↗</span></a></div>
<div class="brand-story-number" data-reveal><strong>14</strong><span>YEARS<br/>OF SILVER CRAFT</span></div>
<div class="brand-story-list">{site.about.advantages.map((item, index) => <div data-reveal><span>0{index + 1}</span><h3>{item.title}</h3><p>{item.desc}</p></div>)}</div>
</div>
</section>
<section id="stories" class="section-pad section-white">
<div class="container"><SectionHeading eyebrow="MADE FOR MOMENTS" title="为每一段重要关系,留下银色注脚" description="没有虚构的故事模板,只有从真实需求出发的定制场景。" align="center" />
<div class="scene-grid">{site.scenes.map((scene, index) => <article data-reveal><span>{String(index + 1).padStart(2, "0")}</span><h3>{scene.title}</h3><p>{scene.desc}</p><a href="/contact/">沟通定制 ↗</a></article>)}</div>
</div>
</section>
<section class="section-pad service-promise-section"><div class="container"><div class="section-intro-row"><SectionHeading eyebrow="OUR PROMISE" title="一件银饰,四重安心" description="从真材实料到长期养护,让购买之后的每一天都更放心。" light /><a class="text-link text-link--light" href="/join/">了解服务体系 <span>↗</span></a></div><div class="promise-grid">{site.promises.map((item) => <article data-reveal><span>{item.number}</span><h3>{item.title}</h3><p>{item.desc}</p></article>)}</div></div></section>
{latest.length > 0 && <section class="section-pad section-cream"><div class="container"><div class="section-intro-row"><SectionHeading eyebrow="INZUNG JOURNAL" title="银饰志" description="关于选购、佩戴、养护与传统银作的实用分享。" /><a class="text-link" href="/articles/">查看全部文章 <span>↗</span></a></div><div class="article-grid">{latest.map((item, index) => <article class="article-card" data-reveal><a href={`/articles/${item.id}/`}><div class="article-card__meta"><span>{item.data.category}</span><time>{fmtDate(item.data.date)}</time></div><div class="article-card__index">0{index + 1}</div><h2>{item.data.title}</h2><p>{item.data.excerpt}</p><strong>阅读全文 ↗</strong></a></article>)}</div></div></section>}
<CTA />
</main>
<Footer />
</Base>
---
import Base from "../../layouts/Base.astro"; import Header from "../../components/Header.astro"; import Footer from "../../components/Footer.astro"; import SectionHeading from "../../components/SectionHeading.astro"; import CTA from "../../components/CTA.astro"; import { site } from "../../data/site";
const title = `门店与服务|${site.brand.name}`; const description = "了解银妆足银质检、透明计价、私人定制、终身免费清洗抛光等门店服务保障。";
---
<Base {title} {description}><Header /><main id="main-content">
<section class="page-hero" data-word="SERVICE"><div class="container page-hero__inner" data-reveal><div class="breadcrumb"><a href="/">首页</a> / 门店与服务</div><div class="eyebrow"><span></span>STORE & SERVICE</div><h1>购买不是结束<br/><em>长久陪伴才是</em></h1><p>200+ 线下直营门店,以统一标准把真材实料、透明计价与终身养护带到你身边。</p></div></section>
<section id="service-promise" class="section-pad section-cream"><div class="container"><SectionHeading eyebrow="OUR PROMISE" title="四重服务承诺" description="每一项承诺都对应一次真实的购买与佩戴体验。" /><div class="promise-grid promise-grid--light">{site.promises.map((item) => <article data-reveal><span>{item.number}</span><h3>{item.title}</h3><p>{item.desc}</p></article>)}</div></div></section>
<section id="service-flow" class="section-pad section-white"><div class="container"><SectionHeading eyebrow="CUSTOM FLOW" title="从构想到交付,六步清晰可见" align="center" /><div class="flow-grid">{[["01","需求沟通"],["02","材质确认"],["03","款式设计"],["04","工艺报价"],["05","匠人制作"],["06","质检交付"]].map(([num, name]) => <div data-reveal><span>{num}</span><strong>{name}</strong></div>)}</div></div></section>
<section class="section-pad store-service-section"><div class="container store-service-grid"><div><SectionHeading eyebrow="AFTERCARE" title="银饰常戴常新,养护不必成为负担" description="银妆提供终身免费清洗、抛光、去氧化与基础整形服务;具体换款、维修与编绳规则以购买门店说明为准。" light /></div><div class="service-list">{["终身免费清洗", "终身免费抛光", "氧化翻新养护", "基础整形调圈", "DIY 编绳修护", "长期佩戴建议"].map((item) => <span data-reveal>✓ {item}</span>)}</div></div></section>
<CTA title="找到离你更近的银妆服务" text="拨打全国客服热线,说明所在城市与需要,我们将协助你了解门店与服务信息。" />
</main><Footer /></Base>
import { site } from "../data/site";
import { getPublishedArticles } from "../lib/articles";
export async function GET() {
const articles = await getPublishedArticles();
const lines = [
`# ${site.brand.name}`,
"",
`> ${site.brand.tagline}`,
"",
`银妆是拥有 14 年行业积淀的银饰连锁品牌,提供足银首饰零售、来料加工、专属雕刻与银饰 DIY 编织服务。`,
"",
"## 核心页面",
"- [首页](/)", "- [产品与定制](/services/)", "- [关于银妆](/about/)", "- [门店与服务](/join/)", "- [银饰志](/articles/)", "- [常见问题](/faq/)", "- [联系我们](/contact/)",
"", "## 银饰志",
...articles.map((article) => `- [${article.data.title}](/articles/${article.id}/): ${article.data.excerpt}`),
"", `客服电话:${site.contact.phone}`, `公司地址:${site.contact.address}`,
];
return new Response(lines.join("\n"), { headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "public, max-age=3600" } });
}
---
import Base from "../../layouts/Base.astro"; import Header from "../../components/Header.astro"; import Footer from "../../components/Footer.astro"; import SectionHeading from "../../components/SectionHeading.astro"; import ProductCarousel from "../../components/ProductCarousel.astro"; import CTA from "../../components/CTA.astro"; import { site } from "../../data/site";
import b1 from "../../../pic/product/微信图片_20260508131941_571_199.jpg"; import b2 from "../../../pic/product/优先-画板 1(1).jpg"; import b3 from "../../../pic/product/22.jpg"; import b4 from "../../../pic/product/微信图片_20260508131948_578_199.jpg";
import { Image } from "astro:assets";
const images = [b1, b2, b3, b4];
const title = `产品与定制|${site.brand.name}`; const description = "探索银妆足银手镯、银饰DIY编织、银耳环、银项链与私人定制服务。";
---
<Base {title} {description}><Header /><main id="main-content">
<section class="page-hero page-hero--product" data-word="SILVER"><div class="container page-hero__inner" data-reveal><div class="breadcrumb"><a href="/">首页</a> / 产品与定制</div><div class="eyebrow"><span></span>PRODUCT & CUSTOM</div><h1>匠心银作<br/><em>专属信物</em></h1><p>以足银为载体,融合古法手工与个性化定制,满足自戴、送礼、纪念与旧银新生的多重需要。</p></div></section>
<nav class="anchor-nav"><div class="container">{site.products.map((item) => <a href={`#${item.slug}`}><span>{item.index}</span>{item.name}</a>)}</div></nav>
<section class="section-pad section-cream"><div class="container product-detail-list">{site.products.map((item, index) => <article id={item.slug} class:list={["product-detail", { "product-detail--reverse": index % 2 }]}>
<div class="product-detail__image" data-reveal><Image src={images[index]} widths={[480, 720, 960]} sizes="(max-width: 760px) calc(100vw - 62px), 44vw" format="webp" quality={86} alt={item.name} loading={index ? "lazy" : "eager"} /><span>{item.index}</span></div>
<div class="product-detail__content" data-reveal><div class="eyebrow"><span></span>INZUNG {item.index}</div><h2>{item.name}</h2><p class="lead">{item.intro}</p><ul>{item.features.map((feature) => <li><span>✓</span>{feature}</li>)}</ul><div class="spec-box"><strong>规格与工艺</strong><p>{item.specification}</p></div><a class="button button--outline" href="tel:4008113922">咨询该系列</a></div>
</article>)}</div></section>
<section class="section-pad section-white"><div class="container"><SectionHeading eyebrow="MORE SILVER PIECES" title="更多银妆产品" description="不同款式与工艺持续上新,具体库存、圈口与克重欢迎咨询门店。" /><ProductCarousel /></div></section>
<CTA title="有一个特别的银饰构想?" text="无论是刻字、錾刻、来料改造还是全新设计,我们都愿意从一张草图、一句话开始。" />
</main><Footer /></Base>
import type { APIRoute } from "astro";
import path from "node:path";
import fs from "node:fs/promises";
import { UPLOADS_DIR } from "../../lib/article-store";
export const prerender = false;
const MIME_TYPES: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
};
export const GET: APIRoute = async ({ params }) => {
const filename = params.file || "";
if (!/^[a-zA-Z0-9._-]+$/.test(filename)) return new Response("Not Found", { status: 404 });
try {
const content = await fs.readFile(path.join(UPLOADS_DIR, filename));
return new Response(content, {
headers: {
"Content-Type": MIME_TYPES[path.extname(filename).toLowerCase()] || "application/octet-stream",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch {
return new Response("Not Found", { status: 404 });
}
};
:root {
--primary: #d1251a;
--primary-dark: #8e1c17;
--gold: #f4dc84;
--gold-deep: #c6a84b;
--ink: #1e1916;
--text: #4b4541;
--muted: #6b625c;
--cream: #f7f3ea;
--surface: #ffffff;
--border: #e8e0d6;
--dark-soft: #2b2420;
--container: 1200px;
--serif: "Songti SC", "STSong", "Noto Serif CJK SC", serif;
--sans: "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; scroll-padding-top: 110px; }
body { margin: 0; background: var(--cream); color: var(--text); font-family: var(--sans); font-size: 16px; line-height: 1.75; -webkit-font-smoothing: antialiased; }
img { display: block; max-width: 100%; }
a { color: inherit; text-decoration: none; }
button, input, textarea { font: inherit; }
button { cursor: pointer; }
h1, h2, h3, p { margin-top: 0; }
h1, h2, h3 { color: var(--ink); font-family: var(--serif); font-weight: 700; line-height: 1.2; }
.container { width: min(calc(100% - 48px), var(--container)); margin-inline: auto; }
.container--wide { --container: 1380px; }
.section-pad { padding: 112px 0; }
.section-cream { background: var(--cream); }
.section-white { background: var(--surface); }
.skip-link { position: fixed; z-index: 1000; left: 16px; top: -80px; padding: 10px 18px; background: var(--ink); color: #fff; }
.skip-link:focus { top: 16px; }
[data-reveal] { opacity: 0; transform: translateY(24px); transition: opacity .8s ease, transform .8s cubic-bezier(.2,.7,.2,1); }
[data-reveal].is-visible { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } [data-reveal] { opacity: 1; transform: none; transition: none; } }
.button { display: inline-flex; align-items: center; justify-content: center; min-height: 50px; padding: 0 28px; border: 1px solid var(--primary); background: var(--primary); color: #fff; font-weight: 650; letter-spacing: .06em; transition: .25s ease; }
.button:hover { background: var(--primary-dark); border-color: var(--primary-dark); transform: translateY(-2px); }
.button--small { min-height: 42px; padding-inline: 21px; font-size: 14px; }
.button--outline { background: transparent; color: var(--primary); }
.button--outline:hover { color: #fff; }
.button--gold { background: var(--gold); border-color: var(--gold); color: var(--ink); }
.button--gold:hover { background: #ffe69a; border-color: #ffe69a; color: var(--ink); }
.text-link { display: inline-flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--gold-deep); color: var(--ink); font-weight: 650; padding-bottom: 5px; white-space: nowrap; }
.text-link span { color: var(--primary); }
.text-link--light { color: #fff; border-color: rgba(244,220,132,.55); }
.text-link--light span { color: var(--gold); }
.eyebrow { display: flex; align-items: center; gap: 13px; margin-bottom: 22px; color: var(--gold-deep); font-size: 12px; font-weight: 750; letter-spacing: .22em; text-transform: uppercase; }
.eyebrow > span { width: 34px; height: 1px; background: currentColor; }
.eyebrow--gold { color: var(--gold); }
.section-heading { max-width: 680px; }
.section-heading h2 { margin-bottom: 20px; font-size: clamp(34px, 4vw, 55px); letter-spacing: -.035em; }
.section-heading > p { margin: 0; color: var(--muted); font-size: 17px; }
.section-heading--center { margin-inline: auto; text-align: center; }
.section-heading--center .eyebrow { justify-content: center; }
.section-heading--light h2 { color: #fff; }
.section-heading--light > p { color: #d8d0c8; }
.section-intro-row { display: flex; justify-content: space-between; align-items: end; gap: 36px; margin-bottom: 52px; }
.section-index { color: var(--gold-deep); font-family: var(--serif); letter-spacing: .14em; }
.breadcrumb { margin-bottom: 32px; color: #8c817a; font-size: 13px; letter-spacing: .05em; }
.breadcrumb a:hover { color: var(--primary); }
.top-note { background: var(--ink); color: #d8d0c8; font-size: 12px; letter-spacing: .08em; }
.top-note__inner { display: flex; justify-content: space-between; min-height: 35px; align-items: center; }
.top-note a { color: var(--gold); }
.site-header { position: sticky; z-index: 100; top: 0; background: rgba(247,243,234,.95); border-bottom: 1px solid rgba(232,224,214,.9); backdrop-filter: blur(16px); transition: box-shadow .25s ease; }
.site-header.is-scrolled { box-shadow: 0 12px 35px rgba(46,32,26,.08); }
.site-header__inner { display: flex; min-height: 79px; align-items: center; gap: 34px; }
.brand { display: flex; width: 154px; flex: 0 0 auto; }
.brand img { width: 154px; height: auto; }
.desktop-nav { flex: 1; }
.desktop-nav > ul { display: flex; justify-content: center; margin: 0; padding: 0; list-style: none; }
.nav-item { position: relative; }
.nav-link { display: flex; min-height: 79px; align-items: center; gap: 5px; padding: 0 14px; color: var(--ink); font-size: 14px; font-weight: 600; }
.nav-link[aria-current="page"], .nav-link:hover { color: var(--primary); }
.nav-link[aria-current="page"]::after { position: absolute; right: 14px; bottom: -1px; left: 14px; height: 2px; background: var(--primary); content: ""; }
.nav-chevron { color: #a79b92; font-size: 11px; transition: transform .2s; }
.nav-item:hover .nav-chevron { transform: rotate(180deg); }
.nav-dropdown { position: absolute; top: calc(100% + 10px); left: 50%; width: 220px; padding: 10px; background: #fff; border: 1px solid var(--border); opacity: 0; pointer-events: none; transform: translate(-50%, -8px); transition: .2s ease; box-shadow: 0 20px 50px rgba(50,34,27,.12); }
.nav-item:hover .nav-dropdown, .nav-item:focus-within .nav-dropdown { opacity: 1; pointer-events: auto; transform: translate(-50%, 0); }
.nav-dropdown ul { margin: 0; padding: 0; list-style: none; }
.nav-dropdown a { display: flex; justify-content: space-between; padding: 9px 11px; color: var(--text); font-size: 13px; }
.nav-dropdown a:hover { background: var(--cream); color: var(--primary); }
.nav-dropdown a span { color: var(--gold-deep); }
.mobile-nav { display: none; margin-left: auto; }
.mobile-nav summary { display: grid; width: 43px; height: 43px; place-content: center; gap: 5px; border: 1px solid var(--border); list-style: none; }
.mobile-nav summary::-webkit-details-marker { display: none; }
.mobile-nav summary span { width: 20px; height: 1px; background: var(--ink); }
.mobile-nav nav { position: fixed; top: 114px; right: 0; left: 0; max-height: calc(100vh - 114px); padding: 24px; overflow: auto; background: var(--cream); border-top: 1px solid var(--border); box-shadow: 0 24px 50px rgba(0,0,0,.12); }
.mobile-nav:not([open]) nav { display: none; }
.mobile-nav__group { padding: 13px 0; border-bottom: 1px solid var(--border); }
.mobile-nav__primary { color: var(--ink); font-family: var(--serif); font-size: 21px; font-weight: 700; }
.mobile-subnav { display: flex; flex-wrap: wrap; gap: 7px 16px; margin-top: 8px; color: var(--muted); font-size: 13px; }
.mobile-nav .button { width: 100%; margin-top: 22px; }
.hero { position: relative; min-height: 720px; overflow: hidden; background: linear-gradient(115deg, #f7f3ea 0 62%, #efe6d8 62%); }
.hero-pattern { position: absolute; inset: 0; opacity: .37; background-image: radial-gradient(circle at 1px 1px, rgba(198,168,75,.24) 1px, transparent 0); background-size: 24px 24px; mask-image: linear-gradient(to right, #000, transparent 55%); }
.hero__grid { position: relative; display: grid; grid-template-columns: .94fr 1.06fr; align-items: center; min-height: 720px; gap: 72px; padding-block: 62px 85px; }
.hero__content { max-width: 580px; }
.hero h1 { margin-bottom: 28px; font-size: clamp(54px, 6vw, 86px); line-height: 1.08; letter-spacing: -.06em; }
.hero h1 em, .page-hero h1 em { display: block; color: var(--primary); font-style: normal; }
.hero__content > p { max-width: 580px; margin-bottom: 34px; color: var(--text); font-size: 18px; }
.hero__actions { display: flex; gap: 14px; }
.hero__trust { display: flex; flex-wrap: wrap; gap: 11px 22px; margin-top: 38px; color: var(--muted); font-size: 13px; }
.hero__trust span::before { margin-right: 8px; color: var(--gold-deep); content: "◆"; font-size: 7px; }
.hero__visual { position: relative; padding: 32px 42px 42px 28px; }
.hero__visual::before { position: absolute; inset: 3px 4px auto auto; width: 62%; height: 64%; border: 1px solid var(--gold-deep); content: ""; }
.hero__image { position: relative; aspect-ratio: 1 / 1; overflow: hidden; box-shadow: 0 30px 80px rgba(68,35,25,.17); }
.hero__image img { width: 100%; height: 100%; object-fit: cover; }
.hero-seal { position: absolute; top: 0; right: 0; display: grid; width: 128px; height: 128px; place-items: center; background: var(--primary); color: var(--gold); border-radius: 50%; box-shadow: 0 15px 45px rgba(142,28,23,.24); }
.hero-seal img { width: 74px; height: 74px; object-fit: contain; border-radius: 50%; }
.hero-seal span { position: absolute; font-size: 8px; letter-spacing: .16em; opacity: 0; }
.hero-caption { position: absolute; right: 8px; bottom: 0; display: flex; min-width: 240px; align-items: center; gap: 18px; padding: 17px 22px; background: var(--ink); color: #fff; }
.hero-caption > span { color: var(--gold); font-family: var(--serif); font-size: 26px; }
.hero-caption strong { display: block; color: #fff; font-family: var(--serif); }
.hero-caption small { color: #bfb5ad; }
.hero-scroll { position: absolute; bottom: 28px; left: 50%; display: flex; align-items: center; gap: 12px; color: #8f837a; font-size: 11px; letter-spacing: .18em; transform: translateX(-50%); }
.hero-scroll span { width: 1px; height: 30px; background: var(--gold-deep); }
.stat-band { background: var(--ink); color: #fff; }
.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: clamp(28px, 4vw, 68px); }
.stat-grid > div { min-height: 180px; padding: 46px 0; text-align: center; }
.stat-grid strong { display: block; margin-bottom: 3px; color: var(--gold); font-family: var(--serif); font-size: clamp(35px, 4vw, 50px); }
.stat-grid p { margin: 0; color: #c9c0b8; font-size: 13px; letter-spacing: .08em; }
.product-category-grid { display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--border); border-left: 1px solid var(--border); }
.product-category-card { position: relative; min-height: 320px; padding: 35px 30px; overflow: hidden; background: #fff; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); transition: .35s ease; }
.product-category-card::before { position: absolute; top: 0; right: 0; left: 0; height: 4px; background: var(--primary); content: ""; transform: scaleX(0); transform-origin: left; transition: transform .35s ease; }
.product-category-card:hover { transform: translateY(-7px); box-shadow: 0 28px 60px rgba(55,37,28,.1); }
.product-category-card:hover::before { transform: scaleX(1); }
.product-category-card > span { color: var(--gold-deep); font-family: var(--serif); }
.product-category-card h3 { margin: 45px 0 17px; font-size: 28px; }
.product-category-card p { color: var(--muted); font-size: 14px; }
.product-category-card strong { position: absolute; bottom: 30px; left: 30px; color: var(--primary); font-size: 13px; }
.gallery-section { overflow: hidden; }
.product-carousel__viewport { overflow: hidden; }
.product-carousel__track { display: flex; gap: 22px; overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: none; }
.product-carousel__track::-webkit-scrollbar { display: none; }
.product-slide { flex: 0 0 min(31vw, 390px); scroll-snap-align: start; }
.product-slide__image { aspect-ratio: 1 / 1; overflow: hidden; background: #eee8df; }
.product-slide__image img { width: 100%; height: 100%; object-fit: cover; transition: transform .65s ease; }
.product-slide:hover img { transform: scale(1.045); }
.product-slide__meta { position: relative; padding: 20px 2px; }
.product-slide__meta span { color: var(--gold-deep); font-size: 11px; letter-spacing: .12em; }
.product-slide__meta h3 { margin: 5px 0 0; font-size: 21px; }
.product-carousel__controls { display: flex; align-items: center; justify-content: space-between; gap: 30px; margin-top: 25px; }
.carousel-progress { flex: 1; max-width: 600px; height: 1px; background: var(--border); }
.carousel-progress span { display: block; width: 12%; height: 2px; background: var(--primary); transition: width .2s; }
.product-carousel__controls button { width: 46px; height: 46px; margin-left: 8px; background: transparent; border: 1px solid var(--border); color: var(--ink); }
.product-carousel__controls button:hover { background: var(--ink); color: #fff; }
.craft-section { background: var(--ink); }
.craft-grid { display: grid; grid-template-columns: .88fr 1.12fr; align-items: center; gap: clamp(55px, 8vw, 115px); }
.craft-visual { position: relative; padding: 30px 0 30px 30px; }
.craft-visual::before { position: absolute; inset: 0 25% 0 0; border: 1px solid rgba(244,220,132,.35); content: ""; }
.craft-visual img { position: relative; width: 100%; aspect-ratio: .82; object-fit: cover; }
.craft-visual__stamp { position: absolute; right: -20px; bottom: 0; display: grid; width: 105px; height: 105px; place-content: center; background: var(--primary); color: var(--gold); border-radius: 50%; text-align: center; font-family: var(--serif); font-size: 17px; line-height: 1.35; }
.craft-steps { margin: 42px 0 36px; padding: 0; list-style: none; border-top: 1px solid rgba(255,255,255,.13); }
.craft-steps li { display: grid; grid-template-columns: 60px 1fr; gap: 18px; padding: 22px 0; border-bottom: 1px solid rgba(255,255,255,.13); }
.craft-steps li > span { color: var(--gold); font-family: var(--serif); }
.craft-steps h3 { margin: 0 0 5px; color: #fff; font-size: 20px; }
.craft-steps p { margin: 0; color: #bfb6ae; font-size: 14px; }
.brand-story-grid { display: grid; grid-template-columns: 1fr .55fr .9fr; gap: 60px; align-items: center; }
.brand-story-number { position: relative; text-align: center; }
.brand-story-number strong { display: block; color: var(--primary); font-family: var(--serif); font-size: clamp(100px, 13vw, 180px); font-weight: 400; line-height: .8; }
.brand-story-number span { display: block; margin-top: 25px; color: var(--gold-deep); font-size: 11px; letter-spacing: .22em; }
.brand-story-list > div { position: relative; padding: 24px 0 24px 50px; border-bottom: 1px solid var(--border); }
.brand-story-list > div > span { position: absolute; top: 28px; left: 0; color: var(--gold-deep); font-family: var(--serif); }
.brand-story-list h3 { margin: 0 0 8px; font-size: 21px; }
.brand-story-list p { margin: 0; color: var(--muted); font-size: 14px; }
.scene-grid { display: grid; grid-template-columns: repeat(4, 1fr); margin-top: 54px; border-top: 1px solid var(--border); border-left: 1px solid var(--border); }
.scene-grid article { min-height: 300px; padding: 34px 28px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); transition: .3s; }
.scene-grid article:hover { background: var(--primary); color: #fff; }
.scene-grid article > span { color: var(--gold-deep); font-family: var(--serif); }
.scene-grid h3 { margin-top: 60px; font-size: 26px; transition: color .3s; }
.scene-grid p { color: var(--muted); font-size: 14px; transition: color .3s; }
.scene-grid a { display: inline-block; margin-top: 18px; color: var(--primary); font-weight: 700; font-size: 13px; }
.scene-grid article:hover h3, .scene-grid article:hover p, .scene-grid article:hover a { color: #fff; }
.scene-grid article:hover > span { color: var(--gold); }
.service-promise-section { background: var(--dark-soft); }
.promise-grid { display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid rgba(255,255,255,.12); border-left: 1px solid rgba(255,255,255,.12); }
.promise-grid article { min-height: 250px; padding: 32px 28px; border-right: 1px solid rgba(255,255,255,.12); border-bottom: 1px solid rgba(255,255,255,.12); }
.promise-grid article > span { color: var(--gold); font-family: var(--serif); }
.promise-grid h3 { margin: 55px 0 12px; color: #fff; font-size: 24px; }
.promise-grid p { color: #c8beb6; font-size: 14px; }
.promise-grid--light { border-color: var(--border); }
.promise-grid--light article { background: #fff; border-color: var(--border); }
.promise-grid--light h3 { color: var(--ink); }
.promise-grid--light p { color: var(--muted); }
.cta-band { position: relative; overflow: hidden; background: var(--primary); }
.cta-band::before { position: absolute; right: -5vw; bottom: -14vw; width: 44vw; height: 44vw; border: 1px solid rgba(244,220,132,.25); border-radius: 50%; content: ""; }
.cta-band__inner { position: relative; display: flex; min-height: 360px; justify-content: space-between; align-items: center; gap: 50px; }
.cta-band h2 { max-width: 680px; margin-bottom: 17px; color: #fff; font-size: clamp(36px, 4.5vw, 61px); }
.cta-band p { max-width: 660px; margin: 0; color: #f6d8d4; }
.cta-band__actions { display: flex; flex-direction: column; align-items: flex-start; gap: 20px; }
.page-hero { position: relative; min-height: 500px; overflow: hidden; background: var(--cream); }
.page-hero::after { position: absolute; right: 5%; bottom: -4%; color: rgba(209,37,26,.055); content: attr(data-word); font-family: var(--serif); font-size: min(20vw, 280px); font-weight: 700; line-height: 1; letter-spacing: -.08em; }
.page-hero__inner { position: relative; z-index: 1; padding-top: 80px; padding-bottom: 90px; }
.page-hero h1 { margin-bottom: 26px; font-size: clamp(52px, 6.5vw, 83px); letter-spacing: -.055em; }
.page-hero p { max-width: 690px; margin: 0; color: var(--muted); font-size: 18px; }
.anchor-nav { position: sticky; z-index: 50; top: 79px; background: #fff; border-block: 1px solid var(--border); }
.anchor-nav .container { display: grid; grid-template-columns: repeat(4, 1fr); }
.anchor-nav a { padding: 17px 22px; border-right: 1px solid var(--border); color: var(--ink); text-align: center; font-size: 14px; font-weight: 650; }
.anchor-nav a:first-child { border-left: 1px solid var(--border); }
.anchor-nav a:hover { background: var(--primary); color: #fff; }
.anchor-nav span { margin-right: 10px; color: var(--gold-deep); font-family: var(--serif); }
.product-detail-list { display: grid; gap: 120px; }
.product-detail { display: grid; grid-template-columns: 1fr 1fr; align-items: center; gap: clamp(55px, 8vw, 110px); scroll-margin-top: 160px; }
.product-detail--reverse .product-detail__image { order: 2; }
.product-detail__image { position: relative; padding: 0 0 30px 30px; }
.product-detail__image::before { position: absolute; inset: 30px 30px 0 0; border: 1px solid var(--gold-deep); content: ""; }
.product-detail__image img { position: relative; aspect-ratio: 1; width: 100%; object-fit: cover; }
.product-detail__image > span { position: absolute; bottom: 0; left: 0; display: grid; width: 86px; height: 86px; place-items: center; background: var(--primary); color: var(--gold); font-family: var(--serif); font-size: 26px; }
.product-detail__content h2 { margin-bottom: 20px; font-size: clamp(38px, 4vw, 55px); }
.product-detail__content .lead { color: var(--text); font-size: 17px; }
.product-detail__content ul { display: grid; gap: 9px; margin: 25px 0; padding: 0; list-style: none; }
.product-detail__content li span { margin-right: 12px; color: var(--primary); }
.spec-box { margin: 25px 0 30px; padding: 20px 22px; background: #fff; border-left: 3px solid var(--gold-deep); }
.spec-box strong { color: var(--ink); }
.spec-box p { margin: 7px 0 0; color: var(--muted); font-size: 14px; }
.about-story-grid { display: grid; grid-template-columns: .85fr 1.15fr; align-items: start; gap: clamp(55px, 8vw, 110px); }
.about-story-grid img { width: 100%; aspect-ratio: .82; object-fit: cover; }
.story-paragraph { color: var(--text); font-size: 16px; }
.advantage-grid { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: 52px; border-left: 1px solid var(--border); }
.advantage-grid article { min-height: 270px; padding: 34px; border-block: 1px solid var(--border); border-right: 1px solid var(--border); }
.advantage-grid article > span { color: var(--gold-deep); font-family: var(--serif); }
.advantage-grid h3 { margin: 55px 0 12px; font-size: 27px; }
.advantage-grid p { color: var(--muted); }
.values-section { background: var(--ink); }
.values-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 100px; align-items: center; }
.values-grid blockquote { margin: 0; padding: 45px; border: 1px solid rgba(244,220,132,.36); color: var(--gold); font-family: var(--serif); font-size: clamp(28px, 3.2vw, 45px); line-height: 1.55; }
.flow-grid { display: grid; grid-template-columns: repeat(6, 1fr); margin-top: 60px; }
.flow-grid > div { position: relative; padding: 30px 12px; text-align: center; }
.flow-grid > div:not(:last-child)::after { position: absolute; top: 48px; right: -15px; width: 30px; height: 1px; background: var(--gold-deep); content: ""; }
.flow-grid span { display: grid; width: 65px; height: 65px; margin: 0 auto 18px; place-items: center; border: 1px solid var(--gold-deep); border-radius: 50%; color: var(--primary); font-family: var(--serif); }
.flow-grid strong { color: var(--ink); }
.store-service-section { background: var(--ink); }
.store-service-grid { display: grid; grid-template-columns: 1.1fr .9fr; gap: 100px; align-items: center; }
.service-list { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.service-list span { padding: 18px; border: 1px solid rgba(255,255,255,.14); color: #ddd4cc; }
.service-list span::first-letter { color: var(--gold); }
.faq-layout { display: grid; grid-template-columns: 310px 1fr; gap: 90px; align-items: start; }
.faq-layout aside { position: sticky; top: 150px; }
.faq-layout aside > span { color: var(--gold-deep); font-family: var(--serif); }
.faq-layout aside h2 { margin-top: 35px; font-size: 34px; }
.faq-layout aside p { color: var(--muted); }
.faq-layout aside .button { margin-top: 15px; }
.faq-list { border-top: 1px solid var(--border); }
.faq-list details { border-bottom: 1px solid var(--border); }
.faq-list summary { display: grid; grid-template-columns: 46px 1fr 24px; align-items: center; gap: 12px; min-height: 92px; color: var(--ink); list-style: none; cursor: pointer; }
.faq-list summary::-webkit-details-marker { display: none; }
.faq-list summary > span { color: var(--gold-deep); font-family: var(--serif); }
.faq-list summary strong { font-family: var(--serif); font-size: 21px; }
.faq-list summary i { position: relative; width: 20px; height: 20px; }
.faq-list summary i::before, .faq-list summary i::after { position: absolute; top: 9px; left: 2px; width: 16px; height: 1px; background: var(--primary); content: ""; transition: transform .25s; }
.faq-list summary i::after { transform: rotate(90deg); }
.faq-list details[open] summary i::after { transform: rotate(0); }
.faq-list details > div { padding: 0 35px 28px 58px; color: var(--muted); }
.faq-list details p { margin: 0; }
.contact-hero { position: relative; overflow: hidden; background: var(--ink); color: #fff; }
.contact-hero::before { position: absolute; inset: 0; opacity: .12; background-image: radial-gradient(circle at 1px 1px, var(--gold) 1px, transparent 0); background-size: 28px 28px; content: ""; }
.contact-hero__grid { position: relative; display: grid; grid-template-columns: 1.15fr .85fr; min-height: 570px; align-items: center; gap: 80px; padding-block: 80px; }
.contact-hero .breadcrumb { color: #9c928a; }
.contact-hero h1 { margin-bottom: 22px; color: #fff; font-size: clamp(55px, 7vw, 92px); }
.contact-hero p { max-width: 660px; color: #d0c7bf; font-size: 17px; }
.contact-hero .button { margin-top: 20px; }
.contact-seal { position: relative; display: grid; width: min(28vw, 330px); aspect-ratio: 1; place-items: center; justify-self: center; border: 1px solid rgba(244,220,132,.42); border-radius: 50%; }
.contact-seal::before, .contact-seal::after { position: absolute; inset: 24px; border: 1px solid rgba(244,220,132,.2); border-radius: 50%; content: ""; }
.contact-seal::after { inset: 48px; }
.contact-seal strong { color: var(--gold); font-family: var(--serif); font-size: 105px; font-weight: 400; }
.contact-seal span { position: absolute; right: -20px; bottom: 40px; padding: 12px 18px; background: var(--primary); color: #fff; font-size: 13px; letter-spacing: .12em; }
.contact-grid { display: grid; grid-template-columns: repeat(2, 1fr); border-top: 1px solid var(--border); border-left: 1px solid var(--border); }
.contact-grid article { min-height: 260px; padding: 35px; background: #fff; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.contact-grid article > span { color: var(--gold-deep); font-family: var(--serif); }
.contact-grid h2 { margin: 36px 0 12px; font-size: 22px; }
.contact-big-link { color: var(--primary); font-family: var(--serif); font-size: clamp(27px, 4vw, 43px); font-weight: 700; }
.contact-big-link--small { font-size: 28px; }
.contact-grid address { max-width: 500px; color: var(--ink); font-size: 18px; font-style: normal; }
.contact-grid p { margin: 15px 0 0; color: var(--muted); font-size: 13px; }
.social-name { display: block; color: var(--ink); font-size: 18px; }
.visit-grid { display: grid; grid-template-columns: .75fr 1.25fr; gap: 100px; }
.visit-grid h2 { font-size: 43px; }
.visit-grid ol { margin: 0; padding: 0; list-style: none; border-top: 1px solid var(--border); }
.visit-grid li { display: grid; grid-template-columns: 55px 1fr; gap: 20px; padding: 22px 0; border-bottom: 1px solid var(--border); }
.visit-grid li > span { color: var(--gold-deep); font-family: var(--serif); }
.visit-grid strong { color: var(--ink); }
.visit-grid p { margin: 4px 0 0; color: var(--muted); font-size: 14px; }
.contact-bottom { padding: 100px 0; background: var(--primary); color: #fff; text-align: center; }
.contact-bottom p { color: var(--gold); font-size: 11px; letter-spacing: .25em; }
.contact-bottom h2 { margin: 22px 0 35px; color: #fff; font-size: clamp(47px, 6vw, 76px); }
.article-hero { min-height: 430px; }
.article-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 22px; }
.article-card { min-height: 320px; background: #fff; border: 1px solid var(--border); transition: .3s; }
.article-card:hover { transform: translateY(-6px); box-shadow: 0 24px 55px rgba(54,35,27,.1); border-top-color: var(--primary); }
.article-card a { display: block; height: 100%; padding: 28px; }
.article-card__meta { display: flex; justify-content: space-between; color: var(--muted); font-size: 12px; }
.article-card__meta span { color: var(--primary); }
.article-card__index { margin: 35px 0 12px; color: var(--gold-deep); font-family: var(--serif); }
.article-card h2 { font-size: 23px; }
.article-card p { color: var(--muted); font-size: 14px; }
.article-card strong { display: inline-block; margin-top: 16px; color: var(--primary); font-size: 13px; }
.article-tabs { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 42px; }
.article-tab { padding: 9px 16px; background: #fff; border: 1px solid var(--border); color: var(--muted); font-size: 13px; }
.article-tab span { color: var(--gold-deep); }
.article-tab.active, .article-tab:hover { background: var(--primary); border-color: var(--primary); color: #fff; }
.article-tab.active span, .article-tab:hover span { color: var(--gold); }
.article-empty { padding: 60px; background: #fff; text-align: center; }
.article-pagination { display: flex; justify-content: center; gap: 8px; margin-top: 45px; }
.article-pagination > * { display: grid; width: 42px; height: 42px; place-items: center; border: 1px solid var(--border); }
.article-pagination .active { background: var(--primary); border-color: var(--primary); color: #fff; }
.article-detail-head { padding: 80px 0 70px; background: var(--cream); border-bottom: 1px solid var(--border); }
.article-detail-head__inner { max-width: 900px; }
.article-detail-category { color: var(--primary); font-size: 13px; font-weight: 700; }
.article-detail-head h1 { margin: 20px 0; font-size: clamp(42px, 6vw, 70px); }
.article-detail-head p { max-width: 760px; color: var(--muted); font-size: 18px; }
.article-detail-meta { display: flex; gap: 22px; color: var(--muted); font-size: 13px; }
.article-detail-layout { display: grid; grid-template-columns: 220px minmax(0, 760px); justify-content: center; gap: 70px; padding-block: 75px 110px; }
.article-detail-layout aside { color: var(--muted); font-size: 13px; }
.article-detail-layout aside a { display: block; margin-bottom: 25px; color: var(--primary); font-weight: 700; }
.article-detail-layout aside p { padding-top: 20px; border-top: 1px solid var(--border); }
.article-prose { color: var(--text); font-size: 17px; line-height: 1.9; }
.article-prose h2, .article-prose h3 { margin-top: 2em; }
.article-prose h2 { font-size: 31px; }
.article-prose h3 { font-size: 23px; }
.article-prose img { margin: 30px 0; }
.article-prose blockquote { margin: 30px 0; padding: 20px 25px; background: var(--cream); border-left: 3px solid var(--gold-deep); }
.article-prose a { color: var(--primary); text-decoration: underline; }
.not-found { display: grid; min-height: 620px; place-items: center; text-align: center; }
.not-found .container > span { display: block; color: rgba(209,37,26,.12); font-family: var(--serif); font-size: 180px; line-height: .8; }
.not-found .eyebrow { justify-content: center; margin-top: 25px; }
.not-found h1 { font-size: 48px; }
.site-footer { padding-top: 75px; background: var(--ink); color: #bdb3ab; }
.footer-grid { display: grid; grid-template-columns: 1.4fr .7fr .8fr 1.1fr; gap: 55px; padding-bottom: 60px; }
.footer-logo { display: block; width: 188px; margin-bottom: 24px; }
.footer-logo img { width: 100%; height: auto; object-fit: contain; }
.footer-brand p { max-width: 320px; }
.footer-tags { display: flex; flex-wrap: wrap; gap: 18px; }
.footer-tags span { color: var(--gold); font-size: 11px; }
.footer-grid h2 { margin: 12px 0 22px; color: #fff; font-family: var(--sans); font-size: 14px; }
.footer-grid > div:not(:first-child) > a { display: block; margin: 10px 0; font-size: 13px; }
.footer-grid a:hover { color: var(--gold); }
.footer-phone { color: var(--gold); font-family: var(--serif); font-size: 26px !important; }
.footer-contact address { max-width: 300px; margin: 14px 0; font-size: 13px; font-style: normal; }
.footer-arrow { margin-top: 22px !important; color: #fff; }
.footer-bottom { display: flex; justify-content: space-between; gap: 30px; padding: 22px 0; border-top: 1px solid rgba(255,255,255,.1); color: #827871; font-size: 11px; }
.footer-bottom p { margin: 0; }
@media (max-width: 1080px) {
.desktop-nav, .header-cta { display: none; }
.mobile-nav { display: block; }
.hero__grid { gap: 35px; }
.hero h1 { font-size: 60px; }
.product-category-grid, .scene-grid, .promise-grid { grid-template-columns: repeat(2, 1fr); }
.brand-story-grid { grid-template-columns: 1fr 1fr; }
.brand-story-number { order: 3; text-align: left; }
.brand-story-number strong { font-size: 120px; }
.flow-grid { grid-template-columns: repeat(3, 1fr); }
.flow-grid > div:nth-child(3)::after { display: none; }
.footer-grid { grid-template-columns: 1.4fr 1fr 1fr; }
.footer-contact { grid-column: 1 / -1; }
}
@media (max-width: 760px) {
.container { width: min(calc(100% - 32px), var(--container)); }
.section-pad { padding: 76px 0; }
.top-note__inner { justify-content: center; min-height: 31px; }
.top-note__inner span { display: none; }
.site-header__inner { min-height: 68px; }
.brand, .brand img { width: 124px; }
.mobile-nav nav { top: 99px; max-height: calc(100vh - 99px); }
.section-intro-row { align-items: flex-start; flex-direction: column; margin-bottom: 38px; }
.section-heading h2 { font-size: 37px; }
.hero { min-height: 0; background: var(--cream); }
.hero__grid { grid-template-columns: 1fr; padding-block: 60px 75px; }
.hero h1 { font-size: 50px; }
.hero__content > p { font-size: 16px; }
.hero__actions { align-items: stretch; flex-direction: column; }
.hero__visual { padding: 20px 25px 32px 12px; }
.hero-seal { width: 86px; height: 86px; }
.hero-seal img { width: 52px; height: 52px; }
.hero-caption { right: 0; min-width: 210px; }
.hero-scroll { display: none; }
.stat-grid { grid-template-columns: repeat(2, 1fr); }
.stat-grid > div { min-height: 145px; padding: 32px 0; }
.product-category-grid, .scene-grid, .promise-grid { grid-template-columns: 1fr; }
.product-category-card { min-height: 270px; }
.product-slide { flex-basis: 78vw; }
.craft-grid, .about-story-grid, .values-grid, .store-service-grid, .faq-layout, .contact-hero__grid, .visit-grid { grid-template-columns: 1fr; }
.craft-grid { gap: 58px; }
.craft-visual { max-width: 520px; }
.craft-visual__stamp { right: 0; }
.brand-story-grid { grid-template-columns: 1fr; gap: 45px; }
.brand-story-number { order: initial; text-align: left; }
.cta-band__inner { min-height: 420px; align-items: flex-start; flex-direction: column; justify-content: center; }
.page-hero { min-height: 0; }
.page-hero__inner { padding-block: 60px 72px; }
.page-hero h1 { font-size: 51px; }
.anchor-nav { top: 68px; overflow-x: auto; }
.anchor-nav .container { display: flex; width: max-content; min-width: 100%; }
.anchor-nav a { min-width: 145px; }
.product-detail-list { gap: 90px; }
.product-detail { grid-template-columns: 1fr; gap: 40px; }
.product-detail--reverse .product-detail__image { order: initial; }
.advantage-grid { grid-template-columns: 1fr; }
.values-grid { gap: 45px; }
.flow-grid { grid-template-columns: repeat(2, 1fr); }
.flow-grid > div:nth-child(3)::after { display: block; }
.flow-grid > div:nth-child(even)::after { display: none; }
.service-list { grid-template-columns: 1fr; }
.faq-layout { gap: 45px; }
.faq-layout aside { position: static; }
.contact-hero__grid { gap: 40px; }
.contact-seal { width: 260px; }
.contact-grid { grid-template-columns: 1fr; }
.article-grid { grid-template-columns: 1fr; }
.article-detail-layout { grid-template-columns: 1fr; gap: 20px; padding-top: 45px; }
.article-detail-layout aside p { display: none; }
.footer-grid { grid-template-columns: 1fr 1fr; }
.footer-brand, .footer-contact { grid-column: 1 / -1; }
.footer-bottom { flex-direction: column; }
}
@media (max-width: 430px) {
.hero h1, .page-hero h1 { font-size: 43px; }
.section-heading h2 { font-size: 33px; }
.stat-grid strong { font-size: 34px; }
.flow-grid { grid-template-columns: 1fr; }
.flow-grid > div::after { display: none !important; }
.footer-grid { grid-template-columns: 1fr; }
.footer-grid > div { grid-column: 1; }
}
{
"extends": "astro/tsconfigs/strict"
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment