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 diff is collapsed.
{
"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"
}
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
<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>
This diff is collapsed.
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;
}
This diff is collapsed.
---
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 });
}
};
This diff is collapsed.
{
"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