Commit acf37b53 authored by xuchentao's avatar xuchentao

feat: rename article section to news center

parent 4e807b40
Pipeline #418 failed with stages
in 0 seconds
# 嘉运网约车官网与文章后台 # 嘉运网约车官网与资讯中心后台
本项目基于 Astro 5 + Node standalone 构建,公开官网与文章后台运行在同一个服务中。企业资料集中维护在 `src/data/site.ts`文章由 Markdown 文件与内置 CMS 管理。 本项目基于 Astro 5 + Node standalone 构建,公开官网与资讯中心后台运行在同一个服务中。企业资料集中维护在 `src/data/site.ts`,资讯文章由 Markdown 文件与内置 CMS 管理。
## 本地运行 ## 本地运行
...@@ -13,8 +13,8 @@ npm run dev ...@@ -13,8 +13,8 @@ npm run dev
``` ```
- 官网:`http://localhost:4321/` - 官网:`http://localhost:4321/`
- 文章后台:`http://localhost:4321/admin/` - 资讯中心后台:`http://localhost:4321/admin/`
- 跑车指南`http://localhost:4321/articles/` - 资讯中心`http://localhost:4321/articles/`
生产模式: 生产模式:
...@@ -32,7 +32,7 @@ npm start ...@@ -32,7 +32,7 @@ npm start
- 分类:`src/content/categories.json` - 分类:`src/content/categories.json`
- 上传图片:`public/uploads/` - 上传图片:`public/uploads/`
生产环境建议设置 `CMS_DATA_DIR` 指向 release 目录之外的持久化路径。后台支持文章新建、保存草稿、预览、发布、下架、删除、图片上传和分类管理。发布操作会在共享锁内原子构建站点,失败时恢复内容快照。 生产环境建议设置 `CMS_DATA_DIR` 指向 release 目录之外的持久化路径。后台支持资讯新建、保存草稿、预览、发布、下架、删除、图片上传和分类管理。发布操作会在共享锁内原子构建站点,失败时恢复内容快照。
## 必需环境变量 ## 必需环境变量
......
...@@ -49,7 +49,7 @@ $("#logout").addEventListener("click", async () => { await api("/session", { met ...@@ -49,7 +49,7 @@ $("#logout").addEventListener("click", async () => { await api("/session", { met
const statusMap = { const statusMap = {
"new-unsaved": ["尚未保存", "new-draft", "填写完成后先保存草稿,线上网站不会发生变化。"], "new-unsaved": ["尚未保存", "new-draft", "填写完成后先保存草稿,线上网站不会发生变化。"],
"new-draft": ["新建未发布", "new-draft", "这篇文章目前只有草稿版本,发布后才会出现在官网。"], "new-draft": ["新建未发布", "new-draft", "这篇资讯目前只有草稿版本,发布后才会出现在官网。"],
"edited-draft": ["修改未发布", "edited-draft", "草稿已经保存,官网仍在展示上一次发布的版本。"], "edited-draft": ["修改未发布", "edited-draft", "草稿已经保存,官网仍在展示上一次发布的版本。"],
live: ["已上线", "live", "当前编辑内容与官网展示的版本一致。"], live: ["已上线", "live", "当前编辑内容与官网展示的版本一致。"],
}; };
...@@ -67,7 +67,7 @@ async function loadArticles() { ...@@ -67,7 +67,7 @@ async function loadArticles() {
const pending = articles.filter((item) => item.publishStatus !== "live").length; const pending = articles.filter((item) => item.publishStatus !== "live").length;
$("#pending-badge").textContent = `${pending} 篇草稿待发布`; $("#pending-badge").textContent = `${pending} 篇草稿待发布`;
$("#pending-badge").classList.toggle("hidden", pending === 0); $("#pending-badge").classList.toggle("hidden", pending === 0);
if (!articles.length) { list.innerHTML = '<div class="empty">还没有文章,点击“新建文章”开始。</div>'; return; } if (!articles.length) { list.innerHTML = '<div class="empty">还没有资讯,点击“新建资讯”开始。</div>'; return; }
for (const article of articles) { for (const article of articles) {
const card = document.createElement("article"); const card = document.createElement("article");
card.className = "article-row"; card.className = "article-row";
...@@ -80,7 +80,7 @@ async function loadArticles() { ...@@ -80,7 +80,7 @@ async function loadArticles() {
const edit = listAction("编辑", "ghost small", () => openEditor(article.slug)); const edit = listAction("编辑", "ghost small", () => openEditor(article.slug));
const publish = listAction("发布", "primary small", () => publishFromList(article)); const publish = listAction("发布", "primary small", () => publishFromList(article));
publish.disabled = article.publishStatus === "live"; publish.disabled = article.publishStatus === "live";
publish.title = publish.disabled ? "文章当前已上线" : "发布到官网"; publish.title = publish.disabled ? "资讯当前已上线" : "发布到官网";
actions.append(edit, publish, listMoreActions(article)); actions.append(edit, publish, listMoreActions(article));
list.append(card); list.append(card);
} }
...@@ -114,9 +114,9 @@ function listMoreActions(article) { ...@@ -114,9 +114,9 @@ function listMoreActions(article) {
menu.append(listMenuAction("放弃未发布修改", "", () => discardFromList(article))); menu.append(listMenuAction("放弃未发布修改", "", () => discardFromList(article)));
} }
if (["live", "edited-draft"].includes(article.publishStatus)) { if (["live", "edited-draft"].includes(article.publishStatus)) {
menu.append(listMenuAction("下架文章", "danger", () => unpublishFromList(article))); menu.append(listMenuAction("下架资讯", "danger", () => unpublishFromList(article)));
} }
menu.append(listMenuAction("删除文章", "danger", () => deleteFromList(article))); menu.append(listMenuAction("删除资讯", "danger", () => deleteFromList(article)));
details.append(summary, menu); details.append(summary, menu);
details.addEventListener("toggle", () => { details.addEventListener("toggle", () => {
if (!details.open) return; if (!details.open) return;
...@@ -143,7 +143,7 @@ async function discardFromList(article) { ...@@ -143,7 +143,7 @@ async function discardFromList(article) {
} }
async function unpublishFromList(article) { async function unpublishFromList(article) {
const detail = article.publishStatus === "edited-draft" ? "未发布的修改会继续保留为草稿。" : "文章内容会继续保留为草稿。"; const detail = article.publishStatus === "edited-draft" ? "未发布的修改会继续保留为草稿。" : "资讯内容会继续保留为草稿。";
if (!confirm(`确定将《${article.title}》从官网下架吗?${detail}`)) return; if (!confirm(`确定将《${article.title}》从官网下架吗?${detail}`)) return;
try { try {
await triggerBuild(`/articles/${article.slug}/unpublish`, { action: "下架" }); await triggerBuild(`/articles/${article.slug}/unpublish`, { action: "下架" });
...@@ -280,7 +280,7 @@ async function runCategoryBuild(action, method, body) { ...@@ -280,7 +280,7 @@ async function runCategoryBuild(action, method, body) {
} }
function showRenameCategory(row, stat) { function showRenameCategory(row, stat) {
const panel = categoryPanel(row, `重命名后,${stat.count}文章会同步更新分类。`); const panel = categoryPanel(row, `重命名后,${stat.count}资讯会同步更新分类。`);
const form = document.createElement("div"); const form = document.createElement("div");
form.className = "category-rename-form"; form.className = "category-rename-form";
const input = document.createElement("input"); const input = document.createElement("input");
...@@ -314,7 +314,7 @@ function showRenameCategory(row, stat) { ...@@ -314,7 +314,7 @@ function showRenameCategory(row, stat) {
function showDeleteCategory(row, stat) { function showDeleteCategory(row, stat) {
const panel = categoryPanel( const panel = categoryPanel(
row, row,
stat.count ? `该分类下有 ${stat.count}文章,请选择删除后要迁移到的分类。` : "该分类下没有文章,可以直接删除。", stat.count ? `该分类下有 ${stat.count}资讯,请选择删除后要迁移到的分类。` : "该分类下没有资讯,可以直接删除。",
); );
const actions = document.createElement("div"); const actions = document.createElement("div");
actions.className = stat.count ? "replacement-options" : "category-inline-actions"; actions.className = stat.count ? "replacement-options" : "category-inline-actions";
...@@ -349,7 +349,7 @@ function renderCategoryManager(stats) { ...@@ -349,7 +349,7 @@ function renderCategoryManager(stats) {
const strong = document.createElement("strong"); const strong = document.createElement("strong");
strong.textContent = stat.name; strong.textContent = stat.name;
const count = document.createElement("span"); const count = document.createElement("span");
count.textContent = stat.count ? `${stat.count}文章` : "暂无文章"; count.textContent = stat.count ? `${stat.count}资讯` : "暂无资讯";
name.append(strong, count); name.append(strong, count);
const actions = document.createElement("div"); const actions = document.createElement("div");
actions.className = "category-row-actions"; actions.className = "category-row-actions";
...@@ -421,13 +421,13 @@ async function openEditor(slug) { ...@@ -421,13 +421,13 @@ async function openEditor(slug) {
$("#upload-status").textContent = ""; $("#upload-status").textContent = "";
if (slug) { if (slug) {
const article = await api(`/articles/${slug}`); const article = await api(`/articles/${slug}`);
$("#edit-heading").textContent = "编辑文章"; $("#edit-heading").textContent = "编辑资讯";
$("#title").value = article.title; $("#title").value = article.title;
fillCategories(article.category); fillCategories(article.category);
$("#body").value = article.body; $("#body").value = article.body;
setEditorStatus(article.publishStatus); setEditorStatus(article.publishStatus);
} else { } else {
$("#edit-heading").textContent = "新建文章"; $("#edit-heading").textContent = "新建资讯";
$("#title").value = ""; $("#title").value = "";
$("#body").value = ""; $("#body").value = "";
setEditorStatus("new-unsaved"); setEditorStatus("new-unsaved");
...@@ -439,7 +439,7 @@ async function openEditor(slug) { ...@@ -439,7 +439,7 @@ async function openEditor(slug) {
$("#new-article").addEventListener("click", () => openEditor(null)); $("#new-article").addEventListener("click", () => openEditor(null));
$("#back").addEventListener("click", () => { $("#back").addEventListener("click", () => {
if (dirty && !confirm("还有未保存的内容,确定返回文章列表吗?")) return; if (dirty && !confirm("还有未保存的内容,确定返回资讯列表吗?")) return;
setView(false); setView(false);
loadArticles(); loadArticles();
}); });
...@@ -494,14 +494,14 @@ $("#discard").addEventListener("click", async () => { ...@@ -494,14 +494,14 @@ $("#discard").addEventListener("click", async () => {
$("#unpublish").addEventListener("click", async () => { $("#unpublish").addEventListener("click", async () => {
if (!editingSlug) return; if (!editingSlug) return;
const detail = editingStatus === "edited-draft" ? "未发布的修改会继续保留为草稿。" : "文章内容会继续保留为草稿。"; const detail = editingStatus === "edited-draft" ? "未发布的修改会继续保留为草稿。" : "资讯内容会继续保留为草稿。";
if (!confirm(`确定将这篇文章从官网下架吗?${detail}`)) return; if (!confirm(`确定将这篇资讯从官网下架吗?${detail}`)) return;
$("#more-actions").open = false; $("#more-actions").open = false;
$("#message").textContent = "正在下架文章…"; $("#message").textContent = "正在下架资讯…";
try { try {
await triggerBuild(`/articles/${editingSlug}/unpublish`, { action: "下架" }); await triggerBuild(`/articles/${editingSlug}/unpublish`, { action: "下架" });
setEditorStatus("new-draft"); setEditorStatus("new-draft");
$("#message").textContent = "文章已下架,内容已保留为草稿。"; $("#message").textContent = "资讯已下架,内容已保留为草稿。";
} catch (error) { $("#message").textContent = error.message; } } catch (error) { $("#message").textContent = error.message; }
}); });
...@@ -509,10 +509,10 @@ $("#delete-article").addEventListener("click", async () => { ...@@ -509,10 +509,10 @@ $("#delete-article").addEventListener("click", async () => {
if (!editingSlug) return; if (!editingSlug) return;
const prompt = editingStatus === "new-draft" const prompt = editingStatus === "new-draft"
? "确定彻底删除这篇草稿吗?删除后无法恢复。" ? "确定彻底删除这篇草稿吗?删除后无法恢复。"
: "确定彻底删除这篇文章吗?线上版本和草稿都会删除,且无法恢复。"; : "确定彻底删除这篇资讯吗?线上版本和草稿都会删除,且无法恢复。";
if (!confirm(prompt)) return; if (!confirm(prompt)) return;
$("#more-actions").open = false; $("#more-actions").open = false;
$("#message").textContent = "正在删除文章…"; $("#message").textContent = "正在删除资讯…";
try { try {
await triggerBuild(`/articles/${editingSlug}`, { action: "删除", method: "DELETE" }); await triggerBuild(`/articles/${editingSlug}`, { action: "删除", method: "DELETE" });
dirty = false; dirty = false;
...@@ -585,7 +585,7 @@ async function triggerBuild(endpoint, { action = "发布", method = "POST", body ...@@ -585,7 +585,7 @@ async function triggerBuild(endpoint, { action = "发布", method = "POST", body
$("#build-title").textContent = state.ok ? `${action}成功` : `${action}失败`; $("#build-title").textContent = state.ok ? `${action}成功` : `${action}失败`;
$("#build-log").textContent = state.ok ? "静态页面已经生成,官网内容已更新。" : (state.log || `${action}失败,请联系技术人员。`); $("#build-log").textContent = state.ok ? "静态页面已经生成,官网内容已更新。" : (state.log || `${action}失败,请联系技术人员。`);
show("#close-modal"); show("#close-modal");
if (!state.ok) throw new Error(`${action}失败,原文章已恢复,请检查操作日志。`); if (!state.ok) throw new Error(`${action}失败,原资讯已恢复,请检查操作日志。`);
return; return;
} }
} }
...@@ -593,4 +593,3 @@ async function triggerBuild(endpoint, { action = "发布", method = "POST", body ...@@ -593,4 +593,3 @@ async function triggerBuild(endpoint, { action = "发布", method = "POST", body
$("#close-modal").addEventListener("click", () => hide("#build-modal")); $("#close-modal").addEventListener("click", () => hide("#build-modal"));
boot(); boot();
...@@ -95,7 +95,7 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(255,125,65,. ...@@ -95,7 +95,7 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(255,125,65,.
.editor-grid { min-height: 480px; display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } .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; } #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 { 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:empty::before { content: "资讯预览会显示在这里"; color: var(--muted); }
.preview h1, .preview h2, .preview h3 { color: var(--primary-dark); } .preview h1, .preview h2, .preview h3 { color: var(--primary-dark); }
.preview img { max-width: 100%; } .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); } .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); }
......
...@@ -6,7 +6,7 @@ const { items } = Astro.props; ...@@ -6,7 +6,7 @@ const { items } = Astro.props;
--- ---
<div class="article-grid"> <div class="article-grid">
{items.map((article, index) => <article class:list={["article-card", index === 0 && "article-card--featured"]}> {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> <a href={`/articles/${article.data.slug}/`} class="article-card__intro" aria-label={article.data.title}><span>{article.data.category}</span><strong>嘉运资讯</strong><small>NEWS {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> <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>)} </article>)}
</div> </div>
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
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"; 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; } 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 { 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 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}/`); 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> <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 NEWS</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>
...@@ -10,7 +10,7 @@ import Icon from "./Icon.astro"; ...@@ -10,7 +10,7 @@ import Icon from "./Icon.astro";
<p>{site.brand.tagline}</p> <p>{site.brand.tagline}</p>
<div class="footer-tags"><span>合规租售</span><span>透明收费</span><span>一站服务</span></div> <div class="footer-tags"><span>合规租售</span><span>透明收费</span><span>一站服务</span></div>
</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/">服务方案</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><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 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>
......
...@@ -9,7 +9,7 @@ const articleCategories = (await getCategoryNames()).map((label) => ({ ...@@ -9,7 +9,7 @@ const articleCategories = (await getCategoryNames()).map((label) => ({
label, label,
})); }));
const nav = site.nav.map((item) => item.href === "/articles/" const nav = site.nav.map((item) => item.href === "/articles/"
? { ...item, children: [{ href: "/articles/", label: "全部跑车指南" }, ...articleCategories] } ? { ...item, children: [{ href: "/articles/", label: "全部资讯" }, ...articleCategories] }
: item); : item);
--- ---
<div class="utility-bar"> <div class="utility-bar">
......
...@@ -101,8 +101,8 @@ export const site = { ...@@ -101,8 +101,8 @@ export const site = {
}, },
{ {
href: "/articles/", href: "/articles/",
label: "跑车指南", label: "资讯中心",
children: [{ href: "/articles/", label: "全部跑车指南" }], children: [{ href: "/articles/", label: "全部资讯" }],
}, },
{ {
href: "/about/", href: "/about/",
......
...@@ -144,7 +144,7 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA ...@@ -144,7 +144,7 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
if (route === "session") { if (route === "session") {
if (method === "GET") return json({ authed: authed(request) }); if (method === "GET") return json({ authed: authed(request) });
if (method === "POST") { if (method === "POST") {
if (!PASSWORD) throw new StoreError("文章后台尚未配置管理密码", 503); if (!PASSWORD) throw new StoreError("资讯中心后台尚未配置管理密码", 503);
if (!loginAllowed(clientAddress)) throw new StoreError("登录尝试过于频繁,请稍后再试", 429); if (!loginAllowed(clientAddress)) throw new StoreError("登录尝试过于频繁,请稍后再试", 429);
const body = await bodyOf(request); const body = await bodyOf(request);
if (!safeEqual(body.password || "", PASSWORD)) { if (!safeEqual(body.password || "", PASSWORD)) {
...@@ -157,7 +157,7 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA ...@@ -157,7 +157,7 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
if (method === "DELETE") return json({ ok: true }, 200, { "Set-Cookie": cookieHeader(request, "", 0) }); if (method === "DELETE") return json({ ok: true }, 200, { "Set-Cookie": cookieHeader(request, "", 0) });
} }
if (!authed(request)) throw new StoreError("请先登录文章后台", 401); if (!authed(request)) throw new StoreError("请先登录资讯中心后台", 401);
if (route === "categories") { if (route === "categories") {
if (method === "GET") return json({ categories: await getCategoryNames(), stats: await getCategoryStats() }); if (method === "GET") return json({ categories: await getCategoryNames(), stats: await getCategoryStats() });
...@@ -179,8 +179,8 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA ...@@ -179,8 +179,8 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
if (method === "GET") return json(await listWorkingArticles()); if (method === "GET") return json(await listWorkingArticles());
if (method === "POST") { if (method === "POST") {
const body = await bodyOf(request); const body = await bodyOf(request);
if (!String(body.title || "").trim()) throw new StoreError("请填写文章标题"); if (!String(body.title || "").trim()) throw new StoreError("请填写资讯标题");
if (!String(body.body || "").trim()) throw new StoreError("请填写文章正文"); if (!String(body.body || "").trim()) throw new StoreError("请填写资讯正文");
const slug = await generateSlug(body.category); const slug = await generateSlug(body.category);
const article = await withSiteBuildLock(() => writeArticle(slug, body, true)); const article = await withSiteBuildLock(() => writeArticle(slug, body, true));
return json({ ok: true, slug, publishStatus: await getPublishStatus(article) }, 201); return json({ ok: true, slug, publishStatus: await getPublishStatus(article) }, 201);
...@@ -204,8 +204,8 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA ...@@ -204,8 +204,8 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
} }
if (parts.length === 2 && method === "PUT") { if (parts.length === 2 && method === "PUT") {
const body = await bodyOf(request); const body = await bodyOf(request);
if (!String(body.title || "").trim()) throw new StoreError("请填写文章标题"); if (!String(body.title || "").trim()) throw new StoreError("请填写资讯标题");
if (!String(body.body || "").trim()) throw new StoreError("请填写文章正文"); if (!String(body.body || "").trim()) throw new StoreError("请填写资讯正文");
const article = await withSiteBuildLock(() => writeArticle(slug, body)); const article = await withSiteBuildLock(() => writeArticle(slug, body));
return json({ ok: true, slug, publishStatus: await getPublishStatus(article) }); return json({ ok: true, slug, publishStatus: await getPublishStatus(article) });
} }
...@@ -227,7 +227,7 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA ...@@ -227,7 +227,7 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
return json({ error: "接口不存在" }, 404); return json({ error: "接口不存在" }, 404);
} catch (error) { } catch (error) {
if (error instanceof StoreError) return json({ error: error.message, ...error.details }, error.status); if (error instanceof StoreError) return json({ error: error.message, ...error.details }, error.status);
if ((error as NodeJS.ErrnoException).code === "ENOENT") return json({ error: "文章不存在" }, 404); if ((error as NodeJS.ErrnoException).code === "ENOENT") return json({ error: "资讯不存在" }, 404);
console.error(error); console.error(error);
return json({ error: error instanceof Error ? error.message : "服务器处理失败" }, 500); return json({ error: error instanceof Error ? error.message : "服务器处理失败" }, 500);
} }
......
...@@ -4,15 +4,15 @@ ...@@ -4,15 +4,15 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex,nofollow" /> <meta name="robots" content="noindex,nofollow" />
<title>嘉运网约车 · 跑车指南后台</title> <title>嘉运网约车 · 资讯中心后台</title>
<link rel="stylesheet" href="/admin/style.css" /> <link rel="stylesheet" href="/admin/style.css" />
</head> </head>
<body> <body>
<section id="login" class="login hidden"> <section id="login" class="login hidden">
<form id="login-form" class="login-card"> <form id="login-form" class="login-card">
<div class="brand-mark">嘉运网约车</div> <div class="brand-mark">嘉运网约车</div>
<h1>跑车指南后台</h1> <h1>资讯中心后台</h1>
<p>输入管理密码,管理官网文章内容。</p> <p>输入管理密码,管理官网资讯内容。</p>
<label>管理密码<input id="password" type="password" autocomplete="current-password" required /></label> <label>管理密码<input id="password" type="password" autocomplete="current-password" required /></label>
<button class="primary" type="submit">登录后台</button> <button class="primary" type="submit">登录后台</button>
<div id="login-error" class="error"></div> <div id="login-error" class="error"></div>
...@@ -21,7 +21,7 @@ ...@@ -21,7 +21,7 @@
<div id="app" class="hidden"> <div id="app" class="hidden">
<header class="topbar"> <header class="topbar">
<div><strong>嘉运网约车</strong><span>跑车指南后台</span></div> <div><strong>嘉运网约车</strong><span>资讯中心后台</span></div>
<div class="top-actions"> <div class="top-actions">
<span id="pending-badge" class="pending-badge hidden"></span> <span id="pending-badge" class="pending-badge hidden"></span>
<a class="button ghost" href="/articles/" target="_blank" rel="noopener">查看前台</a> <a class="button ghost" href="/articles/" target="_blank" rel="noopener">查看前台</a>
...@@ -30,28 +30,28 @@ ...@@ -30,28 +30,28 @@
</header> </header>
<main id="list-view" class="view"> <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 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> <div id="article-list" class="article-list"></div>
</main> </main>
<main id="edit-view" class="view hidden"> <main id="edit-view" class="view hidden">
<div class="view-head editor-titlebar"> <div class="view-head editor-titlebar">
<div><button id="back" class="back-button" type="button">← 返回文章列表</button><h1 id="edit-heading">编辑文章</h1></div> <div><button id="back" class="back-button" type="button">← 返回资讯列表</button><h1 id="edit-heading">编辑资讯</h1></div>
<span id="editor-status" class="status"></span> <span id="editor-status" class="status"></span>
</div> </div>
<div id="version-note" class="version-note"></div> <div id="version-note" class="version-note"></div>
<form id="edit-form"> <form id="edit-form">
<section class="field-panel"> <section class="field-panel">
<label>文章标题<input id="title" required /></label> <label>资讯标题<input id="title" required /></label>
<div class="field-label"> <div class="field-label">
<span>文章分类</span> <span>资讯分类</span>
<div id="category-select" class="custom-select"> <div id="category-select" class="custom-select">
<input id="category" type="hidden" /> <input id="category" type="hidden" />
<button id="category-trigger" class="select-trigger" type="button" aria-haspopup="listbox" aria-expanded="false"> <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> <span id="category-value"></span><span class="select-chevron" aria-hidden="true"></span>
</button> </button>
<div id="category-menu" class="select-menu hidden"> <div id="category-menu" class="select-menu hidden">
<div id="category-options" class="select-options" role="listbox" aria-label="文章分类"></div> <div id="category-options" class="select-options" role="listbox" aria-label="资讯分类"></div>
<div class="category-create"> <div class="category-create">
<span>添加新分类</span> <span>添加新分类</span>
<div><input id="new-category" maxlength="20" placeholder="输入分类名称" /><button id="add-category" class="primary small" type="button">添加</button></div> <div><input id="new-category" maxlength="20" placeholder="输入分类名称" /><button id="add-category" class="primary small" type="button">添加</button></div>
...@@ -60,12 +60,12 @@ ...@@ -60,12 +60,12 @@
</div> </div>
</div> </div>
</div> </div>
<p class="auto-meta">文章网址、摘要、作者和 SEO 信息将由系统自动生成,无需填写。</p> <p class="auto-meta">资讯网址、摘要、作者和 SEO 信息将由系统自动生成,无需填写。</p>
</section> </section>
<section class="editor-panel"> <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> <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" /> <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> <div class="editor-grid"><textarea id="body" required spellcheck="false" placeholder="在这里撰写资讯正文……"></textarea><div id="preview" class="preview" aria-live="polite"></div></div>
</section> </section>
<div class="edit-actions"> <div class="edit-actions">
<button class="ghost save-button" type="submit">保存草稿</button> <button class="ghost save-button" type="submit">保存草稿</button>
...@@ -74,8 +74,8 @@ ...@@ -74,8 +74,8 @@
<summary>更多操作</summary> <summary>更多操作</summary>
<div class="action-menu"> <div class="action-menu">
<button id="discard" class="menu-action hidden" type="button">放弃未发布修改</button> <button id="discard" class="menu-action hidden" type="button">放弃未发布修改</button>
<button id="unpublish" class="menu-action danger 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> <button id="delete-article" class="menu-action danger hidden" type="button">删除资讯</button>
</div> </div>
</details> </details>
<span id="message"></span> <span id="message"></span>
...@@ -86,7 +86,7 @@ ...@@ -86,7 +86,7 @@
<div id="category-modal" class="modal hidden"> <div id="category-modal" class="modal hidden">
<div class="modal-card category-manager"> <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="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="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> <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> <p id="manager-category-error" class="error"></p>
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
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"; 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 } })); } 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 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 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 }; 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> <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>
...@@ -3,6 +3,6 @@ import { site } from "../data/site"; ...@@ -3,6 +3,6 @@ import { site } from "../data/site";
export const prerender = true; export const prerender = true;
export const GET: APIRoute = ({ site: siteUrl }) => { export const GET: APIRoute = ({ site: siteUrl }) => {
const base = (siteUrl?.href || "https://www.jiayunche.com/").replace(/\/$/, ""); 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`; 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" } }); return new Response(body, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
}; };
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