Commit 4e807b40 authored by xuchentao's avatar xuchentao

feat: initialize Jiayun official website

parents
# 上线前请替换为真实域名与高强度随机密钥
SITE_URL=https://www.jiayunche.com
CMS_PORT=8790
CMS_PASSWORD=change-this-password
CMS_SECRET=replace-with-at-least-32-random-characters
# CMS_API_KEY=optional-api-key
# CMS_DATA_DIR=/root/jiayun/shared/cms-data
# CMS_BUILD_LOCK=/root/jiayun/shared/site-build.lock
node_modules/
dist/
.astro/
.env
.DS_Store
.codex-tmp/
public/uploads/*
!public/uploads/.gitkeep
# 嘉运网约车官网 · GitLab CI/CD 安全部署
# 流程:幂等部署 → 配置 Nginx → 验证与清理
# 触发:push 到 main 分支
#
# ⚠️ GitLab 版本:11.7(较旧)
# - 不使用 workflow:rules
# - 不使用 rules
# - 不使用 needs/DAG
# - 不使用 resource_group
# - 不使用 cache:policy
# 仅使用 only、stages、script、when 等基础语法。
# Runner 注册时请使用标签:jiayun-prod
variables:
DEPLOY_ROOT: "/root/jiayun"
RELEASES_DIR: "/root/jiayun/releases"
SHARED_DIR: "/root/jiayun/shared"
SHARED_ENV: "/root/jiayun/shared/.env"
APP_NAME: "jiayun"
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:
- jiayun-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:
- jiayun-prod
only:
- main
script:
- echo "[nginx] 开始配置公网入口:$PUBLIC_HOST"
- test -f "$DEPLOY_ROOT/current/deploy/nginx/jiayun.conf" || (echo "缺少 Nginx 配置" && exit 2)
- sudo -n /usr/bin/install -o root -g root -m 0644 "$DEPLOY_ROOT/current/deploy/nginx/jiayun.conf" /etc/nginx/conf.d/jiayun.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:
- jiayun-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 5 + Node standalone 构建,公开官网与文章后台运行在同一个服务中。企业资料集中维护在 `src/data/site.ts`,文章由 Markdown 文件与内置 CMS 管理。
## 本地运行
需要 Node.js 20.20.x:
```bash
npm install
cp .env.example .env
npm run dev
```
- 官网:`http://localhost:4321/`
- 文章后台:`http://localhost:4321/admin/`
- 跑车指南:`http://localhost:4321/articles/`
生产模式:
```bash
npm run build
npm start
```
默认生产端口为 `8790`,可用 `CMS_PORT` 覆盖。
## 内容管理
- 工作稿:`src/content/articles/`
- 已发布版本:`src/content/published/`
- 分类:`src/content/categories.json`
- 上传图片:`public/uploads/`
生产环境建议设置 `CMS_DATA_DIR` 指向 release 目录之外的持久化路径。后台支持文章新建、保存草稿、预览、发布、下架、删除、图片上传和分类管理。发布操作会在共享锁内原子构建站点,失败时恢复内容快照。
## 必需环境变量
```dotenv
SITE_URL=https://你的正式域名
CMS_PORT=8790
CMS_PASSWORD=高强度后台密码
CMS_SECRET=至少32位随机字符串
CMS_DATA_DIR=/root/jiayun/shared/cms-data
CMS_BUILD_LOCK=/root/jiayun/shared/site-build.lock
```
生产环境必须修改示例密码和密钥。`CMS_API_KEY` 为可选的 Bearer API 访问密钥。
## SEO 与部署
项目包含 canonical、Open Graph、Organization/LocalBusiness/Service/FAQ/Article 结构化数据、sitemap、robots.txt、llms.txt 与 web manifest。修改正式域名时,同时更新 `SITE_URL``public/robots.txt` 中的 Sitemap。
`scripts/` 保留了参考项目的共享构建锁、原子替换、release 部署、健康检查与自动回退机制;`deploy/nginx/jiayun.conf` 是反向代理示例。GitLab CI 使用 release 目录部署,需按真实服务器调整 Runner 标签、域名/IP 与目录。
import { defineConfig } from "astro/config";
import sitemap from "@astrojs/sitemap";
import node from "@astrojs/node";
import path from "node:path";
const customOutDir = process.env.JIAYUN_BUILD_OUT_DIR;
export default defineConfig({
site: process.env.SITE_URL || "https://www.jiayunche.com",
integrations: [sitemap()],
adapter: node({ mode: "standalone" }),
...(customOutDir ? { outDir: path.resolve(customOutDir) } : {}),
build: { format: "directory", inlineStylesheets: "auto" },
trailingSlash: "ignore",
compressHTML: true,
});
# 嘉运网约车官网 Nginx 配置示例
server {
listen 80;
server_name _;
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;
}
}
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "jiayun-official-site",
"type": "module",
"version": "1.0.0",
"private": true,
"engines": {
"node": ">=20.20.0 <21"
},
"scripts": {
"dev": "node --env-file-if-exists=.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-if-exists=.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"
}
}
const $ = (selector) => document.querySelector(selector);
const API_BASE = "/api/cms";
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");
async function boot() {
const session = await api("/session").catch(() => ({ authed: false }));
if (session.authed) {
hide("#login"); show("#app");
await loadCategories();
loadArticles();
}
else { show("#login"); hide("#app"); }
}
$("#login-form").addEventListener("submit", async (event) => {
event.preventDefault();
$("#login-error").textContent = "";
try {
await api("/session", { method: "POST", body: { password: $("#password").value } });
$("#password").value = "";
hide("#login"); show("#app");
await loadCategories();
loadArticles();
} catch (error) { $("#login-error").textContent = error.message; }
});
$("#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;
$("#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: #ff7d41;
--primary-dark: #e96a32;
--deep: #c95320;
--light: #fff1e9;
--bg: #f6f8f7;
--surface: #fff;
--text: #231916;
--muted: #6b7280;
--border: #e8e5e3;
--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(36,79,72,.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; }
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(255,125,65,.16); 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: #101413; }
.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(255,125,65,.16); }
.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(36,79,72,.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: #f0f7f4; }
.select-option[aria-selected="true"] { color: var(--primary-dark); background: #e7f5f1; 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: #f7faf9; 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: #edf5f2; border: 1px solid var(--border); border-radius: 11px; font-size: 13px; }
.auto-meta { margin: 0; padding: 11px 13px; color: var(--muted); background: #f7faf9; 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(36,79,72,.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(36,79,72,.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: #f1f6f4; }
.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: #f8faf9; 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: #edf7f4; }
.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">
<defs><linearGradient id="bg" x1="0" x2="1"><stop stop-color="#fff"/><stop offset="1" stop-color="#fff1e9"/></linearGradient></defs>
<rect width="1200" height="630" fill="url(#bg)"/>
<circle cx="1000" cy="90" r="210" fill="none" stroke="#ff7d41" stroke-opacity=".14" stroke-width="55"/>
<path d="M735 445c100-170 250-205 405-110" fill="none" stroke="#ff7d41" stroke-opacity=".35" stroke-width="4" stroke-dasharray="12 14"/>
<circle cx="755" cy="420" r="13" fill="#ff7d41" stroke="#fff" stroke-width="6"/><circle cx="1094" cy="319" r="13" fill="#00b700" stroke="#fff" stroke-width="6"/>
<text x="76" y="114" fill="#ff7d41" font-family="Arial, PingFang SC, Microsoft YaHei, sans-serif" font-size="22" font-weight="700" letter-spacing="5">JIAYUN MOBILITY</text>
<text x="76" y="245" fill="#231916" font-family="PingFang SC, Microsoft YaHei, sans-serif" font-size="70" font-weight="800">嘉运网约车</text>
<text x="76" y="335" fill="#231916" font-family="PingFang SC, Microsoft YaHei, sans-serif" font-size="48" font-weight="700">天津跑网约车,租车买车就找我们</text>
<text x="80" y="410" fill="#6b625f" font-family="PingFang SC, Microsoft YaHei, sans-serif" font-size="25">租售 · 办证 · 入驻 · 培训 · 维保,一站到位</text>
<rect x="78" y="486" width="210" height="64" rx="32" fill="#ff7d41"/><text x="183" y="527" text-anchor="middle" fill="#fff" font-family="PingFang SC, Microsoft YaHei, sans-serif" font-size="22" font-weight="700">值得托付</text>
</svg>
# 嘉运网约车 — 允许搜索引擎和 AI 搜索抓取公开内容
User-agent: *
Allow: /
Disallow: /admin/
Disallow: /api/
User-agent: GPTBot
Allow: /
Disallow: /admin/
Disallow: /api/
User-agent: Google-Extended
Allow: /
Disallow: /admin/
Disallow: /api/
Sitemap: https://www.jiayunche.com/sitemap-index.xml
{
"name": "嘉运网约车",
"short_name": "嘉运汽车",
"description": "天津一站式网约车综合服务商",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ff7d41",
"lang": "zh-CN"
}
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.JIAYUN_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 [ "${JIAYUN_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, JIAYUN_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", { JIAYUN_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 type { Article } from "../lib/articles";
import Icon from "./Icon.astro";
interface Props { items: Article[]; }
const { items } = Astro.props;
---
<div class="article-grid">
{items.map((article, index) => <article class:list={["article-card", index === 0 && "article-card--featured"]}>
<a href={`/articles/${article.data.slug}/`} class="article-card__intro" aria-label={article.data.title}><span>{article.data.category}</span><strong>嘉运跑车指南</strong><small>GUIDE {String(index + 1).padStart(2, "0")}</small></a>
<div class="article-card__body"><div class="article-meta"><time datetime={article.data.date.toISOString()}>{article.data.date.toISOString().slice(0, 10)}</time><span>{article.data.category}</span></div><h2><a href={`/articles/${article.data.slug}/`}>{article.data.title}</a></h2><p>{article.data.excerpt}</p><a class="text-link" href={`/articles/${article.data.slug}/`}>阅读全文 <Icon name="arrow" size={17} /></a></div>
</article>)}
</div>
---
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 { getCategories, paginationWindow, type Article } from "../lib/articles";
interface Props { items: Article[]; allItems: Article[]; categoryNames: string[]; currentPage: number; lastPage: number; activeName?: string; activeSlug?: string; }
const { items, allItems, categoryNames, currentPage, lastPage, activeName, activeSlug } = Astro.props;
const categories = getCategories(allItems, categoryNames); const heading = activeName || "跑车指南"; const title = `${heading}${currentPage > 1 ? ` 第${currentPage}页` : ""}|${site.brand.name}`; const description = activeName ? `${activeName}相关的网约车入行、车辆、证照与运营实用信息。` : "嘉运网约车整理的司机入行、车辆租售、证照办理、平台注册与运营指南。";
const pageHref = (page: number) => activeSlug ? (page === 1 ? `/articles/topic/${activeSlug}/` : `/articles/topic/${activeSlug}/page/${page}/`) : (page === 1 ? "/articles/" : `/articles/page/${page}/`);
---
<Base title={title} description={description}><Header /><main id="main-content"><section class="page-hero journal-hero"><div class="container"><div class="breadcrumb"><a href="/">首页</a><span>/</span><a href="/articles/">跑车指南</a>{activeName && <><span>/</span>{activeName}</>}</div><p class="eyebrow"><span></span>JIAYUN JOURNAL</p><h1>{heading}</h1><p>把网约车入行与运营中的复杂信息,说得更清楚一点。</p></div></section><section class="section journal-section"><div class="container"><nav class="category-nav" aria-label="文章分类"><a href="/articles/" aria-current={!activeName ? "page" : undefined}>全部 <span>{allItems.length}</span></a>{categories.map((category) => <a href={`/articles/topic/${category.slug}/`} aria-current={activeName === category.name ? "page" : undefined}>{category.name} <span>{category.count}</span></a>)}</nav>{items.length ? <ArticleListing items={items} /> : <div class="empty-state"><h2>该分类还没有文章</h2><p>后续内容会通过文章后台持续更新。</p></div>}{lastPage > 1 && <nav class="pagination" aria-label="分页">{paginationWindow(currentPage, lastPage).map((page) => page === 0 ? <span>…</span> : <a href={pageHref(page)} aria-current={page === currentPage ? "page" : undefined}>{page}</a>)}</nav>}</div></section></main><Footer /></Base>
---
import { site } from "../data/site";
import Icon from "./Icon.astro";
interface Props { eyebrow?: string; title?: string; text?: string; }
const { eyebrow = "READY TO START", title = "准备好开始你的网约车计划了吗?", text = "到店把预算、时间安排和跑车目标说清楚,我们帮你匹配合适的车型、平台与入行路径。" } = Astro.props;
---
<section class="cta-section">
<div class="container cta-panel" data-reveal>
<div><p class="eyebrow eyebrow--light"><span></span>{eyebrow}</p><h2>{title}</h2><p>{text}</p></div>
<div class="cta-actions"><a class="button" href={`tel:${site.contact.tel}`}><Icon name="phone" size={19} /> 电话咨询</a><a class="button button--white" href={site.contact.mapUrl} target="_blank" rel="noopener">导航到店 <Icon name="arrow" size={18} /></a></div>
</div>
</section>
---
import logo from "../../黑底白字绿LOGO.svg";
import { site } from "../data/site";
import Icon from "./Icon.astro";
---
<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="431" height="105" alt="嘉运网约车" /></a>
<p>{site.brand.tagline}</p>
<div class="footer-tags"><span>合规租售</span><span>透明收费</span><span>一站服务</span></div>
</div>
<div><h2>快速导航</h2><a href="/services/">服务方案</a><a href="/join/">司机招募</a><a href="/articles/">跑车指南</a><a href="/about/">关于嘉运</a></div>
<div><h2>核心服务</h2><a href="/services/#vehicle-rental">车辆租赁</a><a href="/services/#license-service">证照代办</a><a href="/services/#platform-access">平台入驻</a><a href="/services/#driver-training">司机培训</a></div>
<div class="footer-contact"><h2>联系嘉运</h2><a href={`tel:${site.contact.tel}`}><Icon name="phone" size={18} />{site.contact.phone}</a><a href={site.contact.mapUrl} target="_blank" rel="noopener"><Icon name="pin" size={18} />{site.contact.address}</a><p><Icon name="clock" size={18} />{site.contact.serviceHours}</p></div>
</div>
<div class="container footer-bottom"><p>© {new Date().getFullYear()} {site.brand.legalName}</p><p>平台规则、车型价格及优惠政策以门店与相关平台实时信息为准。</p></div>
</footer>
---
import logo from "../../白底黑字绿LOGO.svg";
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 articleCategories = (await getCategoryNames()).map((label) => ({
href: `/articles/topic/${categoryToSlug(label)}/`,
label,
}));
const nav = site.nav.map((item) => item.href === "/articles/"
? { ...item, children: [{ href: "/articles/", label: "全部跑车指南" }, ...articleCategories] }
: item);
---
<div class="utility-bar">
<div class="container utility-bar__inner">
<span>曹操出行天津直营中心 · 滴滴出行合规租售服务商</span>
<a href={`tel:${site.contact.tel}`}>服务热线:{site.contact.phone}</a>
</div>
</div>
<header class="site-header" data-header>
<div class="container site-header__inner">
<a class="brand" href="/" aria-label="嘉运网约车首页"><img src={logo.src} width="431" height="105" alt="嘉运网约车" /></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} aria-haspopup="true">
{item.label}
</a>
<div class:list={["nav-dropdown", item.children.length > 5 && "nav-dropdown--wide"]}>
<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:${site.contact.tel}`}>电话咨询</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:${site.contact.tel}`}>拨打 {site.contact.phone}</a>
</nav>
</details>
</div>
</header>
---
interface Props { name: string; size?: number; }
const { name, size = 24 } = Astro.props;
const paths: Record<string, string> = {
car: "M3 13l2-5a3 3 0 012.8-2h8.4A3 3 0 0119 8l2 5v6h-2v-2H5v2H3v-6zm3.3-3l-1.2 3h13.8l-1.2-3a1 1 0 00-.9-.6H7.2a1 1 0 00-.9.6zM7 15.5h2m6 0h2",
key: "M14 7a5 5 0 11-3.4 8.7L3 23v-4l3-3 2 2 2-2m4-6h.01",
license: "M6 3h12a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V5a2 2 0 012-2zm3 5h6M8 13h8M8 17h5",
route: "M5 19a3 3 0 100-6 3 3 0 000 6zm14-8a3 3 0 100-6 3 3 0 000 6zM8 16h3a2 2 0 002-2V8a2 2 0 012-2h1",
coach: "M12 12a4 4 0 100-8 4 4 0 000 8zM4 22a8 8 0 0116 0M18 9l2 2 4-4",
service: "M14.7 6.3a4 4 0 01-5 5L4 17l3 3 5.7-5.7a4 4 0 005-5l-2.4 2.4-3-3 2.4-2.4z",
check: "M5 12l4 4L19 6",
phone: "M5 4h4l2 5-3 2a16 16 0 007 7l2-3 5 2v4c0 1-1 2-2 2C10 23 1 14 1 4c0-1 1-2 2-2h2",
pin: "M20 10c0 6-8 12-8 12S4 16 4 10a8 8 0 1116 0zm-8 2a2 2 0 100-4 2 2 0 000 4z",
clock: "M12 22a10 10 0 110-20 10 10 0 010 20zm0-15v5l3 2",
shield: "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10zm-4-10l3 3 5-6",
arrow: "M5 12h14m-5-5l5 5-5 5",
};
---
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={paths[name] || paths.arrow}></path></svg>
---
interface Props { eyebrow?: string; title: string; intro?: string; align?: "left" | "center"; }
const { eyebrow, title, intro, align = "left" } = Astro.props;
---
<div class:list={["section-heading", `section-heading--${align}`]}>
{eyebrow && <p class="eyebrow"><span></span>{eyebrow}</p>}
<h2>{title}</h2>
{intro && <p class="section-intro">{intro}</p>}
</div>
---
title: "办理网约车证照前,先准备哪些信息"
slug: "license-preparation"
date: 2026-07-22
updated: 2026-07-22
category: "合规办证"
author: "嘉运网约车"
excerpt: "网约车驾驶员证与车辆营运证的办理对象和要求不同。提前核对人员、车辆与平台信息,可以减少往返。"
status: "published"
---
网约车驾驶员证与车辆营运证的办理对象和要求不同。开始办理前,建议先核对人员、车辆与平台三方面信息。
## 人员情况
准备个人身份、驾驶证等基础信息,并确认自己是否满足当地最新申请条件。
## 车辆情况
车辆类型、登记信息和营运要求需要与当地政策相符。若尚未选车,建议在购车或签订长期租赁合同前先核对。
## 平台计划
明确准备接入的平台,有助于同步确认车型准入和注册环节,避免证照办理与平台计划脱节。
政策和审核要求可能调整,具体材料、周期与结果应以主管部门最新规定为准。
---
title: "租车还是买车?先看你的跑车计划"
slug: "rent-or-buy-guide"
date: 2026-07-23
updated: 2026-07-23
category: "车辆选择"
author: "嘉运网约车"
excerpt: "租车和买车没有统一答案。预算、运营周期、每日在线时长和风险承受能力,都会影响选择。"
status: "published"
---
租车和买车没有统一答案。更合适的方式,取决于你的预算、预计运营周期和风险承受能力。
## 短期试跑更看重灵活
如果刚入行、尚未确定长期安排,可以优先了解短租或月租方案,先验证自己是否适合网约车运营。
## 长期运营要核算总成本
准备长期全职跑车时,可以对比长租、以租代购和直接购车。不要只比较月度金额,还要把保险、维保、车辆残值和合同责任纳入考虑。
## 车型需要匹配平台
不同平台与运营场景对车型的要求可能不同。选车前应同步确认平台准入规则,避免出现车辆与计划不匹配的情况。
所有价格、金融与租赁条件,应以门店当期政策和正式合同为准。
---
title: "天津跑网约车,新人入行先理清这四件事"
slug: "tianjin-driver-getting-started"
date: 2026-07-24
updated: 2026-07-24
category: "入行指南"
author: "嘉运网约车"
excerpt: "准备跑网约车时,不要急着先选车。先把时间安排、合规证照、平台准入和车辆成本四件事梳理清楚。"
status: "published"
---
准备跑网约车时,不要急着先选车。先把下面四件事梳理清楚,后面的决定会更稳妥。
## 1. 你准备全职还是兼职
每天可投入的时间,会影响平台选择、租期和车辆成本。兼职更看重灵活,全职则需要更完整地核算长期成本与车辆稳定性。
## 2. 当前证照是否齐全
网约车运营涉及驾驶员证和车辆营运证。办理条件、材料和周期应以主管部门最新要求为准,可以先让专业人员帮助核对现有情况。
## 3. 平台准入规则
不同平台的准入、车型和运营规则会调整。注册前应核对当前政策,并结合自己的跑车时间和区域选择平台。
## 4. 把总成本算清楚
除了租金或车款,还应逐项了解押金、保险、维保和合同责任。不要只看单一月供或短期优惠。
嘉运可以把车型、租售、办证、平台与培训放在一起梳理,但最终方案仍应基于你的预算和实际安排。
[
"入行指南",
"车辆选择",
"合规办证",
"运营技巧"
]
---
title: "办理网约车证照前,先准备哪些信息"
slug: "license-preparation"
date: 2026-07-22
updated: 2026-07-22
category: "合规办证"
author: "嘉运网约车"
excerpt: "网约车驾驶员证与车辆营运证的办理对象和要求不同。提前核对人员、车辆与平台信息,可以减少往返。"
status: "published"
---
网约车驾驶员证与车辆营运证的办理对象和要求不同。开始办理前,建议先核对人员、车辆与平台三方面信息。
## 人员情况
准备个人身份、驾驶证等基础信息,并确认自己是否满足当地最新申请条件。
## 车辆情况
车辆类型、登记信息和营运要求需要与当地政策相符。若尚未选车,建议在购车或签订长期租赁合同前先核对。
## 平台计划
明确准备接入的平台,有助于同步确认车型准入和注册环节,避免证照办理与平台计划脱节。
政策和审核要求可能调整,具体材料、周期与结果应以主管部门最新规定为准。
---
title: "租车还是买车?先看你的跑车计划"
slug: "rent-or-buy-guide"
date: 2026-07-23
updated: 2026-07-23
category: "车辆选择"
author: "嘉运网约车"
excerpt: "租车和买车没有统一答案。预算、运营周期、每日在线时长和风险承受能力,都会影响选择。"
status: "published"
---
租车和买车没有统一答案。更合适的方式,取决于你的预算、预计运营周期和风险承受能力。
## 短期试跑更看重灵活
如果刚入行、尚未确定长期安排,可以优先了解短租或月租方案,先验证自己是否适合网约车运营。
## 长期运营要核算总成本
准备长期全职跑车时,可以对比长租、以租代购和直接购车。不要只比较月度金额,还要把保险、维保、车辆残值和合同责任纳入考虑。
## 车型需要匹配平台
不同平台与运营场景对车型的要求可能不同。选车前应同步确认平台准入规则,避免出现车辆与计划不匹配的情况。
所有价格、金融与租赁条件,应以门店当期政策和正式合同为准。
---
title: "天津跑网约车,新人入行先理清这四件事"
slug: "tianjin-driver-getting-started"
date: 2026-07-24
updated: 2026-07-24
category: "入行指南"
author: "嘉运网约车"
excerpt: "准备跑网约车时,不要急着先选车。先把时间安排、合规证照、平台准入和车辆成本四件事梳理清楚。"
status: "published"
---
准备跑网约车时,不要急着先选车。先把下面四件事梳理清楚,后面的决定会更稳妥。
## 1. 你准备全职还是兼职
每天可投入的时间,会影响平台选择、租期和车辆成本。兼职更看重灵活,全职则需要更完整地核算长期成本与车辆稳定性。
## 2. 当前证照是否齐全
网约车运营涉及驾驶员证和车辆营运证。办理条件、材料和周期应以主管部门最新要求为准,可以先让专业人员帮助核对现有情况。
## 3. 平台准入规则
不同平台的准入、车型和运营规则会调整。注册前应核对当前政策,并结合自己的跑车时间和区域选择平台。
## 4. 把总成本算清楚
除了租金或车款,还应逐项了解押金、保险、维保和合同责任。不要只看单一月供或短期优惠。
嘉运可以把车型、租售、办证、平台与培训放在一起梳理,但最终方案仍应基于你的预算和实际安排。
export const site = {
brand: {
name: "嘉运网约车",
shortName: "嘉运汽车",
englishName: "JIAYUN MOBILITY",
legalName: "嘉运晟通(天津)汽车销售服务有限公司",
tagline: "值得托付的一站式网约车综合服务商",
industry: "汽车 · 网约车租售 · 司机服务",
},
home: {
eyebrow: "天津本地一站式网约车服务",
headline: "天津跑网约车,租车买车就找我们",
subhead: "嘉运网约车拥有曹操出行天津直营中心与滴滴出行合规租售服务商双重官方资质。买车、租车、办证、入驻、培训、售后,一站到位。",
stats: [
{ value: "2项", label: "平台官方合作资质" },
{ value: "数千+", label: "天津司机服务经验" },
{ value: "7×16h", label: "日常客服在线" },
{ value: "一站式", label: "租售办证运营闭环" },
],
},
services: [
{ slug: "vehicle-sales", index: "01", icon: "car", name: "新能源车辆销售", intro: "结合网约车运营场景与预算,提供新能源营运车型选购建议和整车销售服务。", audience: "准备购车入行的司机与小型车队", value: "车型、预算与跑车平台一起规划", note: "具体车型、价格与金融方案以门店当期政策为准。" },
{ slug: "vehicle-rental", index: "02", icon: "key", name: "灵活车辆租赁", intro: "覆盖短租、月租、长租与以租代购,按跑车周期和资金计划匹配用车方案。", audience: "希望轻投入起步或灵活换车的司机", value: "多周期选择,降低入行决策压力", note: "租赁条件、押金与优惠以合同和门店当期政策为准。" },
{ slug: "license-service", index: "03", icon: "license", name: "合规证照代办", intro: "专业团队协助准备材料,办理网约车驾驶员证与车辆营运证,跟进关键节点。", audience: "首次入行或需要补齐合规手续的司机", value: "流程有人带,材料少走弯路", note: "办理周期与结果以主管部门审核为准。" },
{ slug: "platform-access", index: "04", icon: "route", name: "平台入驻对接", intro: "对接曹操出行、滴滴、高德等主流平台,协助注册并处理后续账号问题。", audience: "全职或兼职网约车司机", value: "结合实际跑车情况匹配平台与车型", note: "平台准入、派单与运营规则以各平台实时政策为准。" },
{ slug: "driver-training", index: "05", icon: "coach", name: "司机培训与运营", intro: "提供免费岗前培训,并由专属运营顾问一对一跟进跑车过程中的实际问题。", audience: "零经验新人及希望提升运营效率的司机", value: "从上岗准备到日常运营持续陪伴", note: "实际营收受在线时长、平台规则、市场供需等因素影响。" },
{ slug: "after-sales", index: "06", icon: "service", name: "售后维保与救援", intro: "承接日常维保咨询、车辆问题协调与道路救援,让司机跑车更安心。", audience: "嘉运服务车辆与合作司机", value: "线下门店与客服共同响应", note: "服务范围与费用以车辆方案及现场确认结果为准。" },
],
process: [
{ step: "01", title: "到店沟通", desc: "说清预算、跑车时间与经验情况。" },
{ step: "02", title: "方案匹配", desc: "同步规划车型、租售方式与适合的平台。" },
{ step: "03", title: "办证入驻", desc: "协助准备材料、办理证照与平台注册。" },
{ step: "04", title: "培训上岗", desc: "完成岗前培训,由运营顾问持续跟进。" },
],
about: {
heading: "扎根天津,把网约车入行的复杂事一次办清",
story: [
"嘉运网约车是天津本土一站式网约车综合服务商,获曹操出行天津直营中心及滴滴出行合规租售服务商双重官方认证。",
"我们提供新能源网约车销售、车辆租赁、证照代办、司机培训、平台入驻及售后维保全链条服务。坚持合规经营、透明收费,已服务数千名天津司机。",
],
advantages: [
{ title: "双平台官方资质", desc: "曹操出行天津直营中心与滴滴出行合规租售服务商,合作信息更直接。" },
{ title: "方案因人而定", desc: "根据预算、全职或兼职安排、跑车经验,组合适合的平台、车型与用车方式。" },
{ title: "线下实体门店", desc: "咨询、看车、签约、培训与售后均可面对面沟通,合同与费用当面确认。" },
{ title: "全程运营陪伴", desc: "从证照、注册到岗前培训和账号问题管理,专属顾问持续跟进。" },
],
},
faq: [
{ q: "没有网约车经验,怎么在嘉运入行?", a: "专业代办团队会协助办理人证车证,并根据你的预算、全职或兼职安排匹配平台、车型与用车方案;完成平台注册后,还会提供免费岗前培训和专属运营顾问跟进。" },
{ q: "租车方案有哪些?资金不够怎么办?", a: "可选择短租、月租、长租或低首付以租代购等方案。新人扶持和租金优惠会随当期政策变化,建议到店结合实际预算核算并以合同为准。" },
{ q: "嘉运的服务适合什么样的人?", a: "适合天津本地准备入行或已经从事网约车运营的全职、兼职司机,以及需要采购或租赁新能源营运车辆的个人和小型车队。" },
{ q: "可以协助办理哪些证件?", a: "可协助办理网约车驾驶员证和车辆营运证,并提示材料与流程节点。具体办理条件、周期和审核结果以主管部门要求为准。" },
{ q: "会协助注册哪些平台?", a: "可对接曹操出行、滴滴、高德等主流平台,并协助处理平台注册和后续账号问题。最终准入和运营规则以各平台实时政策为准。" },
{ q: "签约前如何确认费用?", a: "嘉运坚持明码标价。建议到店逐项确认租金或车款、押金、保险、维保、违约责任等内容,所有权利义务以双方签署的正式合同为准。" },
],
contact: {
phone: "022-87940507",
tel: "+862287940507",
address: "天津市南开区咸阳路45号小园新厦",
locality: "南开区",
region: "天津市",
serviceHours: "每日 7×16 小时客服在线",
heading: "到店聊聊你的跑车计划",
intro: "带上你的预算、时间安排和驾驶经验,我们帮你把车型、平台、证照与运营方案一次梳理清楚。",
mapUrl: "https://uri.amap.com/search?keyword=%E5%8D%97%E5%BC%80%E5%8C%BA%E5%92%B8%E9%98%B3%E8%B7%AF45%E5%8F%B7%E5%B0%8F%E5%9B%AD%E6%96%B0%E5%8E%A6&city=%E5%A4%A9%E6%B4%A5",
},
nav: [
{
href: "/",
label: "首页",
children: [
{ href: "/", label: "品牌首页" },
{ href: "/#services", label: "六项核心服务" },
{ href: "/#advantages", label: "官方资质与优势" },
{ href: "/#process", label: "四步服务流程" },
{ href: "/#faq", label: "首页常见问答" },
],
},
{
href: "/services/",
label: "服务方案",
children: [
{ href: "/services/#vehicle-sales", label: "新能源车辆销售" },
{ href: "/services/#vehicle-rental", label: "灵活车辆租赁" },
{ href: "/services/#license-service", label: "合规证照代办" },
{ href: "/services/#platform-access", label: "平台入驻对接" },
{ href: "/services/#driver-training", label: "司机培训与运营" },
{ href: "/services/#after-sales", label: "售后维保与救援" },
],
},
{
href: "/join/",
label: "司机招募",
children: [
{ href: "/join/", label: "招募与入行方案" },
{ href: "/join/#fit", label: "哪些人适合" },
{ href: "/join/#support", label: "新人全程支持" },
{ href: "/join/#income", label: "成本与收益提示" },
{ href: "/contact/?intent=join", label: "咨询司机招募" },
],
},
{
href: "/articles/",
label: "跑车指南",
children: [{ href: "/articles/", label: "全部跑车指南" }],
},
{
href: "/about/",
label: "关于嘉运",
children: [
{ href: "/about/", label: "公司与品牌介绍" },
{ href: "/about/#story", label: "嘉运品牌故事" },
{ href: "/about/#standards", label: "四项服务准则" },
{ href: "/about/#certification", label: "双平台官方资质" },
],
},
{
href: "/faq/",
label: "常见问题",
children: [
{ href: "/faq/", label: "全部常见问题" },
{ href: "/faq/#faq-1", label: "新人如何入行" },
{ href: "/faq/#faq-2", label: "租车与资金方案" },
{ href: "/faq/#faq-4", label: "合规证照办理" },
{ href: "/faq/#faq-6", label: "签约费用确认" },
],
},
{
href: "/contact/",
label: "联系我们",
children: [
{ href: "/contact/", label: "联系方式总览" },
{ href: "/contact/#contact-info", label: "电话与门店地址" },
{ href: "/contact/#visit", label: "到店前准备" },
{ href: "tel:+862287940507", label: "拨打 022-87940507" },
],
},
],
} as const;
---
import "../styles/global.css";
import logo from "../../白底黑字绿LOGO.svg";
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 = "/og-cover.svg", ogType = "website", noindex = false, jsonLd = [] } = Astro.props;
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": ["AutoDealer", "LocalBusiness", "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(logo.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",
},
areaServed: { "@type": "City", name: "天津" },
};
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="#ff7d41" />
<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={logo.src} type="image/svg+xml" />
<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 > 12);
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, rootMargin: '0px 0px -36px' })
: 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;
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> = {
入行指南: "getting-started",
车辆选择: "vehicle-guide",
合规办证: "compliance",
运营技巧: "driver-operations",
};
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 (!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)] || "driver-guide";
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 > 8 * 1024 * 1024) throw new StoreError("图片不能超过 8MB", 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> = {
入行指南: "getting-started",
车辆选择: "vehicle-guide",
合规办证: "compliance",
运营技巧: "driver-operations",
};
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 || "";
const SECRET = process.env.CMS_SECRET || crypto.randomBytes(32).toString("hex");
const API_KEY = process.env.CMS_API_KEY || "";
const loginAttempts = new Map<string, { count: number; until: 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("jiayun-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, value: string, maxAge: number): string {
const forwarded = request.headers.get("x-forwarded-proto");
const secure = new URL(request.url).protocol === "https:" || forwarded === "https";
return `cms_session=${value}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}${secure ? "; Secure" : ""}`;
}
function loginAllowed(ip: string): boolean {
const state = loginAttempts.get(ip);
return !state || state.until < Date.now();
}
function loginFailed(ip: string): void {
const state = loginAttempts.get(ip) || { count: 0, until: 0 };
state.count += 1;
if (state.count >= 5) state.until = Date.now() + 10 * 60 * 1000;
loginAttempts.set(ip, state);
}
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 === "session") {
if (method === "GET") return json({ authed: authed(request) });
if (method === "POST") {
if (!PASSWORD) throw new StoreError("文章后台尚未配置管理密码", 503);
if (!loginAllowed(clientAddress)) throw new StoreError("登录尝试过于频繁,请稍后再试", 429);
const body = await bodyOf(request);
if (!safeEqual(body.password || "", PASSWORD)) {
loginFailed(clientAddress);
throw new StoreError("管理密码错误", 401);
}
loginAttempts.delete(clientAddress);
return json({ ok: true }, 200, { "Set-Cookie": cookieHeader(request, sessionToken(), 86400) });
}
if (method === "DELETE") return json({ ok: true }, 200, { "Set-Cookie": cookieHeader(request, "", 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" class="not-found"><div><span>404</span><h1>这条路线暂时走不通</h1><p>返回首页,继续了解嘉运网约车服务。</p><a class="button" href="/">返回首页</a></div></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 SectionHeading from "../../components/SectionHeading.astro"; import Icon from "../../components/Icon.astro"; import { site } from "../../data/site";
const title = `关于嘉运|${site.brand.name}`; const description = `了解${site.brand.legalName}的品牌定位、官方合作资质与一站式司机服务体系。`;
---
<Base title={title} description={description}><Header /><main id="main-content"><section class="page-hero page-hero--dark"><div class="container"><div class="breadcrumb"><a href="/">首页</a><span>/</span>关于嘉运</div><p class="eyebrow eyebrow--light"><span></span>ABOUT JIAYUN</p><h1>扎根天津,<br />让每一次选择都<em>更踏实</em></h1><p>{site.brand.tagline}</p></div></section>
<section class="section" id="story"><div class="container about-story"><div class="about-story__copy" data-reveal><SectionHeading eyebrow="WHO WE ARE" title={site.about.heading} />{site.about.story.map((paragraph) => <p>{paragraph}</p>)}<div class="about-signature"><strong>嘉运网约车</strong><span>{site.brand.legalName}</span></div></div><div class="about-story__facts" data-reveal><p>JIAYUN · TIANJIN</p><h3>扎根天津的网约车综合服务商</h3><dl><div><dt>服务经验</dt><dd>已服务数千名司机</dd></div><div><dt>线下门店</dt><dd>{site.contact.address}</dd></div><div><dt>服务内容</dt><dd>车辆租售、证照办理、平台入驻与司机运营支持</dd></div><div><dt>服务原则</dt><dd>合规、透明、有人负责</dd></div></dl></div></div></section>
<section class="section values-section" id="standards"><div class="container"><SectionHeading eyebrow="OUR STANDARDS" title="把承诺落实在每一个服务细节" intro="合规、透明、有人负责,是嘉运面对每位司机的基本准则。" align="center" /><div class="values-grid">{site.about.advantages.map((item, index) => <div data-reveal><span>0{index + 1}</span><Icon name={["shield","route","pin","coach"][index]} size={31} /><h3>{item.title}</h3><p>{item.desc}</p></div>)}</div></div></section>
<section class="section certification-section" id="certification"><div class="container certification-layout"><div><p class="eyebrow"><span></span>OFFICIAL PARTNER</p><h2>双平台官方合作资质</h2><p>嘉运获曹操出行天津直营中心及滴滴出行合规租售服务商双重官方认证,平台政策与服务信息能够更高效地触达司机。</p></div><div class="certification-cards"><div><small>CAOCAO MOBILITY</small><strong>曹操出行</strong><span>天津直营中心</span></div><div><small>DIDI CHUXING</small><strong>滴滴出行</strong><span>合规租售服务商</span></div></div></div></section><CTA title="面对面,把跑车方案聊明白" /></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>
<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 CTA from "../../components/CTA.astro"; import { getPublishedArticle, getPublishedArticles } from "../../lib/article-store"; import { categoryToSlug } from "../../lib/articles"; import { site } from "../../data/site";
export async function getStaticPaths() { const articles = await getPublishedArticles(); return articles.map((article) => ({ params: { slug: article.data.slug } })); }
const article = await getPublishedArticle(Astro.params.slug || ""); if (!article) return Astro.redirect("/404/");
const title = `${article.data.title}|跑车指南|${site.brand.name}`; const articleUrl = new URL(Astro.url.pathname, Astro.site).href;
const jsonLd = { "@context": "https://schema.org", "@type": "Article", headline: article.data.title, description: article.data.excerpt, datePublished: article.data.date.toISOString(), dateModified: article.data.updated.toISOString(), author: { "@type": "Organization", name: article.data.author }, publisher: { "@id": new URL("/#organization", Astro.site).href }, mainEntityOfPage: articleUrl };
---
<Base title={title} description={article.data.excerpt} ogType="article" jsonLd={jsonLd}><Header /><main id="main-content"><article class="article-page"><header><div class="container article-header"><div class="breadcrumb"><a href="/">首页</a><span>/</span><a href="/articles/">跑车指南</a><span>/</span><a href={`/articles/topic/${categoryToSlug(article.data.category)}/`}>{article.data.category}</a></div><span class="article-category">{article.data.category}</span><h1>{article.data.title}</h1><div class="article-byline"><span>{article.data.author}</span><time datetime={article.data.date.toISOString()}>发布于 {article.data.date.toISOString().slice(0, 10)}</time>{article.data.updated.valueOf() !== article.data.date.valueOf() && <time datetime={article.data.updated.toISOString()}>更新于 {article.data.updated.toISOString().slice(0, 10)}</time>}</div></div></header><div class="container article-layout"><div class="article-content" set:html={article.html}></div><aside><strong>温馨提示</strong><p>平台规则、办证要求与车型政策可能调整,请以主管部门、平台与门店实时信息为准。</p><a href="/articles/">← 返回跑车指南</a></aside></div></article><CTA title="文章没解决你的具体问题" text="每位司机的预算时间与当前证照情况都不同欢迎到店面对面梳理" /></main><Footer /></Base>
---
import ArticlesView from "../../components/ArticlesView.astro"; import { getCategoryNames, getPublishedArticles, lastPageOf, pageSlice } from "../../lib/articles";
const allItems = await getPublishedArticles(); const categoryNames = await getCategoryNames();
---
<ArticlesView items={pageSlice(allItems, 1)} allItems={allItems} categoryNames={categoryNames} currentPage={1} lastPage={lastPageOf(allItems.length)} />
---
import ArticlesView from "../../../components/ArticlesView.astro"; import { getCategoryNames, getPublishedArticles, lastPageOf, pageSlice } from "../../../lib/articles";
export async function getStaticPaths() { const all = await getPublishedArticles(); return Array.from({ length: lastPageOf(all.length) }, (_, i) => i + 1).filter((p) => p > 1).map((page) => ({ params: { page: String(page) } })); }
const allItems = await getPublishedArticles(); const categoryNames = await getCategoryNames(); const currentPage = Number(Astro.params.page || 1); const lastPage = lastPageOf(allItems.length); if (currentPage < 2 || currentPage > lastPage) return Astro.redirect("/articles/");
---
<ArticlesView items={pageSlice(allItems, currentPage)} allItems={allItems} categoryNames={categoryNames} currentPage={currentPage} lastPage={lastPage} />
---
import ArticlesView from "../../../../components/ArticlesView.astro"; import { categoryToSlug, getCategoryNames, getPublishedArticles, lastPageOf, pageSlice } from "../../../../lib/articles";
export async function getStaticPaths() { const categories = await getCategoryNames(); return categories.map((name) => ({ params: { slug: categoryToSlug(name) }, props: { activeName: name } })); }
const { activeName } = Astro.props; const activeSlug = Astro.params.slug!; const allItems = await getPublishedArticles(); const categoryNames = await getCategoryNames(); const filtered = allItems.filter((item) => item.data.category === activeName);
---
<ArticlesView items={pageSlice(filtered, 1)} allItems={allItems} categoryNames={categoryNames} currentPage={1} lastPage={lastPageOf(filtered.length)} activeName={activeName} activeSlug={activeSlug} />
---
import ArticlesView from "../../../../../components/ArticlesView.astro"; import { categoryToSlug, getCategoryNames, getPublishedArticles, lastPageOf, pageSlice } from "../../../../../lib/articles";
export async function getStaticPaths() { const all = await getPublishedArticles(); const categories = await getCategoryNames(); return categories.flatMap((name) => { const filtered = all.filter((item) => item.data.category === name); return Array.from({ length: lastPageOf(filtered.length) }, (_, i) => i + 1).filter((p) => p > 1).map((page) => ({ params: { slug: categoryToSlug(name), page: String(page) }, props: { activeName: name } })); }); }
const { activeName } = Astro.props; const activeSlug = Astro.params.slug!; const allItems = await getPublishedArticles(); const categoryNames = await getCategoryNames(); const filtered = allItems.filter((item) => item.data.category === activeName); const currentPage = Number(Astro.params.page || 1); const lastPage = lastPageOf(filtered.length);
---
<ArticlesView items={pageSlice(filtered, currentPage)} allItems={allItems} categoryNames={categoryNames} currentPage={currentPage} lastPage={lastPage} activeName={activeName} activeSlug={activeSlug} />
---
import Base from "../../layouts/Base.astro"; import Header from "../../components/Header.astro"; import Footer from "../../components/Footer.astro"; import Icon from "../../components/Icon.astro"; import { site } from "../../data/site";
const title = `联系我们|${site.brand.name}`; const description = `联系嘉运网约车:${site.contact.phone},地址:${site.contact.address}。`;
const intent = Astro.url.searchParams.get("intent"); const lead = intent === "join" ? "想了解司机招募与入行方案?" : intent === "rent" ? "想了解车辆租赁方案?" : site.contact.heading;
---
<Base title={title} description={description}><Header /><main id="main-content"><section class="contact-hero"><div class="container contact-hero__inner"><div><div class="breadcrumb"><a href="/">首页</a><span>/</span>联系我们</div><p class="eyebrow"><span></span>CONTACT US</p><h1>{lead}</h1><p>{site.contact.intro}</p></div><div class="contact-number"><small>SERVICE HOTLINE</small><a href={`tel:${site.contact.tel}`}>{site.contact.phone}</a><span>{site.contact.serviceHours}</span></div></div></section><section class="section contact-section" id="contact-info"><div class="container contact-grid"><div class="contact-cards"><a href={`tel:${site.contact.tel}`}><Icon name="phone" size={27} /><span><small>联系电话</small><strong>{site.contact.phone}</strong></span><Icon name="arrow" size={19} /></a><a href={site.contact.mapUrl} target="_blank" rel="noopener"><Icon name="pin" size={27} /><span><small>线下门店</small><strong>{site.contact.address}</strong></span><Icon name="arrow" size={19} /></a><div><Icon name="clock" size={27} /><span><small>服务时间</small><strong>{site.contact.serviceHours}</strong></span></div></div><div class="visit-panel"><p>JIAYUN · TIANJIN</p><h2>欢迎到店沟通</h2><dl><div><dt>门店地址</dt><dd>{site.contact.address}</dd></div><div><dt>营业时间</dt><dd>{site.contact.serviceHours}</dd></div><div><dt>到店建议</dt><dd>可提前电话联系,便于顾问根据你的需求准备对应方案。</dd></div></dl><a class="button button--secondary" href={site.contact.mapUrl} target="_blank" rel="noopener">打开地图导航 <Icon name="arrow" size={17} /></a></div></div></section><section class="section visit-tips" id="visit"><div class="container"><h2>到店前,建议准备这些信息</h2><div><span>01</span><p><b>计划投入</b>大致预算与希望的租售方式</p></div><div><span>02</span><p><b>时间安排</b>全职或兼职、每日可跑时长</p></div><div><span>03</span><p><b>当前情况</b>驾驶经验、已有证件与平台注册状态</p></div></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 faqLd = { "@context": "https://schema.org", "@type": "FAQPage", mainEntity: site.faq.map((item) => ({ "@type": "Question", name: item.q, acceptedAnswer: { "@type": "Answer", text: item.a } })) };
---
<Base title={title} description={description} jsonLd={faqLd}><Header /><main id="main-content"><section class="page-hero"><div class="container"><div class="breadcrumb"><a href="/">首页</a><span>/</span>常见问题</div><p class="eyebrow"><span></span>FAQ</p><h1>重要的事,<br />签约之前<em>问清楚</em></h1><p>这里整理了司机最常问的问题。你的实际方案仍建议到店逐项确认。</p></div></section><section class="section"><div class="container faq-page-layout"><aside><span>QUICK CONTACT</span><h2>还有其他问题?</h2><p>客服每日 7×16 小时在线,也欢迎直接到店面对面沟通。</p><a class="button" href={`tel:${site.contact.tel}`}>{site.contact.phone}</a></aside><div class="faq-page-list">{site.faq.map((item, index) => <details id={`faq-${index + 1}`} open={index === 0}><summary><span>{String(index + 1).padStart(2, "0")}</span><strong>{item.q}</strong><i></i></summary><p>{item.a}</p></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 CTA from "../components/CTA.astro";
import Icon from "../components/Icon.astro";
import { site } from "../data/site";
const title = `${site.brand.name}|天津网约车租售、办证与司机服务`;
const description = site.home.subhead;
const serviceLd = site.services.map((service) => ({ "@type": "Service", name: service.name, description: service.intro, provider: { "@id": new URL("/#organization", Astro.site).href }, areaServed: "天津市" }));
---
<Base title={title} description={description} jsonLd={{ "@context": "https://schema.org", "@type": "ItemList", itemListElement: serviceLd.map((item, index) => ({ "@type": "ListItem", position: index + 1, item })) }}>
<Header />
<main id="main-content">
<section class="hero">
<div class="container hero__inner">
<div class="hero-copy" data-reveal>
<p class="eyebrow"><span></span>{site.home.eyebrow}</p>
<h1>天津<em>跑网约车,租车买车</em>就找我们</h1>
<p class="hero-lead">{site.home.subhead}</p>
<div class="hero-actions"><a class="button" href={`tel:${site.contact.tel}`}><Icon name="phone" size={19} />立即咨询</a><a class="button button--secondary" href="/services/">查看服务方案<Icon name="arrow" size={18} /></a></div>
<div class="hero-trust"><span><Icon name="shield" size={18} />双平台官方资质</span><span><Icon name="check" size={18} />明码标价</span><span><Icon name="check" size={18} />线下实体门店</span></div>
</div>
</div>
</section>
<section class="stats-strip" aria-label="嘉运服务数据"><div class="container stats-grid">{site.home.stats.map((item) => <div><strong>{item.value}</strong><span>{item.label}</span></div>)}</div></section>
<section class="section services-preview" id="services">
<div class="container">
<SectionHeading eyebrow="ONE-STOP SERVICE" title="从选车到上岗,一站解决跑车准备" intro="把原本需要多处奔波的租售、办证、平台注册与培训,放进同一套服务流程。" />
<div class="service-grid">{site.services.map((service) => <a class="service-card" href={`/services/#${service.slug}`} data-reveal><div class="service-card__icon"><Icon name={service.icon} size={28} /></div><span class="service-card__index">{service.index}</span><h3>{service.name}</h3><p>{service.intro}</p><span class="text-link">了解详情 <Icon name="arrow" size={17} /></span></a>)}</div>
</div>
</section>
<section class="section trust-section" id="advantages">
<div class="container trust-layout">
<div class="trust-proof" data-reveal><p>OFFICIAL PARTNERSHIP</p><h3>双重官方认证</h3><span>正规合作 · 合规服务</span><dl><div><dt>曹操出行</dt><dd>天津直营中心</dd></div><div><dt>滴滴出行</dt><dd>合规租售服务商</dd></div></dl></div>
<div class="trust-copy" data-reveal><p class="eyebrow"><span></span>WHY JIAYUN</p><h2>官方资质是起点,<br />把服务做实才是答案</h2><p>网约车入行涉及车辆、证照、平台和日常运营。嘉运用线下实体门店承接每一个环节,合同与费用面对面确认,遇到问题能找到人。</p><div class="advantage-list">{site.about.advantages.map((item, index) => <div><span>{String(index + 1).padStart(2, "0")}</span><div><h3>{item.title}</h3><p>{item.desc}</p></div></div>)}</div><a class="button button--secondary" href="/about/">了解嘉运汽车 <Icon name="arrow" size={18} /></a></div>
</div>
</section>
<section class="section process-section" id="process"><div class="container"><SectionHeading eyebrow="HOW IT WORKS" title="四步开启跑车计划" intro="先了解真实情况,再匹配方案;不让你在不清楚规则时仓促做决定。" align="center" /><div class="process-grid">{site.process.map((item) => <div class="process-card" data-reveal><span>{item.step}</span><div class="process-icon"><Icon name={site.services[Number(item.step) - 1]?.icon || "arrow"} size={25} /></div><h3>{item.title}</h3><p>{item.desc}</p></div>)}</div></div></section>
<section class="section faq-preview" id="faq"><div class="container faq-layout"><SectionHeading eyebrow="FAQ" title="入行之前,先把关键问题问清楚" intro="车型、租期、证照、平台和费用都应该基于你的情况逐项确认。" /><div class="faq-list">{site.faq.slice(0, 4).map((item, index) => <details open={index === 0}><summary><span>{String(index + 1).padStart(2, "0")}</span>{item.q}<i></i></summary><p>{item.a}</p></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 CTA from "../../components/CTA.astro"; import SectionHeading from "../../components/SectionHeading.astro"; import Icon from "../../components/Icon.astro"; import { site } from "../../data/site";
const title = `天津网约车司机招募|${site.brand.name}`; const description = "嘉运面向天津全职、兼职网约车司机提供车辆租售、合规办证、平台注册、免费岗前培训与专属运营顾问服务。";
---
<Base title={title} description={description}><Header /><main id="main-content"><section class="join-hero"><div class="container join-hero__inner"><div><p class="eyebrow"><span></span>DRIVER RECRUITMENT</p><h1>想在天津跑网约车?<br /><em>从这里稳稳起步</em></h1><p>全职、兼职都可以聊。车型怎么选、平台怎么配、证件怎么办,嘉运按你的实际情况逐项梳理。</p><div class="hero-actions"><a class="button" href={`tel:${site.contact.tel}`}><Icon name="phone" size={19} />咨询入行方案</a><a class="button button--secondary" href="#fit">看看是否适合</a></div></div><div class="join-card"><span>新人入行支持</span><ul><li><Icon name="check" size={18} />多种租售方案</li><li><Icon name="check" size={18} />人证车证协助</li><li><Icon name="check" size={18} />主流平台对接</li><li><Icon name="check" size={18} />免费岗前培训</li><li><Icon name="check" size={18} />专属顾问跟进</li></ul><small>优惠与扶持政策以门店当期方案为准</small></div></div></section>
<section class="section" id="fit"><div class="container"><SectionHeading eyebrow="IS IT FOR YOU" title="这几类人,都可以来嘉运聊聊" intro="先了解实际情况,再判断适合购车、租车,还是先从短周期方案起步。" /><div class="fit-grid"><div><strong>01</strong><h3>零经验新人</h3><p>对平台、车型和证照流程不熟,希望有人全程带着入行。</p></div><div><strong>02</strong><h3>兼职司机</h3><p>需要根据可投入时间,评估更灵活的车辆与平台组合。</p></div><div><strong>03</strong><h3>全职司机</h3><p>关注长期成本、车辆稳定性和后续运营支持。</p></div><div><strong>04</strong><h3>个人与小车队</h3><p>需要采购或租赁新能源营运车辆,并统一衔接平台服务。</p></div></div></div></section>
<section class="section join-support" id="support"><div class="container join-support__layout"><div><p class="eyebrow"><span></span>FULL SUPPORT</p><h2>上岗不是终点,<br />后续有人跟进才安心</h2><p>嘉运提供从岗前准备到日常运营的持续支持,遇到平台注册、账号或车辆问题,可以联系专属顾问与线下门店。</p><a class="button button--secondary" href="/services/">查看全部服务 <Icon name="arrow" size={18} /></a></div><div class="support-stack">{site.process.map((item) => <div><span>{item.step}</span><div><h3>{item.title}</h3><p>{item.desc}</p></div></div>)}</div></div></section>
<section class="section income-note" id="income"><div class="container"><Icon name="shield" size={28} /><div><h2>先算清成本,再谈跑车计划</h2><p>网约车收入会受到在线时长、接单效率、平台规则、市场供需和车辆成本等多种因素影响。嘉运不作绝对收益承诺,建议到店结合个人情况核算。</p></div></div></section><CTA title="告诉我们你的时间与预算,匹配一套入行方案" /></main><Footer /></Base>
import type { APIRoute } from "astro";
import { site } from "../data/site";
export const prerender = true;
export const GET: APIRoute = ({ site: siteUrl }) => {
const base = (siteUrl?.href || "https://www.jiayunche.com/").replace(/\/$/, "");
const body = `# ${site.brand.name}\n\n> ${site.brand.tagline}\n\n${site.home.subhead}\n\n## 核心服务\n${site.services.map((item) => `- ${item.name}${item.intro}`).join("\n")}\n\n## 联系方式\n- 电话:${site.contact.phone}\n- 地址:${site.contact.address}\n- 服务时间:${site.contact.serviceHours}\n\n## 重要页面\n- 首页:${base}/\n- 服务方案:${base}/services/\n- 司机招募:${base}/join/\n- 跑车指南:${base}/articles/\n- 关于嘉运:${base}/about/\n- 常见问题:${base}/faq/\n- 联系我们:${base}/contact/\n\n## 信息边界\n- 平台规则、准入条件、车型价格与优惠政策以实时信息为准。\n- 嘉运不对网约车营收作绝对承诺。\n`;
return new Response(body, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
};
---
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 SectionHeading from "../../components/SectionHeading.astro"; import Icon from "../../components/Icon.astro"; import { site } from "../../data/site";
const title = `服务方案|${site.brand.name}`; const description = "嘉运网约车提供新能源车辆销售与租赁、合规证照代办、平台入驻、司机培训、运营指导、售后维保与道路救援。";
---
<Base title={title} description={description}>
<Header /><main id="main-content"><section class="page-hero"><div class="container"><div class="breadcrumb"><a href="/">首页</a><span>/</span>服务方案</div><p class="eyebrow"><span></span>OUR SERVICES</p><h1>跑网约车需要的,<br /><em>一站配齐</em></h1><p>不只给一台车,更从合规、平台与运营角度,帮你规划完整的跑车方案。</p></div></section>
<section class="section"><div class="container service-detail-list">{site.services.map((service, index) => <article class:list={["service-detail", index % 2 && "service-detail--reverse"]} id={service.slug} data-reveal><div class="service-detail__summary"><span>SERVICE {service.index}</span><h3>{service.name}</h3><p>{service.value}</p><dl><div><dt>服务对象</dt><dd>{service.audience}</dd></div><div><dt>服务方式</dt><dd>到店沟通后按实际情况匹配方案</dd></div></dl></div><div class="service-detail__copy"><p class="eyebrow"><span></span>SERVICE {service.index}</p><h2>{service.name}</h2><p class="large-copy">{service.intro}</p><dl><div><dt>适合谁</dt><dd>{service.audience}</dd></div><div><dt>核心价值</dt><dd>{service.value}</dd></div></dl><p class="service-note"><Icon name="check" size={17} />{service.note}</p><a class="button button--secondary" href={`tel:${site.contact.tel}`}>咨询这项服务 <Icon name="arrow" size={18} /></a></div></article>)}</div></section>
<section class="section process-section"><div class="container"><SectionHeading eyebrow="SERVICE FLOW" title="你的情况不同,方案也应该不同" intro="从沟通到上岗,每一步都有明确的下一步。" align="center" /><div class="process-grid">{site.process.map((item) => <div class="process-card"><span>{item.step}</span><h3>{item.title}</h3><p>{item.desc}</p></div>)}</div></div></section><CTA /></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: #ff7d41;
--primary-hover: #e96a32;
--primary-light: #fff1e9;
--green: #00b700;
--ink: #231916;
--muted: #6b625f;
--background: #f6f7f8;
--surface: #ffffff;
--border: #e8e5e3;
--dark: #231916;
--radius: 18px;
--shadow: 0 20px 60px rgba(50, 29, 20, .1);
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
color: var(--ink);
background: var(--surface);
font-synthesis: none;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; scroll-padding-top: 110px; }
body { margin: 0; min-width: 320px; background: var(--surface); color: var(--ink); line-height: 1.7; -webkit-font-smoothing: antialiased; }
img, svg { display: block; max-width: 100%; }
a { color: inherit; text-decoration: none; }
button, input, textarea { font: inherit; }
h1, h2, h3, p { margin-top: 0; }
h1, h2, h3 { line-height: 1.18; letter-spacing: -.035em; }
h1 { font-size: clamp(42px, 5.7vw, 78px); }
h2 { font-size: clamp(32px, 4vw, 54px); }
h3 { font-size: 21px; }
.container { width: min(1180px, calc(100% - 48px)); margin-inline: auto; }
.section { padding: 112px 0; }
.skip-link { position: fixed; left: 18px; top: -80px; z-index: 1000; background: var(--ink); color: #fff; padding: 10px 16px; }
.skip-link:focus { top: 18px; }
.button { display: inline-flex; align-items: center; justify-content: center; gap: 9px; min-height: 52px; padding: 0 24px; border: 1px solid var(--primary); border-radius: 999px; color: #fff; background: var(--primary); font-size: 15px; font-weight: 700; transition: .24s ease; cursor: pointer; }
.button:hover { color: #fff; background: var(--primary-hover); border-color: var(--primary-hover); transform: translateY(-2px); box-shadow: 0 12px 24px rgba(255, 125, 65, .24); }
.button--secondary { color: var(--ink); background: #fff; border-color: #d8d3d0; }
.button--secondary:hover { color: var(--primary); background: #fff; border-color: var(--primary); box-shadow: none; }
.button--white { color: var(--ink); background: #fff; border-color: #fff; }
.button--white:hover { color: var(--primary); background: #fff; border-color: #fff; }
.button--small { min-height: 42px; padding: 0 20px; font-size: 14px; }
.text-link { display: inline-flex; align-items: center; gap: 8px; color: var(--primary); font-weight: 700; font-size: 14px; }
.text-link svg { transition: transform .2s ease; }
.text-link:hover svg { transform: translateX(4px); }
.eyebrow { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; color: var(--primary); font-size: 12px; font-weight: 800; letter-spacing: .18em; text-transform: uppercase; }
.eyebrow span { width: 26px; height: 2px; background: currentColor; }
.eyebrow--light { color: #ffc3a7; }
.section-heading { max-width: 700px; margin-bottom: 52px; }
.section-heading h2 { margin-bottom: 18px; }
.section-intro { margin: 0; color: var(--muted); font-size: 17px; max-width: 640px; }
.section-heading--center { text-align: center; margin-inline: auto; }
.section-heading--center .eyebrow { justify-content: center; }
.section-heading--center .section-intro { margin-inline: auto; }
.breadcrumb { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 42px; font-size: 13px; color: #8d8582; }
.breadcrumb a:hover { color: var(--primary); }
.utility-bar { min-height: 34px; display: flex; align-items: center; background: var(--ink); color: #d8d2cf; font-size: 12px; }
.utility-bar__inner { display: flex; justify-content: space-between; align-items: center; }
.utility-bar a { color: #fff; }
.site-header { position: sticky; top: 0; z-index: 100; background: rgba(255,255,255,.94); border-bottom: 1px solid var(--border); backdrop-filter: blur(16px); transition: box-shadow .2s ease; }
.site-header.is-scrolled { box-shadow: 0 10px 28px rgba(35,25,22,.08); }
.site-header__inner { min-height: 78px; display: flex; align-items: center; gap: 34px; }
.brand { width: 164px; margin-right: auto; }
.brand img { width: 100%; height: auto; }
.desktop-nav { display: flex; align-items: stretch; height: 78px; }
.desktop-nav > ul { height: 100%; display: flex; align-items: stretch; margin: 0; padding: 0; list-style: none; }
.nav-item { position: relative; display: flex; align-items: stretch; }
.nav-link { position: relative; display: flex; align-items: center; padding: 0 13px; font-size: 14px; font-weight: 600; white-space: nowrap; }
.nav-link::after { content: ""; position: absolute; left: 13px; right: 13px; bottom: 0; height: 3px; border-radius: 3px 3px 0 0; background: var(--primary); transform: scaleX(0); transition: transform .2s ease; }
.nav-link:hover, .nav-link[aria-current="page"], .nav-item:focus-within > .nav-link { color: var(--primary); }
.nav-link[aria-current="page"]::after, .nav-item:hover > .nav-link::after, .nav-item:focus-within > .nav-link::after { transform: scaleX(1); }
.nav-dropdown { position: absolute; left: 50%; top: calc(100% - 1px); z-index: 120; width: 246px; padding: 10px; visibility: hidden; opacity: 0; pointer-events: none; background: rgba(255,255,255,.98); border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 22px 55px rgba(35,25,22,.14); transform: translate(-50%, 10px); transition: opacity .18s ease, transform .18s ease, visibility .18s ease; backdrop-filter: blur(14px); }
.nav-dropdown::before { content: ""; position: absolute; left: 0; right: 0; top: -10px; height: 10px; }
.nav-item:hover .nav-dropdown, .nav-item:focus-within .nav-dropdown { visibility: visible; opacity: 1; pointer-events: auto; transform: translate(-50%, 0); }
.nav-item:nth-last-child(-n+2) .nav-dropdown { left: auto; right: 0; transform: translateY(10px); }
.nav-item:nth-last-child(-n+2):hover .nav-dropdown, .nav-item:nth-last-child(-n+2):focus-within .nav-dropdown { transform: translateY(0); }
.nav-dropdown ul { display: grid; gap: 3px; margin: 0; padding: 0; list-style: none; }
.nav-dropdown--wide { width: 430px; }
.nav-dropdown--wide ul { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.nav-dropdown a { display: flex; align-items: center; justify-content: space-between; gap: 14px; min-height: 43px; padding: 9px 12px; color: #4f4744; border-radius: 9px; font-size: 13px; font-weight: 600; white-space: nowrap; transition: color .18s ease, background .18s ease; }
.nav-dropdown a span { color: #b9b1ae; font-size: 11px; transition: transform .18s ease; }
.nav-dropdown a:hover, .nav-dropdown a:focus-visible { color: var(--primary); background: var(--primary-light); outline: none; }
.nav-dropdown a:hover span, .nav-dropdown a:focus-visible span { color: var(--primary); transform: translate(2px, -2px); }
.mobile-nav { display: none; position: relative; }
.mobile-nav summary { width: 42px; height: 42px; display: grid; place-content: center; gap: 5px; list-style: none; cursor: pointer; }
.mobile-nav summary::-webkit-details-marker { display: none; }
.mobile-nav summary span { width: 24px; height: 2px; background: var(--ink); }
.mobile-nav nav { position: absolute; top: 54px; right: 0; width: min(380px, calc(100vw - 24px)); max-height: calc(100vh - 130px); display: grid; padding: 14px; overflow-y: auto; overscroll-behavior: contain; background: #fff; border: 1px solid var(--border); border-radius: 16px; box-shadow: var(--shadow); }
.mobile-nav__group { padding: 9px 4px 12px; border-bottom: 1px solid var(--border); }
.mobile-nav__primary { display: block; padding: 3px 6px 9px; font-weight: 800; }
.mobile-nav__primary[aria-current="page"] { color: var(--primary); }
.mobile-subnav { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 5px; }
.mobile-subnav a { min-width: 0; padding: 7px 8px; overflow: hidden; color: var(--muted); background: var(--background); border-radius: 7px; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.mobile-subnav a:hover { color: var(--primary); background: var(--primary-light); }
.mobile-nav .button { margin-top: 14px; }
.hero { position: relative; overflow: hidden; background: linear-gradient(140deg, #fff 15%, #fff9f5 100%); }
.hero__inner { position: relative; min-height: 610px; display: flex; align-items: center; padding-top: 72px; padding-bottom: 76px; }
.hero-copy { max-width: 850px; }
.hero-copy h1 { margin-bottom: 26px; }
.hero-copy h1 em, .page-hero h1 em, .join-hero h1 em { color: var(--primary); font-style: normal; }
.hero-lead { max-width: 650px; color: var(--muted); font-size: 18px; }
.hero-actions { display: flex; flex-wrap: wrap; gap: 12px; margin: 34px 0 30px; }
.hero-trust { display: flex; flex-wrap: wrap; gap: 20px; color: #4f4744; font-size: 13px; }
.hero-trust span { display: flex; align-items: center; gap: 7px; }
.hero-trust svg { color: var(--green); }
.stats-strip { background: var(--dark); color: #fff; }
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); }
.stats-grid div { display: flex; flex-direction: column; padding: 34px 28px; border-right: 1px solid rgba(255,255,255,.11); }.stats-grid div:first-child { border-left: 1px solid rgba(255,255,255,.11); }.stats-grid strong { color: var(--primary); font-size: 30px; letter-spacing: -.04em; }.stats-grid span { color: #c7c0bd; font-size: 13px; }
.services-preview { background: var(--background); }
.service-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
.service-card { position: relative; min-height: 318px; display: flex; flex-direction: column; padding: 30px; overflow: hidden; background: #fff; border: 1px solid transparent; border-radius: var(--radius); transition: .25s ease; }
.service-card:hover { transform: translateY(-7px); border-color: rgba(255,125,65,.45); box-shadow: var(--shadow); }
.service-card__icon { width: 54px; height: 54px; display: grid; place-items: center; margin-bottom: 44px; color: var(--primary); background: var(--primary-light); border-radius: 15px; }
.service-card__index { position: absolute; top: 28px; right: 28px; color: #ddd8d5; font-size: 28px; font-weight: 800; }
.service-card h3 { margin-bottom: 13px; }.service-card p { color: var(--muted); font-size: 14px; }.service-card .text-link { margin-top: auto; }
.trust-section { background: #fff; }.trust-layout { display: grid; grid-template-columns: .92fr 1.08fr; gap: 90px; align-items: center; }
.trust-proof { min-height: 480px; display: flex; flex-direction: column; justify-content: center; padding: 52px; color: #fff; background: var(--dark); border-radius: 28px; }
.trust-proof > p { margin: 0 0 24px; color: var(--primary); font-size: 10px; font-weight: 800; letter-spacing: .16em; }
.trust-proof > h3 { margin-bottom: 8px; font-size: clamp(30px, 3.2vw, 46px); }
.trust-proof > span { color: #b9b0ad; font-size: 14px; }
.trust-proof dl { margin: 42px 0 0; border-top: 1px solid rgba(255,255,255,.14); }
.trust-proof dl > div { display: flex; justify-content: space-between; gap: 24px; padding: 21px 0; border-bottom: 1px solid rgba(255,255,255,.14); }
.trust-proof dt { font-weight: 800; }
.trust-proof dd { margin: 0; color: #aaa19e; font-size: 13px; text-align: right; }
.trust-copy h2 { margin-bottom: 26px; }.trust-copy > p:not(.eyebrow) { color: var(--muted); font-size: 16px; }.advantage-list { margin: 30px 0 34px; border-top: 1px solid var(--border); }.advantage-list > div { display: grid; grid-template-columns: 48px 1fr; gap: 14px; padding: 19px 0; border-bottom: 1px solid var(--border); }.advantage-list > div > span { color: var(--primary); font-size: 12px; font-weight: 800; }.advantage-list h3 { margin: 0 0 5px; font-size: 17px; }.advantage-list p { margin: 0; color: var(--muted); font-size: 13px; }
.process-section { background: var(--primary-light); }.process-grid { position: relative; display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }.process-grid::before { content: ""; position: absolute; left: 10%; right: 10%; top: 54px; height: 1px; background: #efcabb; }.process-card { position: relative; z-index: 1; padding: 26px; background: #fff; border-radius: var(--radius); }.process-card > span { display: block; color: var(--primary); font-size: 12px; font-weight: 800; }.process-icon { width: 52px; height: 52px; display: grid; place-items: center; margin: 15px 0 28px; color: var(--primary); background: var(--primary-light); border-radius: 50%; }.process-card h3 { margin-bottom: 10px; font-size: 18px; }.process-card p { margin: 0; color: var(--muted); font-size: 13px; }
.faq-preview { background: #fff; }.faq-layout { display: grid; grid-template-columns: .8fr 1.2fr; gap: 90px; }.faq-list details, .faq-page-list details { border-bottom: 1px solid var(--border); }.faq-list summary, .faq-page-list summary { min-height: 74px; display: grid; grid-template-columns: 36px 1fr 24px; align-items: center; gap: 12px; list-style: none; cursor: pointer; font-weight: 700; }.faq-list summary::-webkit-details-marker, .faq-page-list summary::-webkit-details-marker { display: none; }.faq-list summary > span, .faq-page-list summary > span { color: var(--primary); font-size: 11px; }.faq-list summary i, .faq-page-list summary i { position: relative; width: 18px; height: 18px; }.faq-list summary i::before, .faq-list summary i::after, .faq-page-list summary i::before, .faq-page-list summary i::after { content: ""; position: absolute; left: 2px; right: 2px; top: 8px; height: 2px; background: var(--ink); transition: transform .2s ease; }.faq-list summary i::after, .faq-page-list summary i::after { transform: rotate(90deg); }.faq-list details[open] summary i::after, .faq-page-list details[open] summary i::after { transform: rotate(0); }.faq-list details > p, .faq-page-list details > p { padding: 0 36px 24px 48px; margin: 0; color: var(--muted); font-size: 14px; }
.cta-section { padding: 0 0 96px; background: #fff; }.cta-panel { position: relative; overflow: hidden; display: flex; align-items: center; justify-content: space-between; gap: 48px; padding: 58px 64px; color: #fff; background: var(--dark); border-radius: 24px; }.cta-panel::after { content: ""; position: absolute; width: 260px; height: 260px; right: 18%; bottom: -190px; border: 50px solid rgba(255,125,65,.12); border-radius: 50%; }.cta-panel > * { position: relative; z-index: 1; }.cta-panel h2 { max-width: 650px; margin-bottom: 13px; font-size: clamp(30px, 4vw, 48px); }.cta-panel p:not(.eyebrow) { max-width: 640px; margin: 0; color: #c8c0bd; }.cta-actions { display: grid; gap: 12px; min-width: 180px; }
.page-hero { position: relative; padding: 90px 0 105px; overflow: hidden; background: linear-gradient(135deg, #fff9f5, #f6f7f8); }.page-hero::after { content: "JIAYUN"; position: absolute; right: -22px; bottom: -52px; color: rgba(35,25,22,.035); font-size: clamp(110px, 20vw, 270px); font-weight: 900; line-height: 1; letter-spacing: -.08em; }.page-hero .container { position: relative; z-index: 1; }.page-hero h1 { max-width: 850px; margin-bottom: 24px; }.page-hero > .container > p:last-child { max-width: 620px; color: var(--muted); font-size: 17px; }
.page-hero--dark { color: #fff; background: var(--dark); }.page-hero--dark::after { color: rgba(255,255,255,.025); }.page-hero--dark .breadcrumb, .page-hero--dark > .container > p:last-child { color: #bdb5b2; }
.service-detail-list { display: grid; gap: 96px; }.service-detail { display: grid; grid-template-columns: .92fr 1.08fr; gap: 82px; align-items: center; scroll-margin-top: 130px; }.service-detail--reverse .service-detail__summary { order: 2; }.service-detail__summary { min-height: 410px; display: flex; flex-direction: column; justify-content: center; padding: 46px; color: #fff; background: var(--dark); border-radius: 26px; }.service-detail__summary > span { color: var(--primary); font-size: 10px; font-weight: 800; letter-spacing: .16em; }.service-detail__summary > h3 { margin: 28px 0 14px; font-size: clamp(30px, 3vw, 42px); }.service-detail__summary > p { color: #c2bab7; font-size: 15px; }.service-detail__copy h2 { margin-bottom: 20px; }.large-copy { color: var(--muted); font-size: 17px; }.service-detail dl { margin: 30px 0; border-top: 1px solid var(--border); }.service-detail dl div { display: grid; grid-template-columns: 90px 1fr; gap: 22px; padding: 17px 0; border-bottom: 1px solid var(--border); }.service-detail dt { color: var(--primary); font-size: 13px; font-weight: 800; }.service-detail dd { margin: 0; font-size: 14px; }.service-detail .service-detail__summary dl { margin: 30px 0 0; border-color: rgba(255,255,255,.14); }.service-detail .service-detail__summary dl div { grid-template-columns: 78px 1fr; border-color: rgba(255,255,255,.14); }.service-detail .service-detail__summary dt { color: var(--primary); }.service-detail .service-detail__summary dd { color: #aaa19e; }.service-note { display: flex; align-items: flex-start; gap: 8px; color: var(--muted); font-size: 12px; }.service-note svg { flex: none; margin-top: 2px; color: var(--green); }
.about-story { display: grid; grid-template-columns: 1.06fr .94fr; gap: 90px; align-items: center; }.about-story__copy > p { color: var(--muted); font-size: 17px; }.about-signature { display: grid; gap: 2px; margin-top: 34px; padding-left: 18px; border-left: 3px solid var(--primary); }.about-signature span { color: var(--muted); font-size: 12px; }.about-story__facts { min-height: 520px; display: flex; flex-direction: column; justify-content: center; padding: 48px; color: #fff; background: var(--dark); border-radius: 28px; }.about-story__facts > p { margin: 0; color: var(--primary); font-size: 10px; font-weight: 800; letter-spacing: .16em; }.about-story__facts > h3 { margin: 26px 0 30px; font-size: clamp(30px, 3vw, 42px); }.about-story__facts dl { margin: 0; border-top: 1px solid rgba(255,255,255,.14); }.about-story__facts dl > div { display: grid; grid-template-columns: 82px 1fr; gap: 18px; padding: 17px 0; border-bottom: 1px solid rgba(255,255,255,.14); }.about-story__facts dt { color: var(--primary); font-size: 12px; font-weight: 800; }.about-story__facts dd { margin: 0; color: #b7afac; font-size: 13px; }
.values-section { background: var(--background); }.values-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }.values-grid > div { position: relative; min-height: 310px; padding: 30px; background: #fff; border-radius: var(--radius); }.values-grid > div > span { position: absolute; top: 25px; right: 25px; color: #ddd7d4; font-weight: 800; }.values-grid svg { margin: 45px 0 34px; color: var(--primary); }.values-grid h3 { font-size: 18px; }.values-grid p { color: var(--muted); font-size: 13px; }
.certification-section { background: var(--primary-light); }.certification-layout { display: grid; grid-template-columns: .8fr 1.2fr; gap: 80px; align-items: center; }.certification-layout > div:first-child > p:last-child { color: var(--muted); }.certification-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }.certification-cards > div { display: grid; min-height: 210px; align-content: end; padding: 26px; color: #fff; background: var(--dark); border-radius: 18px; }.certification-cards > div:last-child { background: var(--primary); }.certification-cards small { opacity: .55; font-size: 9px; letter-spacing: .14em; }.certification-cards strong { margin-top: 30px; font-size: 26px; }.certification-cards span { opacity: .75; font-size: 12px; }
.join-hero { padding: 92px 0 104px; background: linear-gradient(135deg, #fff 40%, var(--primary-light)); }.join-hero__inner { display: grid; grid-template-columns: 1.1fr .9fr; gap: 80px; align-items: center; }.join-hero h1 { margin-bottom: 25px; }.join-hero__inner > div:first-child > p:not(.eyebrow) { max-width: 650px; color: var(--muted); font-size: 17px; }.join-card { padding: 36px; color: #fff; background: var(--dark); border-radius: 24px; box-shadow: var(--shadow); transform: rotate(1.5deg); }.join-card > span { color: var(--primary); font-size: 12px; font-weight: 800; letter-spacing: .12em; }.join-card ul { display: grid; gap: 15px; margin: 32px 0; padding: 0; list-style: none; }.join-card li { display: flex; align-items: center; gap: 10px; padding-bottom: 15px; border-bottom: 1px solid rgba(255,255,255,.1); }.join-card li svg { color: var(--green); }.join-card small { color: #a89f9c; }
.fit-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }.fit-grid > div { min-height: 250px; padding: 28px; background: var(--background); border-radius: var(--radius); }.fit-grid strong { color: var(--primary); font-size: 12px; }.fit-grid h3 { margin-top: 70px; font-size: 19px; }.fit-grid p { color: var(--muted); font-size: 13px; }.join-support { color: #fff; background: var(--dark); }.join-support__layout { display: grid; grid-template-columns: .9fr 1.1fr; gap: 90px; align-items: center; }.join-support__layout > div:first-child > p:not(.eyebrow) { color: #bdb5b2; }.support-stack { border-top: 1px solid rgba(255,255,255,.12); }.support-stack > div { display: grid; grid-template-columns: 48px 1fr; gap: 18px; padding: 23px 0; border-bottom: 1px solid rgba(255,255,255,.12); }.support-stack > div > span { color: var(--primary); font-size: 12px; font-weight: 800; }.support-stack h3 { margin-bottom: 6px; font-size: 18px; }.support-stack p { margin: 0; color: #a9a09d; font-size: 13px; }.income-note { padding: 55px 0; background: var(--primary-light); }.income-note .container { display: grid; grid-template-columns: 50px 1fr; gap: 24px; max-width: 880px; }.income-note svg { color: var(--green); }.income-note h2 { margin-bottom: 10px; font-size: 24px; }.income-note p { margin: 0; color: var(--muted); font-size: 14px; }
.faq-page-layout { display: grid; grid-template-columns: 310px 1fr; gap: 90px; align-items: start; }.faq-page-layout aside { position: sticky; top: 130px; padding: 28px; color: #fff; background: var(--dark); border-radius: var(--radius); }.faq-page-layout aside > span { color: var(--primary); font-size: 10px; font-weight: 800; letter-spacing: .15em; }.faq-page-layout aside h2 { margin-top: 28px; font-size: 26px; }.faq-page-layout aside p { color: #b9b0ad; font-size: 13px; }.faq-page-layout aside .button { width: 100%; margin-top: 10px; }.faq-page-list summary { min-height: 90px; font-size: 17px; }.faq-page-list details > p { padding-bottom: 30px; font-size: 15px; }
.contact-hero { padding: 86px 0; color: #fff; background: var(--dark); }.contact-hero__inner { display: grid; grid-template-columns: 1fr auto; gap: 70px; align-items: end; }.contact-hero h1 { margin-bottom: 18px; }.contact-hero__inner > div:first-child > p:last-child { max-width: 650px; margin: 0; color: #bdb5b2; }.contact-number { display: grid; text-align: right; }.contact-number small { color: var(--primary); font-size: 10px; letter-spacing: .16em; }.contact-number a { font-size: clamp(28px, 3.5vw, 46px); font-weight: 800; letter-spacing: -.03em; }.contact-number span { color: #a39a97; font-size: 12px; }.contact-grid { display: grid; grid-template-columns: .85fr 1.15fr; gap: 24px; }.contact-cards { display: grid; gap: 14px; }.contact-cards > * { display: grid; grid-template-columns: 46px 1fr 24px; align-items: center; gap: 15px; padding: 25px; background: var(--background); border: 1px solid transparent; border-radius: var(--radius); transition: .2s ease; }.contact-cards > a:hover { border-color: var(--primary); background: #fff; }.contact-cards > * > svg:first-child { color: var(--primary); }.contact-cards span { display: grid; }.contact-cards small { color: var(--muted); font-size: 11px; }.contact-cards strong { font-size: 16px; }.visit-panel { min-height: 450px; display: flex; flex-direction: column; justify-content: center; padding: 46px; color: #fff; background: var(--dark); border-radius: 24px; }.visit-panel > p { margin: 0; color: var(--primary); font-size: 10px; font-weight: 800; letter-spacing: .16em; }.visit-panel h2 { margin: 24px 0 28px; }.visit-panel dl { margin: 0 0 30px; border-top: 1px solid rgba(255,255,255,.14); }.visit-panel dl > div { display: grid; grid-template-columns: 76px 1fr; gap: 18px; padding: 16px 0; border-bottom: 1px solid rgba(255,255,255,.14); }.visit-panel dt { color: var(--primary); font-size: 12px; font-weight: 800; }.visit-panel dd { margin: 0; color: #b7afac; font-size: 13px; }.visit-panel .button { align-self: flex-start; color: #fff; background: transparent; border-color: rgba(255,255,255,.25); }.visit-panel .button:hover { color: var(--primary); border-color: var(--primary); }.visit-tips { padding-top: 0; }.visit-tips .container { display: grid; grid-template-columns: 1.1fr repeat(3, 1fr); gap: 1px; padding: 0; overflow: hidden; background: var(--border); border: 1px solid var(--border); border-radius: var(--radius); }.visit-tips .container > * { margin: 0; padding: 28px; background: #fff; }.visit-tips h2 { font-size: 23px; }.visit-tips span { color: var(--primary); font-size: 11px; font-weight: 800; }.visit-tips p { color: var(--muted); font-size: 12px; }.visit-tips b { display: block; color: var(--ink); font-size: 15px; }
.journal-hero { padding-bottom: 80px; }.journal-section { background: var(--background); }.category-nav { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 38px; }.category-nav a { display: inline-flex; align-items: center; gap: 8px; padding: 9px 15px; background: #fff; border: 1px solid var(--border); border-radius: 999px; font-size: 13px; }.category-nav a span { color: #9a918e; }.category-nav a:hover, .category-nav a[aria-current="page"] { color: #fff; background: var(--primary); border-color: var(--primary); }.category-nav a[aria-current="page"] span { color: #fff; }.article-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }.article-card { overflow: hidden; background: #fff; border-radius: var(--radius); }.article-card__intro { min-height: 176px; display: flex; flex-direction: column; align-items: flex-start; justify-content: flex-end; padding: 25px; color: #fff; background: var(--dark); }.article-card__intro > span { margin-bottom: auto; color: var(--primary); font-size: 10px; font-weight: 800; }.article-card__intro > strong { margin-top: 36px; font-size: 23px; }.article-card__intro > small { margin-top: 5px; color: #9f9693; font-size: 9px; letter-spacing: .14em; }.article-card__body { padding: 25px; }.article-meta { display: flex; justify-content: space-between; color: #9a918e; font-size: 10px; }.article-card h2 { margin: 14px 0 12px; font-size: 20px; }.article-card h2 a:hover { color: var(--primary); }.article-card p { color: var(--muted); font-size: 13px; }.article-card .text-link { margin-top: 8px; }.pagination { display: flex; justify-content: center; gap: 7px; margin-top: 50px; }.pagination a, .pagination span { width: 38px; height: 38px; display: grid; place-items: center; background: #fff; border: 1px solid var(--border); border-radius: 50%; font-size: 13px; }.pagination a[aria-current="page"] { color: #fff; background: var(--primary); border-color: var(--primary); }.empty-state { padding: 80px; text-align: center; background: #fff; border-radius: var(--radius); }.empty-state h2 { font-size: 27px; }.empty-state p { color: var(--muted); }
.article-page > header { padding: 80px 0; background: var(--primary-light); }.article-header { max-width: 900px; }.article-category { display: inline-block; margin-bottom: 20px; padding: 6px 10px; color: var(--primary); background: #fff; border-radius: 999px; font-size: 11px; font-weight: 700; }.article-header h1 { margin-bottom: 26px; font-size: clamp(38px, 5vw, 66px); }.article-byline { display: flex; flex-wrap: wrap; gap: 18px; color: var(--muted); font-size: 12px; }.article-layout { max-width: 1020px; display: grid; grid-template-columns: minmax(0, 1fr) 250px; gap: 68px; padding-top: 72px; padding-bottom: 110px; align-items: start; }.article-content { min-width: 0; color: #403936; font-size: 16px; line-height: 1.9; }.article-content h2 { margin: 44px 0 18px; color: var(--ink); font-size: 30px; }.article-content h3 { margin: 34px 0 14px; color: var(--ink); }.article-content img { height: auto; margin: 30px 0; border-radius: 14px; }.article-content a { color: var(--primary); text-decoration: underline; }.article-content blockquote { margin: 28px 0; padding: 18px 22px; background: var(--primary-light); border-left: 4px solid var(--primary); }.article-content ul, .article-content ol { padding-left: 24px; }.article-layout aside { position: sticky; top: 130px; padding: 22px; background: var(--background); border-radius: 14px; }.article-layout aside strong { display: block; margin-bottom: 10px; }.article-layout aside p { color: var(--muted); font-size: 12px; }.article-layout aside a { color: var(--primary); font-size: 12px; font-weight: 700; }
.not-found { min-height: 65vh; display: grid; place-items: center; padding: 80px 24px; text-align: center; background: var(--primary-light); }.not-found > div > span { color: var(--primary); font-size: clamp(90px, 18vw, 210px); font-weight: 900; line-height: .8; opacity: .22; }.not-found h1 { margin-top: 20px; font-size: 42px; }.not-found p { color: var(--muted); }
.site-footer { padding: 72px 0 0; color: #fff; background: var(--dark); }.footer-grid { display: grid; grid-template-columns: 1.6fr repeat(3, 1fr); gap: 58px; padding-bottom: 55px; }.footer-logo { display: block; width: 180px; margin-bottom: 22px; }.footer-brand > p { max-width: 330px; color: #b9b1ae; font-size: 14px; }.footer-tags { display: flex; flex-wrap: wrap; gap: 18px; color: #b9b1ae; font-size: 11px; }.footer-tags span { display: inline-flex; align-items: center; }.footer-grid h2 { margin-bottom: 20px; font-size: 14px; }.footer-grid > div:not(.footer-brand) > a, .footer-contact > p { display: flex; align-items: flex-start; gap: 8px; margin: 11px 0; color: #aaa29f; font-size: 12px; }.footer-grid a:hover { color: var(--primary); }.footer-contact svg { flex: none; margin-top: 2px; }.footer-bottom { display: flex; justify-content: space-between; gap: 30px; padding: 20px 0; color: #817875; border-top: 1px solid rgba(255,255,255,.1); font-size: 10px; }.footer-bottom p { margin: 0; }
[data-reveal] { opacity: 0; transform: translateY(24px); transition: opacity .65s ease, transform .65s ease; }[data-reveal].is-visible { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; } [data-reveal] { opacity: 1; transform: none; } }
@media (max-width: 1080px) {
.desktop-nav { display: none; }.mobile-nav { display: block; }.header-cta { margin-left: auto; }
.hero__inner { min-height: 560px; }
.trust-layout, .about-story, .join-support__layout { gap: 50px; }.service-detail { gap: 50px; }.footer-grid { grid-template-columns: 1.5fr repeat(3, 1fr); gap: 28px; }
}
@media (max-width: 820px) {
.container { width: min(100% - 32px, 680px); }.section { padding: 80px 0; }.utility-bar__inner span { display: none; }.utility-bar__inner { justify-content: center; }
.site-header__inner { min-height: 68px; }.brand { width: 145px; }.header-cta { display: none; }
.join-hero__inner { grid-template-columns: 1fr; padding-top: 64px; }.hero-copy { text-align: left; }
.stats-grid { grid-template-columns: repeat(2, 1fr); }.stats-grid div:first-child { border-left: 0; }.stats-grid div:nth-child(2) { border-right: 0; }.stats-grid div:nth-child(-n+2) { border-bottom: 1px solid rgba(255,255,255,.11); }
.service-grid, .article-grid { grid-template-columns: repeat(2, 1fr); }.trust-layout, .faq-layout, .about-story, .certification-layout, .join-support__layout, .contact-grid, .contact-hero__inner { grid-template-columns: 1fr; }.process-grid, .values-grid, .fit-grid { grid-template-columns: repeat(2, 1fr); }.process-grid::before { display: none; }
.service-detail, .service-detail--reverse { grid-template-columns: 1fr; gap: 36px; }.service-detail--reverse .service-detail__summary { order: 0; }.service-detail__summary { min-height: 330px; }
.certification-layout { gap: 40px; }.faq-page-layout { grid-template-columns: 1fr; gap: 40px; }.faq-page-layout aside { position: static; }.contact-number { text-align: left; }.visit-tips .container { grid-template-columns: 1fr 1fr; }.article-layout { grid-template-columns: 1fr; }.article-layout aside { position: static; }.footer-grid { grid-template-columns: 1.4fr 1fr 1fr; }.footer-contact { grid-column: 1 / -1; }.cta-panel { align-items: flex-start; flex-direction: column; padding: 45px; }.cta-actions { display: flex; flex-wrap: wrap; }
}
@media (max-width: 560px) {
.container { width: min(100% - 28px, 520px); }.section { padding: 68px 0; } h1 { font-size: clamp(38px, 12vw, 54px); }.section-heading { margin-bottom: 36px; }
.hero__inner { min-height: auto; padding-top: 54px; padding-bottom: 46px; }.hero-lead { font-size: 16px; }.hero-actions .button { flex: 1; min-width: 150px; }.hero-trust { gap: 10px 16px; }
.stats-grid strong { font-size: 26px; }.stats-grid div { padding: 25px 18px; }.service-grid, .article-grid, .process-grid, .values-grid, .fit-grid, .certification-cards { grid-template-columns: 1fr; }.service-card { min-height: 285px; }.trust-proof { min-height: auto; padding: 36px 28px; }.faq-layout { gap: 16px; }.faq-list summary { min-height: 82px; }.faq-list details > p { padding-left: 48px; }
.page-hero { padding: 62px 0 72px; }.page-hero::after { display: none; }.breadcrumb { margin-bottom: 30px; }.service-detail-list { gap: 70px; }.about-story__facts, .service-detail__summary, .visit-panel { min-height: auto; padding: 36px 28px; }.join-hero { padding: 65px 0; }.join-card { padding: 28px; }.contact-hero { padding: 65px 0; }.contact-number a { font-size: 32px; }.visit-tips .container { grid-template-columns: 1fr; }.visit-tips .container > * { padding: 22px; }.article-card__intro { min-height: 160px; }.article-page > header { padding: 58px 0; }.article-layout { padding-top: 48px; padding-bottom: 80px; }.article-content { font-size: 15px; }.empty-state { padding: 55px 24px; }
.cta-section { padding-bottom: 68px; }.cta-panel { padding: 36px 26px; }.cta-actions { width: 100%; }.cta-actions .button { width: 100%; }.footer-grid { grid-template-columns: 1fr 1fr; }.footer-brand, .footer-contact { grid-column: 1 / -1; }.footer-bottom { flex-direction: column; gap: 8px; }.footer-logo { width: 170px; }
}
{
"extends": "astro/tsconfigs/strict"
}
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" version="1.1" width="431.0182" height="105.4444" viewBox="0 0 431.0182 105.4444">
<defs>
<clipPath id="clip_1">
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M72.1655 356.1899H503.1837V461.6343H72.1655Z"/>
</clipPath>
<clipPath id="clip_2">
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M0 0H595.2756V841.8898H0Z"/>
</clipPath>
<clipPath id="clip_3">
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M-.2021-.015H595.0735V841.87478H-.2021Z" clip-rule="evenodd"/>
</clipPath>
</defs>
<g clip-path="url(#clip_1)">
<g clip-path="url(#clip_2)">
<g clip-path="url(#clip_3)">
<g inkscape:groupmode="layer" inkscape:label="&#x56FE;&#x5C42; 1">
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M378.5428 571.0618H384.8482V552.9336H400.8438V567.6772H407.3349V550.2492C407.3349 549.9408 407.082 549.6885 406.7742 549.6885H384.8482V528.2685H402.2807V545.0519H408.7721V525.5841C408.7721 525.2766 408.5186 525.0234 408.2111 525.0234H355.5054C355.1978 525.0234 354.9447 525.2757 354.9447 525.5841V545.0519H360.9252V528.2685H378.5428V549.6885H356.7107C356.4028 549.6885 356.15 549.9408 356.15 550.2492V567.6772H362.1311V552.9336H378.5428ZM427.0462 558.3889H433.0128L427.1026 552.024 427.4099 552.1459V525.0234H421.1042V545.5636L420.787 545.2217H414.8201ZM427.0462 571.2934H433.0128L420.787 558.1264H414.8201ZM464.1755 553.0731V525.5841C464.1755 525.2766 463.9218 525.0234 463.6148 525.0234H446.6503V528.2685H457.6844V553.0731H435.4767V556.1328H471.7327V553.0731ZM436.0334 569.1144H469.9709V566.0544H436.0334ZM282.524 564.7867H264.9274V561.4112H274.7217 281.4437V557.9501 554.8031 551.3389 548.1816 546.4576 544.7225H264.9274 258.211 248.8116 242.0844 225.5709V546.4576 548.1816 551.3389 554.8031 557.9501 561.4112H232.2924 242.0844V564.7867H224.4923V568.2487H242.0844V571.432H248.8116V568.2487H258.211V571.432H264.9274V568.2487H282.524ZM248.8116 561.4112H258.211V564.7867H248.8116ZM242.0844 548.1816V551.3389H232.2924V548.1816ZM242.0844 554.8031V557.9501H232.2924V554.8031ZM248.8116 557.9501V554.8031H258.211V557.9501ZM248.8116 551.3389V548.1816H258.211V551.3389ZM264.9274 557.9501V554.8031H274.7217V557.9501ZM264.9274 551.3389V548.1816H274.7217V551.3389ZM272.8831 532.2688H234.1312V528.8967H272.8831ZM234.1312 535.7265H272.8831V539.0932H234.1312ZM234.1312 542.5549H272.8831 279.6024 279.6058V527.5964H279.6024V525.439H227.4063V527.5964 528.8967 532.2688 535.7265 539.0932 542.5549ZM301.2148 525.399H294.5171 289.7697V528.7728H294.5171V539.9759L288.7424 538.547V542.6464L294.5171 544.0751V558.3237H288.7424V561.6961H294.5171V570.9665H301.2148V561.6961H306.9893V558.3237H301.2148V545.7325L306.9893 547.1614V543.062L301.2148 541.6333V528.7728ZM306.3742 525.4226 313.3613 525.399 320.9613 537.611H324.1602V525.399H330.8635V537.611H334.0562L341.6562 525.399 348.6447 525.4226 341.0566 537.611H347.9142V540.9836H330.8635V543.494H324.1602V540.9836H307.1064V537.611H313.9642ZM335.0251 567.076H319.9955V562.5536H335.0251ZM319.9955 570.4534H335.0251 341.7245V567.076 562.5536 559.1826H335.0251 319.9955 313.2935V562.5536 567.076 570.4534ZM319.3333 553.4257H314.68V548.9073H319.3333ZM314.68 556.8032H319.3333 325.3139 326.0307V545.5253H325.3139 319.3333 314.68 307.9769V548.9073 553.4257 556.8032ZM340.3454 553.4257H335.6952V548.9073H340.3454ZM335.6952 556.8032H340.3454 346.3285 347.044V545.5253H346.3285 340.3454 335.6952 328.9929V548.9073 553.4257 556.8032Z" fill="#231916" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M197.5893 558.8167V582.6728H151.3655C139.6312 582.6728 129.4668 578.4585 121.208 570.1507 112.9751 561.8503 108.8079 551.74 108.8079 540.1259V518.1891C108.8079 506.4511 112.9901 496.3224 121.2341 488.086 129.4608 479.8559 139.6066 475.6856 151.3655 475.6856H197.5893V499.5153H185.7027V487.5222H151.3655C142.7445 487.5222 135.6208 490.4524 129.5957 496.4851 123.5917 502.4815 120.67 509.599 120.67 518.1891V540.1259C120.67 548.6292 123.5917 555.7133 129.6179 561.7888 135.6653 567.8631 142.7726 570.8154 151.3655 570.8154H185.7027V558.8167ZM138.1326 553.3435V553.3435C141.8451 557.045 146.1784 558.8167 151.3655 558.8167H176.402V561.198H151.3655C145.6118 561.198 140.5933 559.1055 136.4638 555.0216 132.3468 550.9193 130.2741 545.8944 130.2741 540.1259V518.1891C130.2741 512.437 132.3468 507.4503 136.4638 503.3123 140.6217 499.2174 145.6438 497.1237 151.3655 497.1237H176.402V499.4901H151.3655C146.1946 499.4901 141.8842 501.2952 138.1326 505.0287 134.492 508.6446 132.6453 513.0797 132.6453 518.1891V540.1259C132.6453 545.216 134.492 549.6908 138.1326 553.3435" fill="#00b700"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M232.166 485.7494H248.574V488.7612H232.166C231.1659 488.7612 230.3271 489.1107 229.6148 489.8154 228.9107 490.5261 228.5578 491.3637 228.5578 492.3425V502.5677C228.5578 503.526 228.9107 504.3855 229.6148 505.0774 230.3271 505.7793 231.1659 506.1285 232.166 506.1285H248.574V509.1392H232.166C230.3158 509.1392 228.744 508.498 227.4562 507.2091 226.1588 505.9117 225.5097 504.3699 225.5097 502.5677V492.3425C225.5097 490.5147 226.1588 488.9557 227.4562 487.6778 228.744 486.3912 230.3158 485.7494 232.166 485.7494ZM271.7501 489.7936C272.2028 490.6661 272.912 491.1083 273.9166 491.1083H287.5224C288.4949 491.1083 289.2073 490.6661 289.643 489.7936 290.9075 487.4125 291.6465 486.0638 291.8188 485.7494H295.6116C287.5127 500.8618 283.3393 508.6494 283.0669 509.1392H278.3633L265.8421 485.7494H269.6562ZM274.8974 495.6724V495.6724L279.8997 504.9244C280.0811 505.2631 280.3541 505.446 280.7444 505.446 281.1197 505.446 281.4029 505.2631 281.5869 504.9266L286.4611 495.6724C286.6629 495.3078 286.6629 494.9597 286.4438 494.6094 286.2354 494.2817 285.9426 494.113 285.5466 494.113H275.794C275.3966 494.113 275.0899 494.2817 274.8881 494.6094 274.7001 494.9671 274.7001 495.3138 274.8974 495.6724ZM324.7121 509.1395C321.1245 509.1395 318.0818 508.1292 315.6151 506.1283 312.7827 503.8722 311.366 500.9822 311.366 497.4418 311.366 493.8758 312.7501 490.9731 315.5465 488.7383 318.0025 486.7415 321.0673 485.7491 324.7121 485.7491 328.3784 485.7491 331.4157 486.7342 333.8728 488.6949 336.6632 490.9127 338.0559 493.8284 338.0559 497.4418 338.0559 500.9579 336.653 503.8427 333.8413 506.1283 331.4058 508.1292 328.3634 509.1395 324.7121 509.1395ZM331.8823 490.8626V490.8626C330.0038 489.4033 327.6227 488.6657 324.7121 488.6657 321.8264 488.6657 319.4436 489.3721 317.5722 490.828 315.5241 492.4327 314.5073 494.6292 314.5073 497.4194 314.5073 500.169 315.5465 502.344 317.6368 503.9654 319.5524 505.4754 321.9282 506.2238 324.7121 506.2238 327.4914 506.2238 329.7954 505.4973 331.6448 504.0488 333.8141 502.3732 334.908 500.169 334.908 497.4194 334.908 494.6428 333.8952 492.4545 331.8823 490.8626ZM363.64 485.7494H380.0446V488.7612H363.64C362.6408 488.7612 361.7983 489.1107 361.0899 489.8154 360.3815 490.5261 360.032 491.3637 360.032 492.3425V502.5677C360.032 503.526 360.3815 504.3855 361.0899 505.0774 361.7983 505.7793 362.6408 506.1285 363.64 506.1285H380.0446V509.1392H363.64C361.7878 509.1392 360.2169 508.498 358.9271 507.2091 357.6319 505.9117 356.9794 504.3699 356.9794 502.5677V492.3425C356.9794 490.5147 357.6319 488.9557 358.9271 487.6778 360.2169 486.3912 361.7878 485.7494 363.64 485.7494ZM404.4044 489.7933C404.8597 490.6658 405.5681 491.108 406.5718 491.108H420.1784C421.1512 491.108 421.8605 490.6658 422.3007 489.7933 423.5618 487.4125 424.3005 486.0635 424.472 485.7491H428.2648C420.1679 500.8618 415.9925 508.6494 415.724 509.1395H411.0188L398.4973 485.7491H402.3167ZM407.5537 495.6724V495.6724L412.5523 504.9244C412.7389 505.2631 413.0093 505.446 413.4025 505.446 413.7704 505.446 414.0558 505.2631 414.2401 504.9266L419.1157 495.6724C419.3178 495.3076 419.3178 494.9597 419.1021 494.6094 418.8895 494.2817 418.6023 494.113 418.2055 494.113H408.45C408.0518 494.113 407.7473 494.2817 407.5413 494.6094 407.3528 494.9671 407.3528 495.3135 407.5537 495.6724ZM457.8454 509.1392C454.252 509.1392 451.2121 508.1289 448.7414 506.1285 445.9102 503.8722 444.4946 500.9822 444.4946 497.4418 444.4946 493.8758 445.8861 490.9731 448.6751 488.7383 451.1322 486.7415 454.1967 485.7494 457.8454 485.7494 461.507 485.7494 464.5454 486.7342 467.0014 488.6949 469.7901 490.913 471.1825 493.8287 471.1825 497.4418 471.1825 500.9579 469.783 503.8427 466.9702 506.1285 464.531 508.1289 461.4911 509.1392 457.8454 509.1392ZM465.0106 490.8623V490.8623C463.1301 489.403 460.7504 488.6657 457.8454 488.6657 454.9552 488.6657 452.5741 489.3724 450.7027 490.828 448.6518 492.4327 447.6325 494.6292 447.6325 497.4191 447.6325 500.169 448.6751 502.3437 450.7631 503.9654 452.6807 505.4754 455.0559 506.2238 457.8454 506.2238 460.622 506.2238 462.9277 505.497 464.777 504.0488 466.9432 502.3732 468.0329 500.169 468.0329 497.4191 468.0329 494.6431 467.0235 492.4542 465.0106 490.8623" fill="#231916"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M113.6237 453.6343C113.6602 453.6343 113.6965 453.6332 113.7328 453.6329L113.9179 448.9656C113.8198 448.9676 113.722 448.9693 113.6237 448.9693 105.6569 448.9693 99.1984 442.5109 99.1984 434.5441 99.1984 434.522 99.1993 434.4999 99.1993 434.4778L94.5337 434.5265 94.5334 434.5441C94.5334 445.0873 103.0805 453.6343 113.6237 453.6343" fill="#00b700" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M113.6237 447.4142C113.7427 447.4142 113.8612 447.4125 113.9794 447.4091L114.1648 442.7311C113.9859 442.7427 113.8056 442.7493 113.6237 442.7493 109.0919 442.7493 105.4185 439.0758 105.4185 434.5441 105.4185 434.5002 105.4193 434.4562 105.4202 434.4123L100.7544 434.4613C100.7544 434.4888 100.7535 434.5163 100.7535 434.5441 100.7535 441.6523 106.5155 447.4142 113.6237 447.4142" fill="#00b700" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M113.6237 441.1945C113.8269 441.1945 114.0282 441.1848 114.2269 441.167L114.4171 436.3645V436.3642C114.1741 436.4705 113.9057 436.5295 113.6237 436.5295 112.5272 436.5295 111.6383 435.6405 111.6383 434.5441 111.6383 434.4775 111.6417 434.4117 111.6482 434.3468L106.9753 434.3958C106.9741 434.4452 106.9733 434.4945 106.9733 434.5441 106.9733 438.2169 109.9508 441.1945 113.6237 441.1945" fill="#00b700" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M193.7293 446.3107H150.3281C124.9716 446.3107 104.2257 425.5648 104.2257 400.2083V394.2652H140.2892V405.1502H115.4599C117.8861 422.1867 132.656 435.426 150.3281 435.426H162.5063 182.8443 193.7293Z" fill="#00b700" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M139.343 422.7063H163.923 174.8081V411.8216 405.2412 394.0889H163.923C156.0767 391.898 150.158 385.0869 149.2852 376.7729V372.5082 365.9732H139.515 80.1655V376.8582H138.3676C139.3764 391.1621 150.0647 402.7932 163.923 405.2412V411.8216H139.343Z" fill="#00b700" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M169.3655 385.9132C175.3642 385.9132 180.2271 381.0504 180.2271 375.0517 180.2271 369.0527 175.3642 364.1899 169.3655 364.1899 163.3669 364.1899 158.5037 369.0527 158.5037 375.0517 158.5037 381.0504 163.3669 385.9132 169.3655 385.9132" fill="#00b700" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M212.6614 433.4111H240.4437V437.0525H211.6945V442.1129H240.4437V443.6765H245.482V442.1129H274.2313V437.0525H245.482V433.4111H273.2644V428.3507H212.6614Z" fill="#231916" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M216.3847 426.746H269.5408C271.3399 426.746 272.812 425.2742 272.812 423.4751V417.4478C272.812 415.6489 271.3399 414.1769 269.5408 414.1769H216.3847C214.5858 414.1769 213.1138 415.6489 213.1138 417.4478V423.4751C213.1138 425.2742 214.5858 426.746 216.3847 426.746ZM220.9927 422.9918H264.9331C265.8382 422.9918 266.5789 422.2514 266.5789 421.3463V419.5769C266.5789 418.6718 265.8382 417.9311 264.9331 417.9311H220.9927C220.0876 417.9311 219.3469 418.6718 219.3469 419.5769V421.3463C219.3469 422.2514 220.0876 422.9918 220.9927 422.9918" fill="#231916" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M212.188 411.3587H273.7378V406.298H227.9665C227.6731 405.3033 227.3675 404.367 227.0526 403.4798H238.9893C240.9352 403.4798 242.5275 401.8876 242.5275 399.9416V392.0213C242.5275 390.0753 240.9352 388.4834 238.9893 388.4834H231.2782V393.6107H235.6736C236.2884 393.6107 236.7916 394.1135 236.7916 394.7287V397.2345C236.7916 397.8493 236.2884 398.3525 235.6736 398.3525H224.8551C219.2264 387.4822 212.188 388.4834 212.188 388.4834V394.2652C214.5807 395.3069 216.3192 396.786 217.5812 398.3525H212.188V403.4798H220.2871C220.8047 405.1293 220.8898 406.298 220.8898 406.298H212.188Z" fill="#231916" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M248.2294 403.4798H269.9527C272.0344 403.4798 273.7378 401.7764 273.7378 399.6947V392.2685C273.7378 390.1864 272.0344 388.4834 269.9527 388.4834H248.2294C246.1473 388.4834 244.4443 390.1864 244.4443 392.2685V399.6947C244.4443 401.7764 246.1473 403.4798 248.2294 403.4798ZM251.7265 398.5118H266.4556C267.406 398.5118 268.1836 397.7342 268.1836 396.7838V395.1791C268.1836 394.2286 267.406 393.4511 266.4556 393.4511H251.7265C250.776 393.4511 249.9985 394.2286 249.9985 395.1791V396.7838C249.9985 397.7342 250.776 398.5118 251.7265 398.5118" fill="#231916" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M297.4357 442.1129H341.0266V437.0525H297.4357Z" fill="#231916" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M281.1433 442.1129H292.5604V437.0525H281.1433Z" fill="#231916" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M296.2633 428.0525H341.0266V422.9918H296.2633Z" fill="#231916" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M295.365 394.6686C311.1676 394.6686 326.6436 394.1028 342.4462 394.1028 345.3653 394.1028 347.7535 396.4912 347.7535 399.4104V413.3075H354.172V399.5819C354.172 393.6983 349.3582 388.8848 343.4749 388.8848H333.9473 315.4978 293.7036C291.924 388.8848 289.2994 390.8086 287.6409 391.9414 285.3286 389.7833 283.3228 388.8848 280.421 388.8848V394.5801C281.274 394.5801 282.2868 394.9401 282.9283 395.534 283.4742 396.0394 284.0607 396.6968 284.3717 397.3439 284.781 398.1957 284.9876 399.0951 284.9652 400.3084 284.9474 401.2798 284.9258 405.4266 284.9652 419.0987 284.9661 419.4247 284.9652 419.8671 284.9652 420.4335 284.9652 421.5257 284.885 421.9685 284.3997 422.5147 283.9586 423.0105 281.3922 423.201 281.0917 423.1341V428.6854H283.6919C285.3099 428.6854 286.6045 428.5743 287.5754 428.3518 288.546 428.1293 289.3348 427.6236 289.9417 426.835 290.5483 426.0461 290.8562 424.8023 290.8517 423.1032L290.791 399.4424C290.7862 397.4698 290.9409 396.6293 291.6822 395.8512 292.3554 395.1448 293.6256 394.6686 295.365 394.6686" fill="#231916"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M310.7339 422.9918 304.3585 408.617C303.808 407.3757 305.4694 406.1483 306.8269 406.1483H331.1836C332.5411 406.1483 333.8456 407.2731 333.652 408.617L332.2525 418.3265H338.468L339.844 406.6214C340.2414 403.2391 337.0575 400.4297 333.652 400.4297H303.0418C299.6362 400.4297 295.5716 403.4648 296.8498 406.6214L303.4777 422.9918Z" fill="#231916" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M349.1785 442.1129H361.8814V437.0525H349.1785Z" fill="#231916" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M349.1785 428.3507H361.8814V423.29H349.1785Z" fill="#231916" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M369.1225 442.1129C369.1225 442.1129 369.0814 437.8915 365.4196 436.394V429.9185C365.4196 429.9185 369.2979 430.5609 372.2428 434.522H406.9738V439.5827H374.6684C374.8893 440.3631 375.0738 441.2038 375.2116 442.1129Z" fill="#231916" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M364.9105 429.0089H406.2745V423.9485H364.9105Z" fill="#231916" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M364.9105 418.8923H396.7294C400.9382 418.8923 404.3818 415.4485 404.3818 411.2396V407.2076 397.3745C404.3818 395.9609 405.1426 394.7165 406.2745 394.0299V387.8705C401.726 387.8705 398.0047 391.5918 398.0047 396.1403V399.1436 407.2076 408.7851C398.0047 411.09 396.1191 412.9758 393.8139 412.9758H364.9105Z" fill="#231916" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M413.5569 403.0523H441.4927V412.3706 417.8429H446.9649 471.7327V412.3706H446.9649V403.0523H471.7327V397.58H446.9649V387.6029H441.4927V397.58H413.5569Z" fill="#231916" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M435.9796 418.8923C438.0021 418.8923 439.6416 417.2528 439.6416 415.23 439.6416 413.2074 438.0021 411.5679 435.9796 411.5679 433.957 411.5679 432.3175 413.2074 432.3175 415.23 432.3175 417.2528 433.957 418.8923 435.9796 418.8923" fill="#231916" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M436.4674 428.112H472.6117V423.447H436.4674C432.8535 423.447 429.1367 420.2158 428.4068 416.7094L427.5037 412.3706H427.3135 422.7389 418.578 414.1239 413.9087L414.2154 419.4266C414.6148 428.6134 422.4583 436.1301 431.6454 436.1301H438.4281C440.8753 443.3261 448.7652 449.2137 455.961 449.2137H495.1837V444.5484H455.961C450.5309 444.5484 444.5651 439.687 442.8445 434.6283L441.769 431.4651H436.8416 431.6454C424.9491 431.4651 419.1667 425.9103 418.8762 419.224L418.7809 417.0356H423.7098L423.8396 417.6598C425.0364 423.4085 430.7187 428.112 436.4674 428.112" fill="#231916" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M221.1063 378.3702V370.7175C221.1063 368.2284 219.0699 366.192 216.5808 366.192H212.188V369.3019H216.5808C217.3592 369.3019 217.9964 369.9388 217.9964 370.7175V378.3702Z" fill="#231916" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M235.9491 378.3702H238.4371V366.3845H235.9491Z" fill="#231916" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M257.3252 378.3702H260.3976L265.746 366.3845H262.4136L261.0544 369.5859H256.6684L255.3092 366.3845H251.9768ZM260.2335 371.5197 258.8613 374.7518 257.4893 371.5197Z" fill="#231916" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M285.0216 372.3639V366.7884H282.471V372.3639L277.8219 378.7404H280.9151L283.7614 374.1942 286.6073 378.7404H289.671Z" fill="#231916" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M313.4721 378.7404V371.0673C313.4721 368.7137 311.5466 366.7884 309.1932 366.7884H306.9714C304.6181 366.7884 302.6925 368.7137 302.6925 371.0673V378.7404H305.1805V370.9466C305.1805 369.7676 306.1451 368.8033 307.3241 368.8033H308.8406C310.0195 368.8033 310.9842 369.7676 310.9842 370.9466V378.7404Z" fill="#231916" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M335.9673 378.7404H338.4553V369.8328 366.7884H335.9673 334.8972 334.887V366.8074L330.1177 375.6957H329.9596V366.7884H327.4713V375.6957 378.7404H329.9596 330.8136 330.8139L335.5163 369.8328H335.9673Z" fill="#231916" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M377.6709 378.7404H379.5284C382.3432 378.7404 384.6461 376.4375 384.6461 373.6227V371.9061C384.6461 370.6146 384.1611 369.4311 383.3649 368.5283H385.2309V366.7884H379.5284 378.4294 377.6709C374.8561 366.7884 372.5532 369.0913 372.5532 371.9061V373.6227C372.5532 376.4375 374.8561 378.7404 377.6709 378.7404ZM378.5998 376.8301C380.5279 376.8301 382.091 375.0097 382.091 372.7644 382.091 370.5191 380.5279 368.6987 378.5998 368.6987 376.6714 368.6987 375.1084 370.5191 375.1084 372.7644 375.1084 375.0097 376.6714 376.8301 378.5998 376.8301" fill="#231916" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M398.3156 378.3702H400.8039V366.3845H398.3156Z" fill="#231916" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M420.114 378.3702H424.5987V375.8822H420.114C418.4099 375.8822 417.0067 374.4788 417.0067 372.7746V371.9801C417.0067 370.2759 418.4099 368.8727 420.114 368.8727H424.5987V366.3845H420.114C417.0365 366.3845 414.5187 368.9025 414.5187 371.9801V372.7746C414.5187 375.8522 417.0365 378.3702 420.114 378.3702" fill="#231916" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M444.477 378.3702H446.9649V366.3845H444.477V371.1237H439.5019V366.3845H437.0139V378.3702H439.5019V373.6117H444.477Z" fill="#231916" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M466.1527 378.3702H470.6371V375.8822H466.1527C464.7379 375.8822 463.5309 374.9151 463.1613 373.6117H470.6371V371.1237H463.1669C463.5425 369.83 464.745 368.8727 466.1527 368.8727H470.6371V366.3845H466.1527C463.0751 366.3845 460.5571 368.9025 460.5571 371.9801V372.7746C460.5571 375.8522 463.0751 378.3702 466.1527 378.3702" fill="#231916" fill-rule="evenodd"/>
</g>
</g>
</g>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" version="1.1" width="431.0182" height="105.4444" viewBox="0 0 431.0182 105.4444">
<defs>
<clipPath id="clip_1">
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M72.1655 356.1899H503.1837V461.6343H72.1655Z"/>
</clipPath>
<clipPath id="clip_2">
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M0 0H595.2756V841.8898H0Z"/>
</clipPath>
<clipPath id="clip_3">
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M-.2021-.015H595.0735V841.87478H-.2021Z" clip-rule="evenodd"/>
</clipPath>
</defs>
<g clip-path="url(#clip_1)">
<g clip-path="url(#clip_2)">
<g clip-path="url(#clip_3)">
<g inkscape:groupmode="layer" inkscape:label="&#x56FE;&#x5C42; 1">
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M378.5428 571.0618H384.8482V552.9336H400.8438V567.6772H407.3349V550.2492C407.3349 549.9408 407.082 549.6885 406.7742 549.6885H384.8482V528.2685H402.2807V545.0519H408.7721V525.5841C408.7721 525.2766 408.5186 525.0234 408.2111 525.0234H355.5054C355.1978 525.0234 354.9447 525.2757 354.9447 525.5841V545.0519H360.9252V528.2685H378.5428V549.6885H356.7107C356.4028 549.6885 356.15 549.9408 356.15 550.2492V567.6772H362.1311V552.9336H378.5428ZM427.0462 558.3889H433.0128L427.1026 552.024 427.4099 552.1459V525.0234H421.1042V545.5636L420.787 545.2217H414.8201ZM427.0462 571.2934H433.0128L420.787 558.1264H414.8201ZM464.1755 553.0731V525.5841C464.1755 525.2766 463.9218 525.0234 463.6148 525.0234H446.6503V528.2685H457.6844V553.0731H435.4767V556.1328H471.7327V553.0731ZM436.0334 569.1144H469.9709V566.0544H436.0334ZM282.524 564.7867H264.9274V561.4112H274.7217 281.4437V557.9501 554.8031 551.3389 548.1816 546.4576 544.7225H264.9274 258.211 248.8116 242.0844 225.5709V546.4576 548.1816 551.3389 554.8031 557.9501 561.4112H232.2924 242.0844V564.7867H224.4923V568.2487H242.0844V571.432H248.8116V568.2487H258.211V571.432H264.9274V568.2487H282.524ZM248.8116 561.4112H258.211V564.7867H248.8116ZM242.0844 548.1816V551.3389H232.2924V548.1816ZM242.0844 554.8031V557.9501H232.2924V554.8031ZM248.8116 557.9501V554.8031H258.211V557.9501ZM248.8116 551.3389V548.1816H258.211V551.3389ZM264.9274 557.9501V554.8031H274.7217V557.9501ZM264.9274 551.3389V548.1816H274.7217V551.3389ZM272.8831 532.2688H234.1312V528.8967H272.8831ZM234.1312 535.7265H272.8831V539.0932H234.1312ZM234.1312 542.5549H272.8831 279.6024 279.6058V527.5964H279.6024V525.439H227.4063V527.5964 528.8967 532.2688 535.7265 539.0932 542.5549ZM301.2148 525.399H294.5171 289.7697V528.7728H294.5171V539.9759L288.7424 538.547V542.6464L294.5171 544.0751V558.3237H288.7424V561.6961H294.5171V570.9665H301.2148V561.6961H306.9893V558.3237H301.2148V545.7325L306.9893 547.1614V543.062L301.2148 541.6333V528.7728ZM306.3742 525.4226 313.3613 525.399 320.9613 537.611H324.1602V525.399H330.8635V537.611H334.0562L341.6562 525.399 348.6447 525.4226 341.0566 537.611H347.9142V540.9836H330.8635V543.494H324.1602V540.9836H307.1064V537.611H313.9642ZM335.0251 567.076H319.9955V562.5536H335.0251ZM319.9955 570.4534H335.0251 341.7245V567.076 562.5536 559.1826H335.0251 319.9955 313.2935V562.5536 567.076 570.4534ZM319.3333 553.4257H314.68V548.9073H319.3333ZM314.68 556.8032H319.3333 325.3139 326.0307V545.5253H325.3139 319.3333 314.68 307.9769V548.9073 553.4257 556.8032ZM340.3454 553.4257H335.6952V548.9073H340.3454ZM335.6952 556.8032H340.3454 346.3285 347.044V545.5253H346.3285 340.3454 335.6952 328.9929V548.9073 553.4257 556.8032Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M197.5893 558.8167V582.6728H151.3655C139.6312 582.6728 129.4668 578.4585 121.208 570.1507 112.9751 561.8503 108.8079 551.74 108.8079 540.1259V518.1891C108.8079 506.4511 112.9901 496.3224 121.2341 488.086 129.4608 479.8559 139.6066 475.6856 151.3655 475.6856H197.5893V499.5153H185.7027V487.5222H151.3655C142.7445 487.5222 135.6208 490.4524 129.5957 496.4851 123.5917 502.4815 120.67 509.599 120.67 518.1891V540.1259C120.67 548.6292 123.5917 555.7133 129.6179 561.7888 135.6653 567.8631 142.7726 570.8154 151.3655 570.8154H185.7027V558.8167ZM138.1326 553.3435V553.3435C141.8451 557.045 146.1784 558.8167 151.3655 558.8167H176.402V561.198H151.3655C145.6118 561.198 140.5933 559.1055 136.4638 555.0216 132.3468 550.9193 130.2741 545.8944 130.2741 540.1259V518.1891C130.2741 512.437 132.3468 507.4503 136.4638 503.3123 140.6217 499.2174 145.6438 497.1237 151.3655 497.1237H176.402V499.4901H151.3655C146.1946 499.4901 141.8842 501.2952 138.1326 505.0287 134.492 508.6446 132.6453 513.0797 132.6453 518.1891V540.1259C132.6453 545.216 134.492 549.6908 138.1326 553.3435" fill="#00b700"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M232.166 485.7494H248.574V488.7612H232.166C231.1659 488.7612 230.3271 489.1107 229.6148 489.8154 228.9107 490.5261 228.5578 491.3637 228.5578 492.3425V502.5677C228.5578 503.526 228.9107 504.3855 229.6148 505.0774 230.3271 505.7793 231.1659 506.1285 232.166 506.1285H248.574V509.1392H232.166C230.3158 509.1392 228.744 508.498 227.4562 507.2091 226.1588 505.9117 225.5097 504.3699 225.5097 502.5677V492.3425C225.5097 490.5147 226.1588 488.9557 227.4562 487.6778 228.744 486.3912 230.3158 485.7494 232.166 485.7494ZM271.7501 489.7936C272.2028 490.6661 272.912 491.1083 273.9166 491.1083H287.5224C288.4949 491.1083 289.2073 490.6661 289.643 489.7936 290.9075 487.4125 291.6465 486.0638 291.8188 485.7494H295.6116C287.5127 500.8618 283.3393 508.6494 283.0669 509.1392H278.3633L265.8421 485.7494H269.6562ZM274.8974 495.6724V495.6724L279.8997 504.9244C280.0811 505.2631 280.3541 505.446 280.7444 505.446 281.1197 505.446 281.4029 505.2631 281.5869 504.9266L286.4611 495.6724C286.6629 495.3078 286.6629 494.9597 286.4438 494.6094 286.2354 494.2817 285.9426 494.113 285.5466 494.113H275.794C275.3966 494.113 275.0899 494.2817 274.8881 494.6094 274.7001 494.9671 274.7001 495.3138 274.8974 495.6724ZM324.7121 509.1395C321.1245 509.1395 318.0818 508.1292 315.6151 506.1283 312.7827 503.8722 311.366 500.9822 311.366 497.4418 311.366 493.8758 312.7501 490.9731 315.5465 488.7383 318.0025 486.7415 321.0673 485.7491 324.7121 485.7491 328.3784 485.7491 331.4157 486.7342 333.8728 488.6949 336.6632 490.9127 338.0559 493.8284 338.0559 497.4418 338.0559 500.9579 336.653 503.8427 333.8413 506.1283 331.4058 508.1292 328.3634 509.1395 324.7121 509.1395ZM331.8823 490.8626V490.8626C330.0038 489.4033 327.6227 488.6657 324.7121 488.6657 321.8264 488.6657 319.4436 489.3721 317.5722 490.828 315.5241 492.4327 314.5073 494.6292 314.5073 497.4194 314.5073 500.169 315.5465 502.344 317.6368 503.9654 319.5524 505.4754 321.9282 506.2238 324.7121 506.2238 327.4914 506.2238 329.7954 505.4973 331.6448 504.0488 333.8141 502.3732 334.908 500.169 334.908 497.4194 334.908 494.6428 333.8952 492.4545 331.8823 490.8626ZM363.64 485.7494H380.0446V488.7612H363.64C362.6408 488.7612 361.7983 489.1107 361.0899 489.8154 360.3815 490.5261 360.032 491.3637 360.032 492.3425V502.5677C360.032 503.526 360.3815 504.3855 361.0899 505.0774 361.7983 505.7793 362.6408 506.1285 363.64 506.1285H380.0446V509.1392H363.64C361.7878 509.1392 360.2169 508.498 358.9271 507.2091 357.6319 505.9117 356.9794 504.3699 356.9794 502.5677V492.3425C356.9794 490.5147 357.6319 488.9557 358.9271 487.6778 360.2169 486.3912 361.7878 485.7494 363.64 485.7494ZM404.4044 489.7933C404.8597 490.6658 405.5681 491.108 406.5718 491.108H420.1784C421.1512 491.108 421.8605 490.6658 422.3007 489.7933 423.5618 487.4125 424.3005 486.0635 424.472 485.7491H428.2648C420.1679 500.8618 415.9925 508.6494 415.724 509.1395H411.0188L398.4973 485.7491H402.3167ZM407.5537 495.6724V495.6724L412.5523 504.9244C412.7389 505.2631 413.0093 505.446 413.4025 505.446 413.7704 505.446 414.0558 505.2631 414.2401 504.9266L419.1157 495.6724C419.3178 495.3076 419.3178 494.9597 419.1021 494.6094 418.8895 494.2817 418.6023 494.113 418.2055 494.113H408.45C408.0518 494.113 407.7473 494.2817 407.5413 494.6094 407.3528 494.9671 407.3528 495.3135 407.5537 495.6724ZM457.8454 509.1392C454.252 509.1392 451.2121 508.1289 448.7414 506.1285 445.9102 503.8722 444.4946 500.9822 444.4946 497.4418 444.4946 493.8758 445.8861 490.9731 448.6751 488.7383 451.1322 486.7415 454.1967 485.7494 457.8454 485.7494 461.507 485.7494 464.5454 486.7342 467.0014 488.6949 469.7901 490.913 471.1825 493.8287 471.1825 497.4418 471.1825 500.9579 469.783 503.8427 466.9702 506.1285 464.531 508.1289 461.4911 509.1392 457.8454 509.1392ZM465.0106 490.8623V490.8623C463.1301 489.403 460.7504 488.6657 457.8454 488.6657 454.9552 488.6657 452.5741 489.3724 450.7027 490.828 448.6518 492.4327 447.6325 494.6292 447.6325 497.4191 447.6325 500.169 448.6751 502.3437 450.7631 503.9654 452.6807 505.4754 455.0559 506.2238 457.8454 506.2238 460.622 506.2238 462.9277 505.497 464.777 504.0488 466.9432 502.3732 468.0329 500.169 468.0329 497.4191 468.0329 494.6431 467.0235 492.4542 465.0106 490.8623" fill="#FFFFFF"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M113.6237 453.6343C113.6602 453.6343 113.6965 453.6332 113.7328 453.6329L113.9179 448.9656C113.8198 448.9676 113.722 448.9693 113.6237 448.9693 105.6569 448.9693 99.1984 442.5109 99.1984 434.5441 99.1984 434.522 99.1993 434.4999 99.1993 434.4778L94.5337 434.5265 94.5334 434.5441C94.5334 445.0873 103.0805 453.6343 113.6237 453.6343" fill="#00b700" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M113.6237 447.4142C113.7427 447.4142 113.8612 447.4125 113.9794 447.4091L114.1648 442.7311C113.9859 442.7427 113.8056 442.7493 113.6237 442.7493 109.0919 442.7493 105.4185 439.0758 105.4185 434.5441 105.4185 434.5002 105.4193 434.4562 105.4202 434.4123L100.7544 434.4613C100.7544 434.4888 100.7535 434.5163 100.7535 434.5441 100.7535 441.6523 106.5155 447.4142 113.6237 447.4142" fill="#00b700" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M113.6237 441.1945C113.8269 441.1945 114.0282 441.1848 114.2269 441.167L114.4171 436.3645V436.3642C114.1741 436.4705 113.9057 436.5295 113.6237 436.5295 112.5272 436.5295 111.6383 435.6405 111.6383 434.5441 111.6383 434.4775 111.6417 434.4117 111.6482 434.3468L106.9753 434.3958C106.9741 434.4452 106.9733 434.4945 106.9733 434.5441 106.9733 438.2169 109.9508 441.1945 113.6237 441.1945" fill="#00b700" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M193.7293 446.3107H150.3281C124.9716 446.3107 104.2257 425.5648 104.2257 400.2083V394.2652H140.2892V405.1502H115.4599C117.8861 422.1867 132.656 435.426 150.3281 435.426H162.5063 182.8443 193.7293Z" fill="#00b700" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M139.343 422.7063H163.923 174.8081V411.8216 405.2412 394.0889H163.923C156.0767 391.898 150.158 385.0869 149.2852 376.7729V372.5082 365.9732H139.515 80.1655V376.8582H138.3676C139.3764 391.1621 150.0647 402.7932 163.923 405.2412V411.8216H139.343Z" fill="#00b700" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M169.3655 385.9132C175.3642 385.9132 180.2271 381.0504 180.2271 375.0517 180.2271 369.0527 175.3642 364.1899 169.3655 364.1899 163.3669 364.1899 158.5037 369.0527 158.5037 375.0517 158.5037 381.0504 163.3669 385.9132 169.3655 385.9132" fill="#00b700" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M212.6614 433.4111H240.4437V437.0525H211.6945V442.1129H240.4437V443.6765H245.482V442.1129H274.2313V437.0525H245.482V433.4111H273.2644V428.3507H212.6614Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M216.3847 426.746H269.5408C271.3399 426.746 272.812 425.2742 272.812 423.4751V417.4478C272.812 415.6489 271.3399 414.1769 269.5408 414.1769H216.3847C214.5858 414.1769 213.1138 415.6489 213.1138 417.4478V423.4751C213.1138 425.2742 214.5858 426.746 216.3847 426.746ZM220.9927 422.9918H264.9331C265.8382 422.9918 266.5789 422.2514 266.5789 421.3463V419.5769C266.5789 418.6718 265.8382 417.9311 264.9331 417.9311H220.9927C220.0876 417.9311 219.3469 418.6718 219.3469 419.5769V421.3463C219.3469 422.2514 220.0876 422.9918 220.9927 422.9918" fill="#FFFFFF" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M212.188 411.3587H273.7378V406.298H227.9665C227.6731 405.3033 227.3675 404.367 227.0526 403.4798H238.9893C240.9352 403.4798 242.5275 401.8876 242.5275 399.9416V392.0213C242.5275 390.0753 240.9352 388.4834 238.9893 388.4834H231.2782V393.6107H235.6736C236.2884 393.6107 236.7916 394.1135 236.7916 394.7287V397.2345C236.7916 397.8493 236.2884 398.3525 235.6736 398.3525H224.8551C219.2264 387.4822 212.188 388.4834 212.188 388.4834V394.2652C214.5807 395.3069 216.3192 396.786 217.5812 398.3525H212.188V403.4798H220.2871C220.8047 405.1293 220.8898 406.298 220.8898 406.298H212.188Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M248.2294 403.4798H269.9527C272.0344 403.4798 273.7378 401.7764 273.7378 399.6947V392.2685C273.7378 390.1864 272.0344 388.4834 269.9527 388.4834H248.2294C246.1473 388.4834 244.4443 390.1864 244.4443 392.2685V399.6947C244.4443 401.7764 246.1473 403.4798 248.2294 403.4798ZM251.7265 398.5118H266.4556C267.406 398.5118 268.1836 397.7342 268.1836 396.7838V395.1791C268.1836 394.2286 267.406 393.4511 266.4556 393.4511H251.7265C250.776 393.4511 249.9985 394.2286 249.9985 395.1791V396.7838C249.9985 397.7342 250.776 398.5118 251.7265 398.5118" fill="#FFFFFF" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M297.4357 442.1129H341.0266V437.0525H297.4357Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M281.1433 442.1129H292.5604V437.0525H281.1433Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M296.2633 428.0525H341.0266V422.9918H296.2633Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M295.365 394.6686C311.1676 394.6686 326.6436 394.1028 342.4462 394.1028 345.3653 394.1028 347.7535 396.4912 347.7535 399.4104V413.3075H354.172V399.5819C354.172 393.6983 349.3582 388.8848 343.4749 388.8848H333.9473 315.4978 293.7036C291.924 388.8848 289.2994 390.8086 287.6409 391.9414 285.3286 389.7833 283.3228 388.8848 280.421 388.8848V394.5801C281.274 394.5801 282.2868 394.9401 282.9283 395.534 283.4742 396.0394 284.0607 396.6968 284.3717 397.3439 284.781 398.1957 284.9876 399.0951 284.9652 400.3084 284.9474 401.2798 284.9258 405.4266 284.9652 419.0987 284.9661 419.4247 284.9652 419.8671 284.9652 420.4335 284.9652 421.5257 284.885 421.9685 284.3997 422.5147 283.9586 423.0105 281.3922 423.201 281.0917 423.1341V428.6854H283.6919C285.3099 428.6854 286.6045 428.5743 287.5754 428.3518 288.546 428.1293 289.3348 427.6236 289.9417 426.835 290.5483 426.0461 290.8562 424.8023 290.8517 423.1032L290.791 399.4424C290.7862 397.4698 290.9409 396.6293 291.6822 395.8512 292.3554 395.1448 293.6256 394.6686 295.365 394.6686" fill="#FFFFFF"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M310.7339 422.9918 304.3585 408.617C303.808 407.3757 305.4694 406.1483 306.8269 406.1483H331.1836C332.5411 406.1483 333.8456 407.2731 333.652 408.617L332.2525 418.3265H338.468L339.844 406.6214C340.2414 403.2391 337.0575 400.4297 333.652 400.4297H303.0418C299.6362 400.4297 295.5716 403.4648 296.8498 406.6214L303.4777 422.9918Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M349.1785 442.1129H361.8814V437.0525H349.1785Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M349.1785 428.3507H361.8814V423.29H349.1785Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M369.1225 442.1129C369.1225 442.1129 369.0814 437.8915 365.4196 436.394V429.9185C365.4196 429.9185 369.2979 430.5609 372.2428 434.522H406.9738V439.5827H374.6684C374.8893 440.3631 375.0738 441.2038 375.2116 442.1129Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M364.9105 429.0089H406.2745V423.9485H364.9105Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M364.9105 418.8923H396.7294C400.9382 418.8923 404.3818 415.4485 404.3818 411.2396V407.2076 397.3745C404.3818 395.9609 405.1426 394.7165 406.2745 394.0299V387.8705C401.726 387.8705 398.0047 391.5918 398.0047 396.1403V399.1436 407.2076 408.7851C398.0047 411.09 396.1191 412.9758 393.8139 412.9758H364.9105Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M413.5569 403.0523H441.4927V412.3706 417.8429H446.9649 471.7327V412.3706H446.9649V403.0523H471.7327V397.58H446.9649V387.6029H441.4927V397.58H413.5569Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M435.9796 418.8923C438.0021 418.8923 439.6416 417.2528 439.6416 415.23 439.6416 413.2074 438.0021 411.5679 435.9796 411.5679 433.957 411.5679 432.3175 413.2074 432.3175 415.23 432.3175 417.2528 433.957 418.8923 435.9796 418.8923" fill="#FFFFFF" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M436.4674 428.112H472.6117V423.447H436.4674C432.8535 423.447 429.1367 420.2158 428.4068 416.7094L427.5037 412.3706H427.3135 422.7389 418.578 414.1239 413.9087L414.2154 419.4266C414.6148 428.6134 422.4583 436.1301 431.6454 436.1301H438.4281C440.8753 443.3261 448.7652 449.2137 455.961 449.2137H495.1837V444.5484H455.961C450.5309 444.5484 444.5651 439.687 442.8445 434.6283L441.769 431.4651H436.8416 431.6454C424.9491 431.4651 419.1667 425.9103 418.8762 419.224L418.7809 417.0356H423.7098L423.8396 417.6598C425.0364 423.4085 430.7187 428.112 436.4674 428.112" fill="#FFFFFF" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M221.1063 378.3702V370.7175C221.1063 368.2284 219.0699 366.192 216.5808 366.192H212.188V369.3019H216.5808C217.3592 369.3019 217.9964 369.9388 217.9964 370.7175V378.3702Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M235.9491 378.3702H238.4371V366.3845H235.9491Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M257.3252 378.3702H260.3976L265.746 366.3845H262.4136L261.0544 369.5859H256.6684L255.3092 366.3845H251.9768ZM260.2335 371.5197 258.8613 374.7518 257.4893 371.5197Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M285.0216 372.3639V366.7884H282.471V372.3639L277.8219 378.7404H280.9151L283.7614 374.1942 286.6073 378.7404H289.671Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M313.4721 378.7404V371.0673C313.4721 368.7137 311.5466 366.7884 309.1932 366.7884H306.9714C304.6181 366.7884 302.6925 368.7137 302.6925 371.0673V378.7404H305.1805V370.9466C305.1805 369.7676 306.1451 368.8033 307.3241 368.8033H308.8406C310.0195 368.8033 310.9842 369.7676 310.9842 370.9466V378.7404Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M335.9673 378.7404H338.4553V369.8328 366.7884H335.9673 334.8972 334.887V366.8074L330.1177 375.6957H329.9596V366.7884H327.4713V375.6957 378.7404H329.9596 330.8136 330.8139L335.5163 369.8328H335.9673Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M377.6709 378.7404H379.5284C382.3432 378.7404 384.6461 376.4375 384.6461 373.6227V371.9061C384.6461 370.6146 384.1611 369.4311 383.3649 368.5283H385.2309V366.7884H379.5284 378.4294 377.6709C374.8561 366.7884 372.5532 369.0913 372.5532 371.9061V373.6227C372.5532 376.4375 374.8561 378.7404 377.6709 378.7404ZM378.5998 376.8301C380.5279 376.8301 382.091 375.0097 382.091 372.7644 382.091 370.5191 380.5279 368.6987 378.5998 368.6987 376.6714 368.6987 375.1084 370.5191 375.1084 372.7644 375.1084 375.0097 376.6714 376.8301 378.5998 376.8301" fill="#FFFFFF" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M398.3156 378.3702H400.8039V366.3845H398.3156Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M420.114 378.3702H424.5987V375.8822H420.114C418.4099 375.8822 417.0067 374.4788 417.0067 372.7746V371.9801C417.0067 370.2759 418.4099 368.8727 420.114 368.8727H424.5987V366.3845H420.114C417.0365 366.3845 414.5187 368.9025 414.5187 371.9801V372.7746C414.5187 375.8522 417.0365 378.3702 420.114 378.3702" fill="#FFFFFF" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M444.477 378.3702H446.9649V366.3845H444.477V371.1237H439.5019V366.3845H437.0139V378.3702H439.5019V373.6117H444.477Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path transform="matrix(1,0,0,-1,-72.1655,461.6343)" d="M466.1527 378.3702H470.6371V375.8822H466.1527C464.7379 375.8822 463.5309 374.9151 463.1613 373.6117H470.6371V371.1237H463.1669C463.5425 369.83 464.745 368.8727 466.1527 368.8727H470.6371V366.3845H466.1527C463.0751 366.3845 460.5571 368.9025 460.5571 371.9801V372.7746C460.5571 375.8522 463.0751 378.3702 466.1527 378.3702" fill="#FFFFFF" fill-rule="evenodd"/>
</g>
</g>
</g>
</g>
</svg>
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