Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Sign in / Register
Toggle navigation
W
WebAgent
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
xuchentao
WebAgent
Commits
467e65cb
Commit
467e65cb
authored
Jul 21, 2026
by
xuchentao
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: 重构官网模板与文章发布流程
parent
56b080e4
Changes
26
Hide whitespace changes
Inline
Side-by-side
Showing
26 changed files
with
523 additions
and
58 deletions
+523
-58
TEMPLATE.md
astro-template/TEMPLATE.md
+8
-9
astro.config.mjs
astro-template/astro.config.mjs
+17
-1
ai-content-growth.md
astro-template/examples/articles/ai-content-growth.md
+35
-0
product-story-draft.md
astro-template/examples/articles/product-story-draft.md
+23
-0
website-seo-checklist.md
astro-template/examples/articles/website-seo-checklist.md
+39
-0
ai-content-growth.svg
astro-template/examples/images/ai-content-growth.svg
+1
-0
seo-checklist.svg
astro-template/examples/images/seo-checklist.svg
+1
-0
package.json
astro-template/package.json
+1
-0
[...id].astro
astro-template/src/pages/articles/[...id].astro
+2
-0
llms-full.txt.ts
astro-template/src/pages/llms-full.txt.ts
+2
-2
content.css
astro-template/src/styles/content.css
+1
-0
orchestrator.ts
backend/src/agent/orchestrator.ts
+2
-0
article-service.test.ts
backend/src/articles/article-service.test.ts
+29
-2
article-service.ts
backend/src/articles/article-service.ts
+93
-28
build-manager.ts
backend/src/build/build-manager.ts
+8
-0
domain-deployment-service.ts
backend/src/domains/domain-deployment-service.ts
+1
-0
schemas.ts
backend/src/schemas.ts
+5
-0
server.ts
backend/src/server.ts
+30
-3
create-site.ts
backend/src/sites/create-site.ts
+9
-3
site-repository.ts
backend/src/sites/site-repository.ts
+18
-0
site-version-service.ts
backend/src/sites/site-version-service.ts
+16
-0
App.tsx
frontend/src/App.tsx
+140
-8
api.ts
frontend/src/api.ts
+9
-1
styles.css
frontend/src/styles.css
+8
-1
pnpm-lock.yaml
pnpm-lock.yaml
+3
-0
index.ts
shared/src/index.ts
+22
-0
No files found.
astro-template/TEMPLATE.md
View file @
467e65cb
# 网站
修改
规范
# 网站规范
修改前必须读完本文档
。
改代码前读完本文
。
1.
`src/_platform/`
和
`src/content.config.ts`
是平台能力内核,禁止修改。
2.
网站资料和默认 TDK 在
`src/data/site.json`
;首页内容在
`src/data/home.json`
。
3.
文章由平台外部存储,禁止在源码中创建或修改文章文件。产品或服务放
`src/content/products/*.md`
。仅已发布内容公开。
4.
页面、布局、组件和样式可自由修改,但公开页面必须通过
`BaseLayout`
保留 TDK、canonical、robots、OG 和 Schema。
5.
图片只能放
`public/images/`
,链接和资源路径必须兼容
`SITE_BASE_PATH`
。
6.
不得修改依赖、Astro 配置、构建产物或 Git 文件,不得访问站点外目录。
7.
完成后检查改动文件并执行允许的 Git diff 检查;不得部署或提交。
1.
禁改
`src/_platform/`
、
`src/content.config.ts`
、Astro 配置、依赖、产物及 Git 文件。
2.
资料与 TDK 在
`src/data/site.json`
;首页内容在
`src/data/home.json`
。
3.
文章及图片由平台外存,禁止写入源码;正文图片用
`/images/articles/文件名`
。
4.
样式自由;公开页须用
`BaseLayout`
保留 SEO/GEO。
5.
页面图片放
`public/images/`
,路径兼容
`SITE_BASE_PATH`
。
6.
禁止访问站点外目录、部署或提交;完成后检查 diff。
astro-template/astro.config.mjs
View file @
467e65cb
import
{
defineConfig
}
from
"astro/config"
;
const
configuredBase
=
process
.
env
.
SITE_BASE_PATH
||
"/"
;
const
basePath
=
configuredBase
===
"/"
?
"/"
:
`/
${
configuredBase
.
replace
(
/^
\/
+|
\/
+$/g
,
""
)}
/`
;
function
remarkArticleImages
()
{
return
(
tree
)
=>
{
const
visit
=
(
node
)
=>
{
if
(
node
?.
type
===
"image"
&&
typeof
node
.
url
===
"string"
&&
node
.
url
.
startsWith
(
"/images/articles/"
))
{
node
.
url
=
`
${
basePath
}${
node
.
url
.
replace
(
/^
\/
+/
,
""
)}
`
.
replace
(
/
\/
+/g
,
"/"
);
}
if
(
Array
.
isArray
(
node
?.
children
))
node
.
children
.
forEach
(
visit
);
};
visit
(
tree
);
};
}
export
default
defineConfig
({
output
:
"static"
,
site
:
process
.
env
.
SITE_PUBLIC_ORIGIN
||
"https://example.com"
,
base
:
process
.
env
.
SITE_BASE_PATH
||
"/"
,
base
:
configuredBase
,
markdown
:
{
remarkPlugins
:
[
remarkArticleImages
]
},
server
:
{
host
:
"127.0.0.1"
},
});
astro-template/examples/articles/ai-content-growth.md
0 → 100644
View file @
467e65cb
---
title
:
用 AI 建立可持续的官网内容增长机制
summary
:
从选题、撰写到发布,建立一套兼顾品牌表达、SEO 与 GEO 的内容工作流。
status
:
published
author
:
内容团队
cover
:
/images/articles/ai-content-growth.svg
tags
:
-
AI
-
内容增长
-
GEO
publishedAt
:
2026-07-18
updatedAt
:
2026-07-18
seo
:
title
:
AI 官网内容增长指南
description
:
了解如何用 AI 建立稳定、可复用的官网内容生产与优化流程。
ogDescription
:
一套兼顾品牌、SEO 与 GEO 的官网内容增长方法。
ogImage
:
/images/articles/ai-content-growth.svg
noindex
:
false
---
官网内容不应是一次性交付物,而应该成为持续积累的品牌资产。AI 最适合承担资料整理、结构生成和初稿优化,人仍然负责事实、判断与品牌语气。

## 从真实问题开始
优先整理销售、客服和项目交付中反复出现的问题。每篇文章解决一个明确问题,并给出可验证的结论、过程和下一步行动。
## 同时服务搜索与阅读
标题要说明主题,摘要要直接回答核心问题,正文使用清晰的层级。补充作者、发布日期、结构化数据和开放图谱信息,可以帮助搜索引擎与 AI 系统理解内容。
## 保持稳定更新
每月复查旧文章中的数据、链接和产品描述。持续更新比盲目增加低质量页面更有价值。
astro-template/examples/articles/product-story-draft.md
0 → 100644
View file @
467e65cb
---
title
:
如何把产品能力写成客户愿意读的故事
summary
:
一篇用于演示草稿状态、Markdown 编辑和图片插入的示例文章。
status
:
draft
author
:
品牌团队
tags
:
-
产品内容
-
品牌表达
updatedAt
:
2026-07-20
seo
:
noindex
:
true
---
客户真正关心的不是功能列表,而是产品能否解决他的具体问题。先描述使用场景,再解释方案如何工作,最后补充结果与证据。
## 推荐结构
1.
客户处于什么场景;
2.
原有方法为什么不够;
3.
产品如何完成关键任务;
4.
可以获得什么可验证的结果。
这是一篇草稿,不会出现在公开文章列表中。你可以在文章中心继续编辑并发布它。
astro-template/examples/articles/website-seo-checklist.md
0 → 100644
View file @
467e65cb
---
title
:
企业官网上线前的 SEO 检查清单
summary
:
用一份简洁清单检查 TDK、网站地图、Robots、结构化数据和分享摘要。
status
:
published
author
:
官网运营团队
cover
:
/images/articles/seo-checklist.svg
tags
:
-
SEO
-
官网
-
上线检查
publishedAt
:
2026-07-12
updatedAt
:
2026-07-16
seo
:
title
:
企业官网 SEO 上线检查清单
description
:
企业官网发布前必须检查的 SEO 与 GEO 基础配置。
ogDescription
:
发布官网前,用这份清单完成 SEO 与 GEO 基础检查。
ogImage
:
/images/articles/seo-checklist.svg
noindex
:
false
---
网站上线前,先确认每个可索引页面都有独立、准确的标题和摘要。首页、产品页和文章页不应复用同一组 TDK。

## 基础文件
-
网站地图包含所有正式页面,不包含草稿和测试地址。
-
Robots 文件允许抓取正式内容,并指向网站地图。
-
Favicon、Canonical 和分享图片可以正常访问。
## 内容结构
-
每页只有一个清晰的主标题。
-
标题层级连续,链接文字能表达目标内容。
-
图片包含有意义的替代文本。
## GEO 信息
为组织、网站和文章输出 Schema 结构,同时维护
`llms.txt`
与
`llms-full.txt`
,让 AI 系统更容易获取站点的权威摘要与完整内容索引。
astro-template/examples/images/ai-content-growth.svg
0 → 100644
View file @
467e65cb
<svg
xmlns=
"http://www.w3.org/2000/svg"
width=
"1200"
height=
"630"
viewBox=
"0 0 1200 630"
role=
"img"
aria-labelledby=
"t d"
><title
id=
"t"
>
AI 内容增长
</title><desc
id=
"d"
>
紫色渐变背景上的内容增长流程示意
</desc><defs><linearGradient
id=
"g"
x1=
"0"
y1=
"0"
x2=
"1"
y2=
"1"
><stop
stop-color=
"#30108c"
/><stop
offset=
"1"
stop-color=
"#7755ff"
/></linearGradient></defs><rect
width=
"1200"
height=
"630"
rx=
"36"
fill=
"url(#g)"
/><g
fill=
"none"
stroke=
"#fff"
stroke-width=
"10"
opacity=
".9"
><circle
cx=
"260"
cy=
"315"
r=
"82"
/><circle
cx=
"600"
cy=
"315"
r=
"82"
/><circle
cx=
"940"
cy=
"315"
r=
"82"
/><path
d=
"M342 315h176m164 0h176"
/></g><g
fill=
"#fff"
font-family=
"system-ui,sans-serif"
text-anchor=
"middle"
><text
x=
"260"
y=
"330"
font-size=
"38"
>
选题
</text><text
x=
"600"
y=
"330"
font-size=
"38"
>
创作
</text><text
x=
"940"
y=
"330"
font-size=
"38"
>
增长
</text><text
x=
"600"
y=
"115"
font-size=
"54"
font-weight=
"700"
>
AI 内容增长工作流
</text></g></svg>
astro-template/examples/images/seo-checklist.svg
0 → 100644
View file @
467e65cb
<svg
xmlns=
"http://www.w3.org/2000/svg"
width=
"1200"
height=
"630"
viewBox=
"0 0 1200 630"
role=
"img"
aria-labelledby=
"t d"
><title
id=
"t"
>
SEO 上线检查
</title><desc
id=
"d"
>
带有勾选项的搜索优化检查清单
</desc><rect
width=
"1200"
height=
"630"
rx=
"36"
fill=
"#f2efff"
/><rect
x=
"255"
y=
"70"
width=
"690"
height=
"490"
rx=
"32"
fill=
"#fff"
stroke=
"#6f45df"
stroke-width=
"8"
/><g
fill=
"none"
stroke=
"#6f45df"
stroke-width=
"12"
stroke-linecap=
"round"
stroke-linejoin=
"round"
><path
d=
"m335 190 24 26 48-62m-72 151 24 26 48-62m-72 151 24 26 48-62"
/><path
d=
"M470 188h380M470 303h310M470 418h350"
/></g><text
x=
"600"
y=
"125"
fill=
"#2b175f"
font-family=
"system-ui,sans-serif"
font-size=
"45"
font-weight=
"700"
text-anchor=
"middle"
>
官网 SEO 上线检查
</text></svg>
astro-template/package.json
View file @
467e65cb
...
...
@@ -12,6 +12,7 @@
},
"dependencies"
:
{
"@astrojs/check"
:
"^0.9.4"
,
"@types/node"
:
"^22.15.3"
,
"astro"
:
"^5.7.0"
,
"typescript"
:
"^5.8.3"
}
...
...
astro-template/src/pages/articles/[...id].astro
View file @
467e65cb
...
...
@@ -3,6 +3,7 @@ import { render, type CollectionEntry } from "astro:content";
import
ContentLayout
from
"../../layouts/ContentLayout.astro"
;
import
{
entryPath
,
publishedArticles
}
from
"../../_platform/content/public-content"
;
import
{
articleSchema
,
breadcrumbSchema
}
from
"../../_platform/seo/schema"
;
import
{
withBase
}
from
"../../_platform/seo/metadata"
;
export
async
function
getStaticPaths
()
{
return
(
await
publishedArticles
())
.
map
((
entry
)
=>
({
params
:
{
id
:
entry
.
id
.
replace
(
/
\
.
(
md
|
mdx
)
$
/
i
,
""
)
},
props
:
{
entry
}
}));
...
...
@@ -29,6 +30,7 @@ const description = entry.data.seo.ogDescription || entry.data.seo.description |
]}
>
<
h1
>
{
entry
.
data
.
title
}
</
h1
>
{
entry
.
data
.
cover
&&
<
img
class
="
article
-
cover
" src=
{
withBase(entry.data.cover)
}
alt=
{
entry.data.title
}
/>}
<div class="
content
-
meta
">
{entry.data.publishedAt && <time datetime=
{
entry.data.publishedAt.toISOString()}>{entry.data.publishedAt.toLocaleDateString("zh-CN")}</time>
}
{entry.data.author && <span>
{
entry.data.author}</span>
}
...
...
astro-template/src/pages/llms-full.txt.ts
View file @
467e65cb
import
type
{
APIRoute
}
from
"astro"
;
import
{
publishedArticles
,
publishedProducts
}
from
"../_platform/content/public-content"
;
import
{
site
}
from
"../_platform/seo/metadata"
;
import
{
absoluteUrl
,
site
}
from
"../_platform/seo/metadata"
;
const
MAX_CHARACTERS
=
200
_000
;
...
...
@@ -11,7 +11,7 @@ export const GET: APIRoute = async () => {
];
const
sections
=
[
`#
${
site
.
name
}
\n\n>
${
site
.
description
}
`
];
for
(
const
entry
of
entries
)
{
const
body
=
entry
.
body
||
entry
.
data
.
summary
;
const
body
=
(
entry
.
body
||
entry
.
data
.
summary
).
replace
(
/
\]\((\/
images
\/
articles
\/[^
)
]
+
)\)
/g
,
(
_match
,
image
:
string
)
=>
`](
${
absoluteUrl
(
image
)}
)`
)
;
const
section
=
`##
${
entry
.
data
.
title
}
\n\n
${
entry
.
data
.
summary
}
\n\n
${
body
}
`
;
if
(
sections
.
join
(
"
\n\n
"
).
length
+
section
.
length
>
MAX_CHARACTERS
)
break
;
sections
.
push
(
section
);
...
...
astro-template/src/styles/content.css
View file @
467e65cb
...
...
@@ -13,6 +13,7 @@
.content-list
p
{
margin
:
0
0
16px
;
color
:
var
(
--color-text-secondary
);
line-height
:
1.75
;
}
.content-list
a
{
color
:
var
(
--color-primary
);
font-size
:
14px
;
font-weight
:
750
;
}
.content-meta
{
display
:
flex
;
flex-wrap
:
wrap
;
gap
:
10px
20px
;
margin
:
0
0
42px
;
color
:
var
(
--color-text-muted
);
font-size
:
13px
;
}
.article-cover
{
display
:
block
;
width
:
100%
;
height
:
auto
;
margin
:
0
0
28px
;
border-radius
:
22px
;
}
.prose
{
font-size
:
17px
;
line-height
:
1.9
;
}
.prose
h2
,
.prose
h3
{
margin
:
2em
0
.7em
;
line-height
:
1.3
;
}
.prose
p
,
.prose
ul
,
.prose
ol
{
margin
:
0
0
1.35em
;
}
...
...
backend/src/agent/orchestrator.ts
View file @
467e65cb
...
...
@@ -92,6 +92,7 @@ export class AgentOrchestrator {
dist
=
await
this
.
builds
.
build
(
workspace
,
"agent_"
+
runId
,
{
basePath
:
getPublicPreviewUrl
(
run
.
tenantId
,
run
.
siteId
),
indexable
:
false
,
articlesDirectory
:
this
.
sites
.
getArticlesPath
(
run
.
tenantId
,
run
.
siteId
),
articleImagesDirectory
:
this
.
sites
.
getArticleImagesPath
(
run
.
tenantId
,
run
.
siteId
),
});
buildValidation
=
{
passed
:
true
,
buildExitCode
:
0
,
durationMs
:
Date
.
now
()
-
buildStarted
,
output
:
"网站构建通过"
};
this
.
runs
.
transition
(
runId
,
"validating_build"
,
{
validation
:
buildValidation
});
...
...
@@ -107,6 +108,7 @@ export class AgentOrchestrator {
const
latest
=
this
.
requireRun
(
runId
);
const
summary
=
(
latest
.
summary
||
"更新网站"
).
replace
(
/
\s
+/g
,
" "
).
slice
(
0
,
120
);
await
this
.
builds
.
publishPreview
(
run
.
tenantId
,
run
.
siteId
,
dist
);
await
this
.
sites
.
markArticlesCompiled
(
run
.
tenantId
,
run
.
siteId
);
await
this
.
sites
.
update
(
run
.
tenantId
,
run
.
siteId
,
{
status
:
"ready"
,
previewCommit
:
baseCommit
,
draftBaseCommit
:
baseCommit
,
draftUpdatedAt
:
new
Date
().
toISOString
(),
draftSummary
:
summary
,
...
...
backend/src/articles/article-service.test.ts
View file @
467e65cb
...
...
@@ -15,6 +15,8 @@ test("article mutations use external content storage without creating Git-manage
const
root
=
await
mkdtemp
(
path
.
join
(
os
.
tmpdir
(),
"webagent-articles-"
));
const
project
=
path
.
join
(
root
,
"project"
);
const
articles
=
path
.
join
(
root
,
"content"
,
"articles"
);
const
images
=
path
.
join
(
root
,
"content"
,
"images"
);
const
articleState
=
path
.
join
(
root
,
"metadata"
,
"articles.json"
);
const
dist
=
path
.
join
(
root
,
"dist"
);
await
mkdir
(
path
.
join
(
project
,
"src"
),
{
recursive
:
true
});
await
writeFile
(
path
.
join
(
project
,
"src"
,
"content.config.ts"
),
"export const collections = {};
\n
"
,
"utf8"
);
...
...
@@ -26,6 +28,7 @@ test("article mutations use external content storage without creating Git-manage
createdAt
:
now
,
updatedAt
:
now
,
};
let
buildArticlesDirectory
=
""
;
let
buildCalls
=
0
;
const
sites
=
{
get
:
async
()
=>
site
,
update
:
async
(
_tenantId
:
string
,
_siteId
:
string
,
values
:
Partial
<
SiteInfo
>
)
=>
{
...
...
@@ -35,9 +38,18 @@ test("article mutations use external content storage without creating Git-manage
getProjectPath
:
()
=>
project
,
getDraftPath
:
()
=>
path
.
join
(
root
,
"draft"
),
getArticlesPath
:
()
=>
articles
,
getArticleImagesPath
:
()
=>
images
,
getArticleStatePath
:
()
=>
articleState
,
markArticlesCompiled
:
async
()
=>
{
const
compiledAt
=
new
Date
().
toISOString
();
await
mkdir
(
path
.
dirname
(
articleState
),
{
recursive
:
true
});
await
writeFile
(
articleState
,
JSON
.
stringify
({
pendingSlugs
:
[],
lastCompiledAt
:
compiledAt
}),
"utf8"
);
return
compiledAt
;
},
}
as
unknown
as
SiteRepository
;
const
builds
=
{
build
:
async
(
_workspace
:
string
,
_taskId
:
string
,
context
:
SiteBuildContext
)
=>
{
buildCalls
+=
1
;
buildArticlesDirectory
=
context
.
articlesDirectory
||
""
;
assert
.
match
(
await
readFile
(
path
.
join
(
buildArticlesDirectory
,
"hello.md"
),
"utf8"
),
/title: Hello/
);
await
mkdir
(
dist
,
{
recursive
:
true
});
...
...
@@ -56,8 +68,23 @@ test("article mutations use external content storage without creating Git-manage
assert
.
equal
(
result
.
article
?.
slug
,
"hello"
);
assert
.
equal
(
await
exists
(
path
.
join
(
articles
,
"hello.md"
)),
true
);
assert
.
equal
(
await
exists
(
path
.
join
(
project
,
"src"
,
"content"
,
"articles"
)),
false
);
assert
.
notEqual
(
buildArticlesDirectory
,
articles
);
assert
.
match
(
buildArticlesDirectory
,
/
\.
articles-next-/
);
assert
.
equal
(
buildCalls
,
0
);
assert
.
equal
((
await
new
ArticleService
(
sites
,
builds
,
previews
).
list
(
"tenant_test"
,
"site_test"
)).
pendingBuild
,
true
);
const
published
=
await
new
ArticleService
(
sites
,
builds
,
previews
).
update
(
"tenant_test"
,
"site_test"
,
"hello"
,
{
...
article
,
status
:
"published"
});
assert
.
equal
(
published
.
article
?.
status
,
"published"
);
assert
.
match
(
await
readFile
(
path
.
join
(
articles
,
"hello.md"
),
"utf8"
),
/status: published/
);
assert
.
equal
(
buildCalls
,
0
);
await
new
ArticleService
(
sites
,
builds
,
previews
).
compilePreview
(
"tenant_test"
,
"site_test"
);
assert
.
equal
(
buildCalls
,
1
);
assert
.
equal
(
buildArticlesDirectory
,
articles
);
assert
.
equal
((
await
new
ArticleService
(
sites
,
builds
,
previews
).
list
(
"tenant_test"
,
"site_test"
)).
pendingBuild
,
false
);
const
uploaded
=
await
new
ArticleService
(
sites
,
builds
,
previews
).
uploadImage
(
"tenant_test"
,
"site_test"
,
{
filename
:
"pixel.png"
,
dataUrl
:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
,
});
assert
.
match
(
uploaded
.
path
,
/^
\/
images
\/
articles
\/
pixel-
[
a-f0-9
]{10}\.
png$/
);
assert
.
equal
(
await
exists
(
path
.
join
(
images
,
uploaded
.
filename
)),
true
);
assert
.
equal
(
await
exists
(
path
.
join
(
project
,
"public"
,
"images"
,
"articles"
,
uploaded
.
filename
)),
false
);
}
finally
{
await
rm
(
root
,
{
recursive
:
true
,
force
:
true
});
}
...
...
backend/src/articles/article-service.ts
View file @
467e65cb
import
crypto
from
"node:crypto"
;
import
path
from
"node:path"
;
import
{
access
,
cp
,
mkdir
,
readFile
,
readdir
,
rename
,
rm
,
writeFile
}
from
"node:fs/promises"
;
import
type
{
Article
Documen
t
,
ArticleInput
,
ArticleListResult
,
ArticleMutationResult
,
ArticleSeoInput
,
SiteInfo
}
from
"@webagent/shared"
;
import
type
{
Article
CompileResult
,
ArticleDocument
,
ArticleImageUploadInput
,
ArticleImageUploadResul
t
,
ArticleInput
,
ArticleListResult
,
ArticleMutationResult
,
ArticleSeoInput
,
SiteInfo
}
from
"@webagent/shared"
;
import
{
parse
as
parseYaml
,
stringify
as
stringifyYaml
}
from
"yaml"
;
import
{
BuildError
,
BuildManager
}
from
"../build/build-manager.js"
;
import
{
getPublicPreviewUrl
}
from
"../config.js"
;
...
...
@@ -10,6 +10,7 @@ import { articleInputSchema } from "../schemas.js";
import
{
SiteRepository
}
from
"../sites/site-repository.js"
;
type
Frontmatter
=
Record
<
string
,
unknown
>
;
type
ArticleBuildState
=
{
pendingSlugs
:
string
[];
lastCompiledAt
?:
string
};
export
class
ArticleService
{
private
readonly
queues
=
new
Map
<
string
,
Promise
<
unknown
>>
();
...
...
@@ -22,16 +23,21 @@ export class ArticleService {
async
list
(
tenantId
:
string
,
siteId
:
string
):
Promise
<
ArticleListResult
>
{
const
site
=
await
this
.
sites
.
get
(
tenantId
,
siteId
);
if
(
!
await
this
.
supported
(
this
.
sites
.
getProjectPath
(
tenantId
,
siteId
)))
return
{
supported
:
false
,
articles
:
[]
};
if
(
!
await
this
.
supported
(
this
.
sites
.
getProjectPath
(
tenantId
,
siteId
)))
return
{
supported
:
false
,
articles
:
[]
,
pendingBuild
:
false
,
pendingCount
:
0
};
const
directory
=
this
.
sites
.
getArticlesPath
(
tenantId
,
siteId
);
await
mkdir
(
directory
,
{
recursive
:
true
});
const
files
=
(
await
readdir
(
directory
,
{
withFileTypes
:
true
}))
.
filter
((
entry
)
=>
entry
.
isFile
()
&&
!
entry
.
name
.
startsWith
(
"_"
)
&&
/
\.
md$/i
.
test
(
entry
.
name
))
.
map
((
entry
)
=>
entry
.
name
);
const
articles
=
await
Promise
.
all
(
files
.
map
((
file
)
=>
this
.
readArticleFile
(
path
.
join
(
directory
,
file
),
file
.
replace
(
/
\.
md$/i
,
""
))));
const
state
=
await
this
.
readState
(
tenantId
,
siteId
);
const
pending
=
new
Set
(
state
.
pendingSlugs
);
return
{
supported
:
true
,
articles
:
articles
.
map
(({
body
:
_body
,
...
article
})
=>
article
).
sort
((
a
,
b
)
=>
{
pendingBuild
:
pending
.
size
>
0
,
pendingCount
:
pending
.
size
,
lastCompiledAt
:
state
.
lastCompiledAt
,
articles
:
articles
.
map
(({
body
:
_body
,
...
article
})
=>
({
...
article
,
pendingBuild
:
pending
.
has
(
article
.
slug
)
})).
sort
((
a
,
b
)
=>
{
const
left
=
a
.
updatedAt
||
a
.
publishedAt
||
""
;
const
right
=
b
.
updatedAt
||
b
.
publishedAt
||
""
;
return
right
.
localeCompare
(
left
)
||
a
.
title
.
localeCompare
(
b
.
title
,
"zh-CN"
);
...
...
@@ -52,7 +58,7 @@ export class ArticleService {
if
(
await
this
.
exists
(
destination
))
throw
Object
.
assign
(
new
Error
(
"Slug 已存在,请更换后再保存"
),
{
statusCode
:
409
});
await
mkdir
(
path
.
dirname
(
destination
),
{
recursive
:
true
});
await
writeFile
(
destination
,
serializeArticle
(
input
),
"utf8"
);
return
{
article
:
input
,
summary
:
`新增文章:
${
input
.
title
}
`
};
return
{
article
:
input
,
changedSlugs
:
[
input
.
slug
]
};
});
}
...
...
@@ -70,7 +76,7 @@ export class ArticleService {
if
(
previous
===
next
)
throw
Object
.
assign
(
new
Error
(
"文章没有实际变化"
),
{
statusCode
:
409
});
await
writeFile
(
destination
,
next
,
"utf8"
);
if
(
destination
!==
source
)
await
rm
(
source
,
{
force
:
true
});
return
{
article
:
input
,
summary
:
`更新文章:
${
input
.
title
}
`
};
return
{
article
:
input
,
changedSlugs
:
[
input
.
slug
]
};
});
}
...
...
@@ -81,7 +87,7 @@ export class ArticleService {
if
(
!
await
this
.
exists
(
source
))
throw
Object
.
assign
(
new
Error
(
"文章不存在"
),
{
statusCode
:
404
});
const
article
=
await
this
.
readArticleFile
(
source
,
slug
);
await
rm
(
source
,
{
force
:
true
});
return
{
deletedSlug
:
slug
,
summary
:
`删除文章:
${
article
.
title
}
`
};
return
{
deletedSlug
:
slug
,
changedSlugs
:
[
slug
]
};
});
}
...
...
@@ -90,11 +96,25 @@ export class ArticleService {
return
this
.
create
(
tenantId
,
siteId
,
articleInputSchema
.
parse
(
parsed
));
}
private
async
mutate
(
tenantId
:
string
,
siteId
:
string
,
operation
:
(
articlesDirectory
:
string
)
=>
Promise
<
{
article
?:
ArticleInput
;
deletedSlug
?:
string
;
summary
:
string
}
>
,
):
Promise
<
ArticleMutationResult
>
{
async
uploadImage
(
tenantId
:
string
,
siteId
:
string
,
input
:
ArticleImageUploadInput
):
Promise
<
ArticleImageUploadResult
>
{
await
this
.
sites
.
get
(
tenantId
,
siteId
);
await
this
.
assertSupported
(
this
.
sites
.
getProjectPath
(
tenantId
,
siteId
));
const
encoded
=
input
.
dataUrl
.
slice
(
input
.
dataUrl
.
indexOf
(
","
)
+
1
);
const
content
=
Buffer
.
from
(
encoded
,
"base64"
);
if
(
!
content
.
length
||
content
.
length
>
5
*
1024
*
1024
)
{
throw
Object
.
assign
(
new
Error
(
"图片大小必须在 5MB 以内"
),
{
statusCode
:
400
});
}
const
extension
=
imageExtension
(
content
);
if
(
!
extension
)
throw
Object
.
assign
(
new
Error
(
"图片内容无效,仅支持 PNG、JPEG、WebP 或 GIF"
),
{
statusCode
:
400
});
const
stem
=
slugify
(
path
.
basename
(
input
.
filename
,
path
.
extname
(
input
.
filename
))).
slice
(
0
,
60
);
const
filename
=
`
${
stem
}
-
${
crypto
.
randomBytes
(
5
).
toString
(
"hex"
)}
.
${
extension
}
`
;
const
directory
=
this
.
sites
.
getArticleImagesPath
(
tenantId
,
siteId
);
await
mkdir
(
directory
,
{
recursive
:
true
});
await
writeFile
(
path
.
join
(
directory
,
filename
),
content
);
return
{
path
:
`/images/articles/
${
filename
}
`
,
filename
,
size
:
content
.
length
};
}
async
compilePreview
(
tenantId
:
string
,
siteId
:
string
):
Promise
<
ArticleCompileResult
>
{
return
this
.
withLock
(
`
${
tenantId
}
/
${
siteId
}
`
,
async
()
=>
{
const
site
=
await
this
.
sites
.
get
(
tenantId
,
siteId
);
if
(
site
.
status
===
"building"
)
throw
Object
.
assign
(
new
Error
(
"网站正在执行其他构建,请稍后重试"
),
{
statusCode
:
409
});
...
...
@@ -105,12 +125,45 @@ export class ArticleService {
const
workspace
=
await
this
.
readWorkspace
(
site
);
await
this
.
assertSupported
(
project
);
const
articlesDirectory
=
this
.
sites
.
getArticlesPath
(
tenantId
,
siteId
);
await
this
.
sites
.
update
(
tenantId
,
siteId
,
{
status
:
"building"
,
lastError
:
undefined
});
try
{
const
dist
=
await
this
.
builds
.
build
(
workspace
,
`articles_
${
crypto
.
randomBytes
(
4
).
toString
(
"hex"
)}
`
,
{
basePath
:
getPublicPreviewUrl
(
tenantId
,
siteId
),
indexable
:
false
,
articlesDirectory
,
articleImagesDirectory
:
this
.
sites
.
getArticleImagesPath
(
tenantId
,
siteId
),
});
const
published
=
await
this
.
builds
.
publishPreview
(
tenantId
,
siteId
,
dist
);
await
this
.
previews
.
start
(
tenantId
,
siteId
,
published
,
site
.
previewPort
);
const
compiledAt
=
await
this
.
sites
.
markArticlesCompiled
(
tenantId
,
siteId
);
await
this
.
sites
.
update
(
tenantId
,
siteId
,
{
status
:
"ready"
,
previewCommit
:
site
.
draftBaseCommit
||
site
.
currentCommit
,
environmentVersion
:
4
,
lastError
:
undefined
,
});
return
{
previewUrl
:
site
.
previewUrl
,
compiledAt
,
articleCount
:
await
this
.
articleCount
(
articlesDirectory
)
};
}
catch
(
error
)
{
const
details
=
error
instanceof
BuildError
?
error
.
output
.
slice
(
-
3000
)
:
error
instanceof
Error
?
error
.
message
:
String
(
error
);
await
this
.
sites
.
update
(
tenantId
,
siteId
,
{
status
:
site
.
previewCommit
?
"ready"
:
"failed"
,
lastError
:
details
});
throw
error
;
}
});
}
private
async
mutate
(
tenantId
:
string
,
siteId
:
string
,
operation
:
(
articlesDirectory
:
string
)
=>
Promise
<
{
article
?:
ArticleInput
;
deletedSlug
?:
string
;
changedSlugs
:
string
[]
}
>
,
):
Promise
<
ArticleMutationResult
>
{
return
this
.
withLock
(
`
${
tenantId
}
/
${
siteId
}
`
,
async
()
=>
{
const
site
=
await
this
.
sites
.
get
(
tenantId
,
siteId
);
if
(
site
.
status
===
"building"
)
throw
Object
.
assign
(
new
Error
(
"网站正在执行其他构建,请稍后重试"
),
{
statusCode
:
409
});
const
project
=
this
.
sites
.
getProjectPath
(
tenantId
,
siteId
);
await
this
.
assertSupported
(
project
);
const
articlesDirectory
=
this
.
sites
.
getArticlesPath
(
tenantId
,
siteId
);
const
suffix
=
crypto
.
randomBytes
(
5
).
toString
(
"hex"
);
const
stagingDirectory
=
path
.
join
(
path
.
dirname
(
articlesDirectory
),
`.articles-next-
${
suffix
}
`
);
const
previousDirectory
=
path
.
join
(
path
.
dirname
(
articlesDirectory
),
`.articles-previous-
${
suffix
}
`
);
await
mkdir
(
articlesDirectory
,
{
recursive
:
true
});
await
cp
(
articlesDirectory
,
stagingDirectory
,
{
recursive
:
true
});
let
changed
:
{
article
?:
ArticleInput
;
deletedSlug
?:
string
;
summary
:
string
};
let
changed
:
{
article
?:
ArticleInput
;
deletedSlug
?:
string
;
changedSlugs
:
string
[]
};
try
{
changed
=
await
operation
(
stagingDirectory
);
}
catch
(
error
)
{
...
...
@@ -118,35 +171,22 @@ export class ArticleService {
throw
error
;
}
await
this
.
sites
.
update
(
tenantId
,
siteId
,
{
status
:
"building"
,
lastError
:
undefined
});
let
swapped
=
false
;
try
{
const
dist
=
await
this
.
builds
.
build
(
workspace
,
`article_
${
crypto
.
randomBytes
(
4
).
toString
(
"hex"
)}
`
,
{
basePath
:
getPublicPreviewUrl
(
tenantId
,
siteId
),
indexable
:
false
,
articlesDirectory
:
stagingDirectory
,
});
await
rename
(
articlesDirectory
,
previousDirectory
);
await
rename
(
stagingDirectory
,
articlesDirectory
);
swapped
=
true
;
const
published
=
await
this
.
builds
.
publishPreview
(
tenantId
,
siteId
,
dist
);
await
this
.
previews
.
start
(
tenantId
,
siteId
,
published
,
site
.
previewPort
);
await
this
.
sites
.
update
(
tenantId
,
siteId
,
{
status
:
"ready"
,
previewCommit
:
site
.
draftBaseCommit
||
site
.
currentCommit
,
environmentVersion
:
4
,
lastError
:
undefined
,
});
const
state
=
await
this
.
readState
(
tenantId
,
siteId
);
await
this
.
writeState
(
tenantId
,
siteId
,
{
...
state
,
pendingSlugs
:
[...
new
Set
([...
state
.
pendingSlugs
,
...
changed
.
changedSlugs
])]
});
const
article
=
changed
.
article
?
await
this
.
readArticleFile
(
this
.
articlePath
(
articlesDirectory
,
changed
.
article
.
slug
),
changed
.
article
.
slug
)
:
undefined
;
return
{
article
,
deletedSlug
:
changed
.
deletedSlug
,
previewUrl
:
site
.
previewUrl
};
return
{
article
,
deletedSlug
:
changed
.
deletedSlug
,
previewUrl
:
site
.
previewUrl
,
pendingBuild
:
true
};
}
catch
(
error
)
{
if
(
swapped
)
{
await
rm
(
articlesDirectory
,
{
recursive
:
true
,
force
:
true
});
await
rename
(
previousDirectory
,
articlesDirectory
).
catch
(()
=>
undefined
);
}
const
details
=
error
instanceof
BuildError
?
error
.
output
.
slice
(
-
3000
)
:
error
instanceof
Error
?
error
.
message
:
String
(
error
);
await
this
.
sites
.
update
(
tenantId
,
siteId
,
{
status
:
site
.
previewCommit
?
"ready"
:
"failed"
,
lastError
:
details
,
});
throw
error
;
}
finally
{
await
Promise
.
all
([
...
...
@@ -175,6 +215,23 @@ export class ArticleService {
}
}
private
async
readState
(
tenantId
:
string
,
siteId
:
string
):
Promise
<
ArticleBuildState
>
{
return
readFile
(
this
.
sites
.
getArticleStatePath
(
tenantId
,
siteId
),
"utf8"
)
.
then
((
raw
)
=>
JSON
.
parse
(
raw
)
as
ArticleBuildState
)
.
then
((
state
)
=>
({
pendingSlugs
:
Array
.
isArray
(
state
.
pendingSlugs
)
?
state
.
pendingSlugs
.
filter
((
slug
)
=>
typeof
slug
===
"string"
)
:
[],
lastCompiledAt
:
state
.
lastCompiledAt
}))
.
catch
(()
=>
({
pendingSlugs
:
[]
}));
}
private
async
writeState
(
tenantId
:
string
,
siteId
:
string
,
state
:
ArticleBuildState
):
Promise
<
void
>
{
const
destination
=
this
.
sites
.
getArticleStatePath
(
tenantId
,
siteId
);
await
mkdir
(
path
.
dirname
(
destination
),
{
recursive
:
true
});
const
temporary
=
`
${
destination
}
.tmp`
;
await
writeFile
(
temporary
,
JSON
.
stringify
(
state
,
null
,
2
)
+
"
\n
"
,
"utf8"
);
await
rename
(
temporary
,
destination
);
}
private
async
articleCount
(
directory
:
string
):
Promise
<
number
>
{
return
(
await
readdir
(
directory
,
{
withFileTypes
:
true
})).
filter
((
entry
)
=>
entry
.
isFile
()
&&
/
\.
md$/i
.
test
(
entry
.
name
)).
length
;
}
private
articlePath
(
directory
:
string
,
slug
:
string
):
string
{
this
.
assertSlug
(
slug
);
return
path
.
join
(
directory
,
`
${
slug
}
.md`
);
}
private
async
supported
(
workspace
:
string
):
Promise
<
boolean
>
{
return
this
.
exists
(
path
.
join
(
workspace
,
"src"
,
"content.config.ts"
));
...
...
@@ -278,3 +335,11 @@ function wordCount(body: string): number {
const
han
=
textBody
.
match
(
/
[\u
3400-
\u
9fff
]
/g
)?.
length
||
0
;
return
latin
+
han
;
}
function
imageExtension
(
content
:
Buffer
):
"png"
|
"jpg"
|
"webp"
|
"gif"
|
undefined
{
if
(
content
.
subarray
(
0
,
8
).
equals
(
Buffer
.
from
([
0x89
,
0x50
,
0x4e
,
0x47
,
0x0d
,
0x0a
,
0x1a
,
0x0a
])))
return
"png"
;
if
(
content
[
0
]
===
0xff
&&
content
[
1
]
===
0xd8
&&
content
[
2
]
===
0xff
)
return
"jpg"
;
if
(
content
.
subarray
(
0
,
6
).
toString
(
"ascii"
)
===
"GIF87a"
||
content
.
subarray
(
0
,
6
).
toString
(
"ascii"
)
===
"GIF89a"
)
return
"gif"
;
if
(
content
.
subarray
(
0
,
4
).
toString
(
"ascii"
)
===
"RIFF"
&&
content
.
subarray
(
8
,
12
).
toString
(
"ascii"
)
===
"WEBP"
)
return
"webp"
;
return
undefined
;
}
backend/src/build/build-manager.ts
View file @
467e65cb
...
...
@@ -13,6 +13,7 @@ export interface SiteBuildContext {
publicOrigin
?:
string
;
indexable
?:
boolean
;
articlesDirectory
?:
string
;
articleImagesDirectory
?:
string
;
}
export
class
BuildManager
{
...
...
@@ -26,6 +27,7 @@ export class BuildManager {
const
options
=
typeof
context
===
"string"
?
{
basePath
:
context
}
:
context
;
const
publicOrigin
=
(
options
.
publicOrigin
||
config
.
frontendOrigin
).
replace
(
/
\/
$/
,
""
);
if
(
options
.
articlesDirectory
)
await
mkdir
(
options
.
articlesDirectory
,
{
recursive
:
true
});
if
(
options
.
articleImagesDirectory
)
await
mkdir
(
options
.
articleImagesDirectory
,
{
recursive
:
true
});
try
{
const
install
=
await
execa
(
"pnpm"
,
[
"install"
,
"--store-dir"
,
runtimePaths
.
pnpmStore
,
"--prefer-offline"
],
{
cwd
:
projectPath
,
...
...
@@ -44,6 +46,12 @@ export class BuildManager {
},
});
output
+=
build
.
stdout
+
"
\n
"
+
build
.
stderr
;
if
(
options
.
articleImagesDirectory
)
{
const
target
=
path
.
join
(
projectPath
,
"dist"
,
"images"
,
"articles"
);
await
rm
(
target
,
{
recursive
:
true
,
force
:
true
});
await
mkdir
(
path
.
dirname
(
target
),
{
recursive
:
true
});
await
cp
(
options
.
articleImagesDirectory
,
target
,
{
recursive
:
true
});
}
await
writeFile
(
path
.
join
(
runtimePaths
.
logs
,
taskId
+
".log"
),
output
,
"utf8"
);
return
path
.
join
(
projectPath
,
"dist"
);
}
catch
(
error
)
{
...
...
backend/src/domains/domain-deployment-service.ts
View file @
467e65cb
...
...
@@ -46,6 +46,7 @@ export class DomainDeploymentService {
const
dist
=
await
this
.
builds
.
build
(
workspace
,
taskId
,
{
basePath
:
"/"
,
publicOrigin
:
primary
?
`https://
${
primary
.
hostname
}
`
:
undefined
,
indexable
:
true
,
articlesDirectory
:
this
.
sites
.
getArticlesPath
(
tenantId
,
siteId
),
articleImagesDirectory
:
this
.
sites
.
getArticleImagesPath
(
tenantId
,
siteId
),
});
await
this
.
builds
.
publishCustomDomain
(
tenantId
,
siteId
,
dist
);
await
this
.
domains
.
updateSite
(
tenantId
,
siteId
,
(
domain
)
=>
domain
.
ownershipStatus
===
"verified"
...
...
backend/src/schemas.ts
View file @
467e65cb
...
...
@@ -60,6 +60,11 @@ export const articleImportSchema = z.object({
markdown
:
z
.
string
().
min
(
1
).
max
(
256
*
1024
),
});
export
const
articleImageUploadSchema
=
z
.
object
({
filename
:
z
.
string
().
trim
().
min
(
1
).
max
(
180
),
dataUrl
:
z
.
string
().
max
(
7
*
1024
*
1024
).
regex
(
/^data:image
\/(?:
png|jpeg|webp|gif
)
;base64,/i
,
"仅支持 PNG、JPEG、WebP 或 GIF 图片"
),
});
export
const
addDomainSchema
=
z
.
object
({
hostname
:
z
.
string
().
trim
().
min
(
4
,
"请输入完整域名"
).
max
(
253
),
});
...
...
backend/src/server.ts
View file @
467e65cb
...
...
@@ -12,7 +12,7 @@ import { config, runtimePaths } from "./config.js";
import
{
GitManager
}
from
"./git/git-manager.js"
;
import
{
PreviewProcessManager
}
from
"./preview/preview-process-manager.js"
;
import
{
addDomainSchema
,
agentSettingsSchema
,
chatSchema
,
createSiteSchema
,
createTenantSchema
,
loginSchema
,
resetPasswordSchema
,
saveDraftSchema
,
addDomainSchema
,
agentSettingsSchema
,
articleImageUploadSchema
,
articleImportSchema
,
articleInputSchema
,
chatSchema
,
createSiteSchema
,
createTenantSchema
,
loginSchema
,
resetPasswordSchema
,
saveDraftSchema
,
tenantStatusSchema
,
updateDomainSchema
,
versionCommitSchema
,
}
from
"./schemas.js"
;
import
{
CreateSiteService
}
from
"./sites/create-site.js"
;
...
...
@@ -24,12 +24,13 @@ import { DomainRepository } from "./domains/domain-repository.js";
import
{
DomainRoutingConfig
}
from
"./domains/domain-routing-config.js"
;
import
{
DomainService
}
from
"./domains/domain-service.js"
;
import
{
createDomainProvider
}
from
"./domains/domain-provider.js"
;
import
{
ArticleService
}
from
"./articles/article-service.js"
;
declare
module
"fastify"
{
interface
FastifyRequest
{
auth
?:
SessionInfo
}
}
const
app
=
Fastify
({
logger
:
{
level
:
process
.
env
.
LOG_LEVEL
||
"info"
},
bodyLimit
:
1024
*
1024
});
const
app
=
Fastify
({
logger
:
{
level
:
process
.
env
.
LOG_LEVEL
||
"info"
},
bodyLimit
:
8
*
1024
*
1024
});
await
app
.
register
(
cors
,
{
origin
:
[
config
.
frontendOrigin
,
"http://127.0.0.1:5173"
]
});
const
auth
=
new
AuthRepository
();
...
...
@@ -48,6 +49,7 @@ const domainProvider = createDomainProvider();
const
domainDeployments
=
new
DomainDeploymentService
(
sites
,
domainRepository
,
git
,
builds
,
domainRouting
);
const
domainService
=
new
DomainService
(
sites
,
domainRepository
,
domainRouting
,
domainProvider
);
const
siteLifecycle
=
new
SiteLifecycleService
(
sites
,
previews
,
domainRepository
,
domainRouting
,
domainProvider
);
const
articles
=
new
ArticleService
(
sites
,
builds
,
previews
);
app
.
addHook
(
"onRequest"
,
async
(
request
,
reply
)
=>
{
if
(
!
request
.
url
.
startsWith
(
"/api/"
)
||
request
.
url
===
"/api/health"
||
request
.
url
===
"/api/login"
)
return
;
...
...
@@ -164,6 +166,31 @@ app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/publish", async (re
void
domainDeployments
.
schedule
(
tenantId
,
request
.
params
.
siteId
,
result
.
commit
).
catch
((
error
)
=>
app
.
log
.
error
(
error
));
return
result
;
});
app
.
get
<
{
Params
:
{
siteId
:
string
}
}
>
(
"/api/sites/:siteId/articles"
,
async
(
request
)
=>
(
articles
.
list
(
requireTenant
(
request
.
auth
),
request
.
params
.
siteId
)
));
app
.
get
<
{
Params
:
{
siteId
:
string
;
slug
:
string
}
}
>
(
"/api/sites/:siteId/articles/:slug"
,
async
(
request
)
=>
(
articles
.
get
(
requireTenant
(
request
.
auth
),
request
.
params
.
siteId
,
request
.
params
.
slug
)
));
app
.
post
<
{
Params
:
{
siteId
:
string
}
}
>
(
"/api/sites/:siteId/articles"
,
async
(
request
,
reply
)
=>
(
reply
.
code
(
201
).
send
(
await
articles
.
create
(
requireTenant
(
request
.
auth
),
request
.
params
.
siteId
,
articleInputSchema
.
parse
(
request
.
body
)))
));
app
.
put
<
{
Params
:
{
siteId
:
string
;
slug
:
string
}
}
>
(
"/api/sites/:siteId/articles/:slug"
,
async
(
request
)
=>
(
articles
.
update
(
requireTenant
(
request
.
auth
),
request
.
params
.
siteId
,
request
.
params
.
slug
,
articleInputSchema
.
parse
(
request
.
body
))
));
app
.
delete
<
{
Params
:
{
siteId
:
string
;
slug
:
string
}
}
>
(
"/api/sites/:siteId/articles/:slug"
,
async
(
request
)
=>
(
articles
.
remove
(
requireTenant
(
request
.
auth
),
request
.
params
.
siteId
,
request
.
params
.
slug
)
));
app
.
post
<
{
Params
:
{
siteId
:
string
}
}
>
(
"/api/sites/:siteId/articles/import"
,
async
(
request
,
reply
)
=>
{
const
body
=
articleImportSchema
.
parse
(
request
.
body
);
return
reply
.
code
(
201
).
send
(
await
articles
.
importMarkdown
(
requireTenant
(
request
.
auth
),
request
.
params
.
siteId
,
body
.
filename
,
body
.
markdown
));
});
app
.
post
<
{
Params
:
{
siteId
:
string
}
}
>
(
"/api/sites/:siteId/articles/compile"
,
async
(
request
)
=>
(
articles
.
compilePreview
(
requireTenant
(
request
.
auth
),
request
.
params
.
siteId
)
));
app
.
post
<
{
Params
:
{
siteId
:
string
}
}
>
(
"/api/sites/:siteId/article-images"
,
async
(
request
,
reply
)
=>
(
reply
.
code
(
201
).
send
(
await
articles
.
uploadImage
(
requireTenant
(
request
.
auth
),
request
.
params
.
siteId
,
articleImageUploadSchema
.
parse
(
request
.
body
)))
));
app
.
get
<
{
Params
:
{
siteId
:
string
}
}
>
(
"/api/sites/:siteId/history"
,
async
(
request
)
=>
{
const
tenantId
=
requireTenant
(
request
.
auth
);
await
sites
.
get
(
tenantId
,
request
.
params
.
siteId
);
...
...
@@ -252,7 +279,7 @@ await mkdir(runtimePaths.builds, { recursive: true });
for
(
let
site
of
await
sites
.
listAll
())
{
await
git
.
pruneWorktrees
(
sites
.
getProjectPath
(
site
.
tenantId
,
site
.
siteId
));
await
builds
.
ensureProductionPlaceholder
(
site
.
tenantId
,
site
.
siteId
,
site
.
name
);
if
(
site
.
environmentVersion
!==
3
)
{
if
(
site
.
environmentVersion
!==
4
)
{
await
versions
.
rebuild
(
site
.
tenantId
,
site
.
siteId
).
catch
((
error
)
=>
app
.
log
.
warn
(
error
));
site
=
await
sites
.
get
(
site
.
tenantId
,
site
.
siteId
);
}
...
...
backend/src/sites/create-site.ts
View file @
467e65cb
...
...
@@ -28,10 +28,15 @@ export class CreateSiteService {
createdAt
:
now
,
updatedAt
:
now
,
};
const
articlesDirectory
=
this
.
sites
.
getArticlesPath
(
tenantId
,
siteId
);
await
Promise
.
all
([
mkdir
(
projectPath
,
{
recursive
:
true
}),
mkdir
(
articlesDirectory
,
{
recursive
:
true
})]);
const
articleImagesDirectory
=
this
.
sites
.
getArticleImagesPath
(
tenantId
,
siteId
);
await
Promise
.
all
([
mkdir
(
projectPath
,
{
recursive
:
true
}),
mkdir
(
articlesDirectory
,
{
recursive
:
true
}),
mkdir
(
articleImagesDirectory
,
{
recursive
:
true
})]);
await
this
.
sites
.
save
(
site
);
try
{
await
cp
(
config
.
templateDir
,
projectPath
,
{
recursive
:
true
,
filter
:
(
source
)
=>
!
[
"node_modules"
,
"dist"
,
".astro"
,
".git"
,
"pnpm-lock.yaml"
].
includes
(
path
.
basename
(
source
))
});
await
cp
(
config
.
templateDir
,
projectPath
,
{
recursive
:
true
,
filter
:
(
source
)
=>
!
[
"node_modules"
,
"dist"
,
".astro"
,
".git"
,
"pnpm-lock.yaml"
,
"examples"
].
includes
(
path
.
basename
(
source
))
});
await
Promise
.
all
([
cp
(
path
.
join
(
config
.
templateDir
,
"examples"
,
"articles"
),
articlesDirectory
,
{
recursive
:
true
}),
cp
(
path
.
join
(
config
.
templateDir
,
"examples"
,
"images"
),
articleImagesDirectory
,
{
recursive
:
true
}),
]);
await
rm
(
path
.
join
(
projectPath
,
"src/data/company.json"
),
{
force
:
true
});
const
siteDataPath
=
path
.
join
(
projectPath
,
"src/data/site.json"
);
const
siteData
=
JSON
.
parse
(
await
readFile
(
siteDataPath
,
"utf8"
))
as
{
...
...
@@ -57,12 +62,13 @@ export class CreateSiteService {
const
theme
=
(
await
readFile
(
themePath
,
"utf8"
)).
replaceAll
(
"#7028ff"
,
input
.
brandColor
.
toLowerCase
());
await
writeFile
(
themePath
,
theme
,
"utf8"
);
const
dist
=
await
this
.
builds
.
build
(
projectPath
,
"create_"
+
siteId
,
{
basePath
:
getPublicPreviewUrl
(
tenantId
,
siteId
),
indexable
:
false
,
articlesDirectory
,
basePath
:
getPublicPreviewUrl
(
tenantId
,
siteId
),
indexable
:
false
,
articlesDirectory
,
articleImagesDirectory
,
});
const
commit
=
await
this
.
git
.
init
(
projectPath
);
await
this
.
sites
.
update
(
tenantId
,
siteId
,
{
currentCommit
:
commit
});
const
published
=
await
this
.
builds
.
publishPreview
(
tenantId
,
siteId
,
dist
);
await
this
.
previews
.
start
(
tenantId
,
siteId
,
published
,
previewPort
);
await
this
.
sites
.
markArticlesCompiled
(
tenantId
,
siteId
);
await
this
.
builds
.
ensureProductionPlaceholder
(
tenantId
,
siteId
,
input
.
name
);
return
await
this
.
sites
.
update
(
tenantId
,
siteId
,
{
status
:
"ready"
,
currentCommit
:
commit
,
previewCommit
:
commit
,
lastError
:
undefined
});
}
catch
(
error
)
{
...
...
backend/src/sites/site-repository.ts
View file @
467e65cb
...
...
@@ -40,6 +40,24 @@ export class SiteRepository {
return
path
.
join
(
this
.
getSiteRoot
(
tenantId
,
siteId
),
"content"
,
"articles"
);
}
getArticleImagesPath
(
tenantId
:
string
,
siteId
:
string
):
string
{
return
path
.
join
(
this
.
getSiteRoot
(
tenantId
,
siteId
),
"content"
,
"images"
);
}
getArticleStatePath
(
tenantId
:
string
,
siteId
:
string
):
string
{
return
path
.
join
(
this
.
getSiteRoot
(
tenantId
,
siteId
),
"metadata"
,
"articles.json"
);
}
async
markArticlesCompiled
(
tenantId
:
string
,
siteId
:
string
):
Promise
<
string
>
{
const
destination
=
this
.
getArticleStatePath
(
tenantId
,
siteId
);
const
compiledAt
=
new
Date
().
toISOString
();
await
mkdir
(
path
.
dirname
(
destination
),
{
recursive
:
true
});
const
temporary
=
`
${
destination
}
.tmp`
;
await
writeFile
(
temporary
,
JSON
.
stringify
({
pendingSlugs
:
[],
lastCompiledAt
:
compiledAt
},
null
,
2
)
+
"
\n
"
,
"utf8"
);
await
rename
(
temporary
,
destination
);
return
compiledAt
;
}
getMetadataPath
(
tenantId
:
string
,
siteId
:
string
):
string
{
return
path
.
join
(
this
.
getSiteRoot
(
tenantId
,
siteId
),
"metadata"
,
"site.json"
);
}
...
...
backend/src/sites/site-version-service.ts
View file @
467e65cb
import
crypto
from
"node:crypto"
;
import
path
from
"node:path"
;
import
{
readFile
}
from
"node:fs/promises"
;
import
type
{
DraftPreviewResult
,
PreviewVersionResult
,
PublishResult
,
SiteInfo
}
from
"@webagent/shared"
;
import
{
getPublicPreviewUrl
,
getPublicProductionUrl
,
runtimePaths
}
from
"../config.js"
;
import
{
BuildError
,
BuildManager
}
from
"../build/build-manager.js"
;
...
...
@@ -53,9 +54,11 @@ export class SiteVersionService {
const
dist
=
await
this
.
builds
.
build
(
workspace
,
taskId
,
{
basePath
:
getPublicPreviewUrl
(
tenantId
,
siteId
),
indexable
:
false
,
articlesDirectory
:
this
.
sites
.
getArticlesPath
(
tenantId
,
siteId
),
articleImagesDirectory
:
this
.
sites
.
getArticleImagesPath
(
tenantId
,
siteId
),
});
const
published
=
await
this
.
builds
.
publishPreview
(
tenantId
,
siteId
,
dist
);
await
this
.
previews
.
start
(
tenantId
,
siteId
,
published
,
site
.
previewPort
);
await
this
.
sites
.
markArticlesCompiled
(
tenantId
,
siteId
);
await
this
.
sites
.
update
(
tenantId
,
siteId
,
{
status
:
"ready"
,
previewCommit
:
site
.
draftBaseCommit
,
draftUpdatedAt
:
new
Date
().
toISOString
(),
...
...
@@ -85,6 +88,7 @@ export class SiteVersionService {
const
dist
=
await
this
.
builds
.
build
(
workspace
,
taskId
,
{
basePath
:
getPublicPreviewUrl
(
tenantId
,
siteId
),
indexable
:
false
,
articlesDirectory
:
this
.
sites
.
getArticlesPath
(
tenantId
,
siteId
),
articleImagesDirectory
:
this
.
sites
.
getArticleImagesPath
(
tenantId
,
siteId
),
});
const
draftCommit
=
await
this
.
git
.
hasChanges
(
workspace
)
?
await
this
.
git
.
commit
(
workspace
,
message
)
...
...
@@ -92,6 +96,7 @@ export class SiteVersionService {
const
commit
=
await
this
.
git
.
fastForward
(
project
,
draftCommit
,
site
.
draftBaseCommit
);
const
published
=
await
this
.
builds
.
publishPreview
(
tenantId
,
siteId
,
dist
);
await
this
.
previews
.
start
(
tenantId
,
siteId
,
published
,
site
.
previewPort
);
await
this
.
sites
.
markArticlesCompiled
(
tenantId
,
siteId
);
await
this
.
sites
.
update
(
tenantId
,
siteId
,
{
status
:
"ready"
,
currentCommit
:
commit
,
previewCommit
:
commit
,
draftBaseCommit
:
undefined
,
draftUpdatedAt
:
undefined
,
draftSummary
:
undefined
,
...
...
@@ -133,10 +138,12 @@ export class SiteVersionService {
const
dist
=
await
this
.
builds
.
build
(
workspace
,
taskId
,
{
basePath
:
getPublicPreviewUrl
(
tenantId
,
siteId
),
indexable
:
false
,
articlesDirectory
:
this
.
sites
.
getArticlesPath
(
tenantId
,
siteId
),
articleImagesDirectory
:
this
.
sites
.
getArticleImagesPath
(
tenantId
,
siteId
),
});
const
commit
=
await
this
.
git
.
restoreAsCommit
(
project
,
targetCommit
);
const
published
=
await
this
.
builds
.
publishPreview
(
tenantId
,
siteId
,
dist
);
await
this
.
previews
.
start
(
tenantId
,
siteId
,
published
,
site
.
previewPort
);
await
this
.
sites
.
markArticlesCompiled
(
tenantId
,
siteId
);
await
this
.
sites
.
update
(
tenantId
,
siteId
,
{
status
:
"ready"
,
currentCommit
:
commit
,
previewCommit
:
commit
,
environmentVersion
:
4
,
lastError
:
undefined
,
...
...
@@ -153,6 +160,12 @@ export class SiteVersionService {
const
site
=
await
this
.
sites
.
get
(
tenantId
,
siteId
);
if
(
site
.
status
!==
"ready"
)
throw
new
Error
(
"测试环境尚未构建成功,不能发布到生产环境"
);
if
(
site
.
draftBaseCommit
)
throw
new
Error
(
"存在未保存修改,请先在“版本”中保存或放弃草稿后再发布"
);
const
articleState
=
await
readFile
(
this
.
sites
.
getArticleStatePath
(
tenantId
,
siteId
),
"utf8"
)
.
then
((
raw
)
=>
JSON
.
parse
(
raw
)
as
{
pendingSlugs
?:
unknown
[]
})
.
catch
(()
=>
({
pendingSlugs
:
[]
}));
if
(
articleState
.
pendingSlugs
?.
length
)
{
throw
Object
.
assign
(
new
Error
(
"存在尚未编译的文章修改,请先在“文章”中统一编译测试预览"
),
{
statusCode
:
409
});
}
const
taskId
=
"publish_"
+
crypto
.
randomBytes
(
4
).
toString
(
"hex"
);
const
workspace
=
path
.
join
(
runtimePaths
.
builds
,
taskId
,
"workspace"
);
await
this
.
sites
.
update
(
tenantId
,
siteId
,
{
publishStatus
:
"publishing"
,
lastPublishError
:
undefined
});
...
...
@@ -164,6 +177,7 @@ export class SiteVersionService {
const
dist
=
await
this
.
builds
.
build
(
workspace
,
taskId
,
{
basePath
:
getPublicProductionUrl
(
tenantId
,
siteId
),
indexable
:
true
,
articlesDirectory
:
this
.
sites
.
getArticlesPath
(
tenantId
,
siteId
),
articleImagesDirectory
:
this
.
sites
.
getArticleImagesPath
(
tenantId
,
siteId
),
});
await
this
.
builds
.
publishProduction
(
tenantId
,
siteId
,
dist
);
const
publishedAt
=
new
Date
().
toISOString
();
...
...
@@ -199,9 +213,11 @@ export class SiteVersionService {
const
dist
=
await
this
.
builds
.
build
(
workspace
,
taskId
,
{
basePath
:
getPublicPreviewUrl
(
site
.
tenantId
,
site
.
siteId
),
indexable
:
false
,
articlesDirectory
:
this
.
sites
.
getArticlesPath
(
site
.
tenantId
,
site
.
siteId
),
articleImagesDirectory
:
this
.
sites
.
getArticleImagesPath
(
site
.
tenantId
,
site
.
siteId
),
});
const
published
=
await
this
.
builds
.
publishPreview
(
site
.
tenantId
,
site
.
siteId
,
dist
);
await
this
.
previews
.
start
(
site
.
tenantId
,
site
.
siteId
,
published
,
site
.
previewPort
);
await
this
.
sites
.
markArticlesCompiled
(
site
.
tenantId
,
site
.
siteId
);
await
this
.
sites
.
update
(
site
.
tenantId
,
site
.
siteId
,
{
...
metadata
,
status
:
"ready"
,
previewCommit
:
commit
,
environmentVersion
:
4
,
lastError
:
undefined
,
...
...
frontend/src/App.tsx
View file @
467e65cb
...
...
@@ -4,9 +4,9 @@ import {
Archive
,
ArrowLeft
,
ArrowRight
,
BookOpen
,
Bot
,
Check
,
CheckCircle2
,
ChevronDown
,
Clock3
,
Code2
,
Copy
,
ExternalLink
,
Globe2
,
History
,
Laptop
,
LayoutGrid
,
Link2
,
LoaderCircle
,
MessageSquareText
,
Monitor
,
Plus
,
RefreshCw
,
Rocket
,
RotateCcw
,
Search
,
Send
,
Settings2
,
ShieldCheck
,
Smartphone
,
Sparkles
,
Trash2
,
WandSparkles
,
X
,
FileText
,
ImagePlus
,
Save
,
Upload
,
WandSparkles
,
X
,
}
from
"lucide-react"
;
import
type
{
AgentRunEvent
,
CreateSiteInput
,
CreateTenantInput
,
DomainBinding
,
GitHistoryItem
,
SessionInfo
,
SiteInfo
,
TenantAdminInfo
}
from
"@webagent/shared"
;
import
type
{
AgentRunEvent
,
ArticleInput
,
CreateSiteInput
,
CreateTenantInput
,
DomainBinding
,
GitHistoryItem
,
SessionInfo
,
SiteInfo
,
TenantAdminInfo
}
from
"@webagent/shared"
;
import
{
api
}
from
"./api"
;
type
ChatMessage
=
{
role
:
"user"
|
"agent"
;
text
:
string
;
meta
?:
string
};
...
...
@@ -468,7 +468,9 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage,
const queryClient = useQueryClient();
const siteQuery = useQuery({ queryKey: ["site", siteId], queryFn: () => api.site(siteId) });
const historyQuery = useQuery({ queryKey: ["history", siteId], queryFn: () => api.history(siteId) });
const [tab, setTab] = useState<"chat" | "history" | "domains">("chat");
const [tab, setTab] = useState<"chat" | "history" | "articles" | "domains">("chat");
const [selectedArticleSlug, setSelectedArticleSlug] = useState("");
const [creatingArticle, setCreatingArticle] = useState(false);
const [device, setDevice] = useState<"desktop" | "mobile">("desktop");
const [environment, setEnvironment] = useState<"preview" | "production">("preview");
const [pendingPreviewCommit, setPendingPreviewCommit] = useState("");
...
...
@@ -488,6 +490,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage,
useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); }, [messages]);
useEffect(() => {
setEnvironment("preview");
setSelectedArticleSlug(""); setCreatingArticle(false);
setPendingPreviewCommit("");
setMessages([{ role: "agent", text: "已切换到这个网站。你可以继续描述修改要求。", meta: agentMode === "model" ? "模型 Agent 已连接" : "本地演示 Agent" }]);
}, [siteId, agentMode]);
...
...
@@ -629,16 +632,16 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage,
publishMutation
.
reset
();
setWorkspaceDialog
({
kind
:
"publish"
});
};
return
<
main
className=
{
`workspace ${tab === "domains" ? "domains-mode" : ""}`
}
>
return
<
main
className=
{
`workspace ${tab === "domains" ? "domains-mode" :
tab === "articles" ? "articles-mode" :
""}`
}
>
<
aside
className=
"rail"
>
<
Logo
compact
/>
<
button
className=
"rail-create-top"
onClick=
{
onCreate
}
title=
"创建官网"
data
-
tooltip=
"创建官网"
aria
-
label=
"创建官网"
><
Plus
size=
{
18
}
/></
button
>
<
div
className=
"rail-nav"
><
button
className=
{
tab
!==
"domains"
?
"active"
:
""
}
onClick=
{
()
=>
setTab
(
"chat"
)
}
title=
"Agent 工作台"
data
-
tooltip=
"Agent 工作台"
aria
-
label=
"Agent 工作台"
><
Monitor
size=
{
18
}
/></
button
><
button
onClick=
{
onManage
}
title=
"网站管理"
data
-
tooltip=
"网站管理"
aria
-
label=
"网站管理"
><
LayoutGrid
size=
{
18
}
/></
button
></
div
>
<
div
className=
"rail-nav"
><
button
className=
{
tab
!==
"domains"
&&
tab
!==
"articles"
?
"active"
:
""
}
onClick=
{
()
=>
setTab
(
"chat"
)
}
title=
"Agent 工作台"
data
-
tooltip=
"Agent 工作台"
aria
-
label=
"Agent 工作台"
><
Monitor
size=
{
18
}
/></
button
><
button
onClick=
{
onManage
}
title=
"网站管理"
data
-
tooltip=
"网站管理"
aria
-
label=
"网站管理"
><
LayoutGrid
size=
{
18
}
/></
button
></
div
>
<
RailSettings
onLogout=
{
onLogout
}
/>
</
aside
>
<
section
className=
"control-panel"
>
<
SiteSwitcher
site=
{
site
}
sites=
{
sites
}
onSelect=
{
onSelectSite
}
/>
<
div
className=
"panel-tabs"
><
button
className=
{
tab
===
"chat"
?
"active"
:
""
}
onClick=
{
()
=>
setTab
(
"chat"
)
}
><
MessageSquareText
size=
{
15
}
/>
Agent
</
button
><
button
className=
{
tab
===
"history"
?
"active"
:
""
}
onClick=
{
()
=>
{
setTab
(
"history"
);
void
siteQuery
.
refetch
();
void
historyQuery
.
refetch
();
}
}
><
History
size=
{
15
}
/>
版本
</
button
><
button
className=
{
tab
===
"domains"
?
"active"
:
""
}
onClick=
{
()
=>
setTab
(
"domains"
)
}
><
Globe2
size=
{
15
}
/>
域名
</
button
></
div
>
<
div
className=
"panel-tabs"
><
button
className=
{
tab
===
"chat"
?
"active"
:
""
}
onClick=
{
()
=>
setTab
(
"chat"
)
}
><
MessageSquareText
size=
{
15
}
/>
Agent
</
button
><
button
className=
{
tab
===
"history"
?
"active"
:
""
}
onClick=
{
()
=>
{
setTab
(
"history"
);
void
siteQuery
.
refetch
();
void
historyQuery
.
refetch
();
}
}
><
History
size=
{
15
}
/>
版本
</
button
><
button
className=
{
tab
===
"
articles"
?
"active"
:
""
}
onClick=
{
()
=>
setTab
(
"articles"
)
}
><
BookOpen
size=
{
15
}
/>
文章
</
button
><
button
className=
{
tab
===
"
domains"
?
"active"
:
""
}
onClick=
{
()
=>
setTab
(
"domains"
)
}
><
Globe2
size=
{
15
}
/>
域名
</
button
></
div
>
{
tab
===
"chat"
?
<>
<
div
className=
"chat-scroll"
ref=
{
scrollRef
}
>
<
div
className=
"chat-date"
>
今天
</
div
>
...
...
@@ -680,9 +683,9 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage,
</
div
>
</
article
>;
})
}
</
div
>
:
<
DomainSidePanel
site=
{
site
}
/>
}
</
div
>
:
tab
===
"articles"
?
<
ArticleSidePanel
site=
{
site
}
selectedSlug=
{
selectedArticleSlug
}
creating=
{
creatingArticle
}
onSelect=
{
(
slug
)
=>
{
setSelectedArticleSlug
(
slug
);
setCreatingArticle
(
false
);
}
}
onCreate=
{
()
=>
{
setSelectedArticleSlug
(
""
);
setCreatingArticle
(
true
);
}
}
onUpdated=
{
refreshData
}
onGoToAgent=
{
()
=>
setTab
(
"chat"
)
}
/>
:
<
DomainSidePanel
site=
{
site
}
/>
}
</
section
>
{
tab
===
"domains"
?
<
DomainManagement
site=
{
site
}
/>
:
<
section
className=
"preview-shell"
>
{
tab
===
"domains"
?
<
DomainManagement
site=
{
site
}
/>
:
tab
===
"articles"
?
<
ArticleManagement
site=
{
site
}
selectedSlug=
{
selectedArticleSlug
}
creating=
{
creatingArticle
}
onSelect=
{
setSelectedArticleSlug
}
onCreating=
{
setCreatingArticle
}
/>
:
<
section
className=
"preview-shell"
>
<
header
className=
"preview-toolbar"
>
<
div
className=
"preview-title"
><
div
><
span
className=
{
`status-dot ${showingProduction ? "production" : ""}`
}
/><
strong
>
{
site
.
name
}
</
strong
><
em
className=
{
showingProduction
?
"production"
:
""
}
>
{
showingProduction
?
"生产环境"
:
"测试环境"
}
</
em
></
div
><
span
className=
{
`build-status ${showingProduction ? site.lastPublishError ? "failed" : site.publishStatus : site.lastError ? "failed" : site.status}`
}
>
{
busy
?
<
LoaderCircle
className=
"spin"
size=
{
12
}
/>
:
showingProduction
?
site
.
lastPublishError
?
<
X
size=
{
12
}
/>
:
<
Check
size=
{
12
}
/>
:
site
.
lastError
?
<
X
size=
{
12
}
/>
:
<
Check
size=
{
12
}
/>
}{
publishMutation
.
isPending
?
"正在发布"
:
saveDraftMutation
.
isPending
?
"正在保存版本"
:
discardDraftMutation
.
isPending
?
"正在放弃草稿"
:
previewDraftMutation
.
isPending
?
"正在构建草稿"
:
chatMutation
.
isPending
?
"正在修改草稿"
:
rebuildMutation
.
isPending
||
previewVersionMutation
.
isPending
||
restoreVersionMutation
.
isPending
?
"正在构建"
:
environmentStatus
}
</
span
></
div
>
<
div
className=
"preview-modes"
>
...
...
@@ -717,6 +720,135 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage,
</
main
>;
}
function
emptyArticle
():
ArticleInput
{
return
{
slug
:
`article-
${
Date
.
now
()}
`
,
title
:
""
,
summary
:
""
,
body
:
""
,
status
:
"draft"
,
author
:
""
,
cover
:
""
,
tags
:
[],
publishedAt
:
""
,
updatedAt
:
""
,
seo
:
{
title
:
""
,
description
:
""
,
ogDescription
:
""
,
ogImage
:
""
,
noindex
:
false
},
};
}
function
ArticleSidePanel
({
site
,
selectedSlug
,
creating
,
onSelect
,
onCreate
,
onUpdated
,
onGoToAgent
}:
{
site
:
SiteInfo
;
selectedSlug
:
string
;
creating
:
boolean
;
onSelect
:
(
slug
:
string
)
=>
void
;
onCreate
:
()
=>
void
;
onUpdated
:
()
=>
Promise
<
void
>
;
onGoToAgent
:
()
=>
void
})
{
const
queryClient
=
useQueryClient
();
const
importInput
=
useRef
<
HTMLInputElement
>
(
null
);
const
[
notice
,
setNotice
]
=
useState
(
""
);
const
articlesQuery
=
useQuery
({
queryKey
:
[
"articles"
,
site
.
siteId
],
queryFn
:
()
=>
api
.
articles
(
site
.
siteId
)
});
const
published
=
articlesQuery
.
data
?.
articles
.
filter
((
article
)
=>
article
.
status
===
"published"
).
length
||
0
;
const
refresh
=
async
()
=>
{
await
queryClient
.
invalidateQueries
({
queryKey
:
[
"articles"
,
site
.
siteId
]
});
};
const
compileMutation
=
useMutation
({
mutationFn
:
()
=>
api
.
compileArticles
(
site
.
siteId
),
onSuccess
:
async
()
=>
{
setNotice
(
"文章已发布到测试环境;确认后请返回 Agent 工作台点击“重新上线”。"
);
await
Promise
.
all
([
refresh
(),
onUpdated
()]);
},
});
const
importMutation
=
useMutation
({
mutationFn
:
async
(
file
:
File
)
=>
api
.
importArticle
(
site
.
siteId
,
file
.
name
,
await
file
.
text
()),
onSuccess
:
async
(
result
)
=>
{
if
(
result
.
article
)
onSelect
(
result
.
article
.
slug
);
setNotice
(
"Markdown 已导入,尚未编译。"
);
await
refresh
();
},
});
useEffect
(()
=>
{
if
(
!
selectedSlug
&&
!
creating
&&
articlesQuery
.
data
?.
articles
[
0
])
onSelect
(
articlesQuery
.
data
.
articles
[
0
].
slug
);
},
[
articlesQuery
.
data
,
creating
,
onSelect
,
selectedSlug
]);
const
pending
=
articlesQuery
.
data
?.
pendingBuild
||
false
;
const
error
=
compileMutation
.
error
||
importMutation
.
error
;
const
busy
=
compileMutation
.
isPending
||
importMutation
.
isPending
;
return
<
div
className=
"article-side-panel"
>
<
div
className=
"article-side-intro"
><
span
className=
"domain-side-icon"
><
BookOpen
size=
{
18
}
/></
span
><
div
><
h2
>
文章中心
</
h2
><
p
>
先连续编辑多篇文章,最后统一编译,减少等待时间。
</
p
></
div
></
div
>
<
div
className=
"article-flow-guide"
><
strong
>
推荐流程
</
strong
><
ol
><
li
><
b
>
1
</
b
><
span
>
保存草稿或加入发布队列
<
small
>
编辑、导入和图片上传都不会触发构建。
</
small
></
span
></
li
><
li
><
b
>
2
</
b
><
span
>
发布到测试环境
<
small
>
一次编译全部待同步内容,先在测试环境确认。
</
small
></
span
></
li
><
li
><
b
>
3
</
b
><
span
>
返回 Agent 点击“重新上线”
<
small
>
确认测试效果后,将当前版本更新到正式网站。
</
small
></
span
></
li
></
ol
></
div
>
<
div
className=
{
`article-batch-card ${pending ? "pending" : "ready"}`
}
><
div
><
span
>
{
pending
?
`${articlesQuery.data?.pendingCount || 0} 项待发布`
:
"测试环境已同步"
}
</
span
><
small
>
{
articlesQuery
.
data
?.
lastCompiledAt
?
`上次发布 ${formatTime(articlesQuery.data.lastCompiledAt)}`
:
"初始测试环境已生成"
}
</
small
></
div
><
button
type=
"button"
disabled=
{
busy
||
!
pending
}
onClick=
{
()
=>
compileMutation
.
mutate
()
}
>
{
compileMutation
.
isPending
?
<
LoaderCircle
className=
"spin"
size=
{
13
}
/>
:
<
RefreshCw
size=
{
13
}
/>
}{
compileMutation
.
isPending
?
"发布中…"
:
pending
?
"发布到测试环境"
:
"测试环境已同步"
}
</
button
><
button
className=
"article-go-live"
type=
"button"
disabled=
{
busy
||
pending
}
title=
{
pending
?
"请先发布到测试环境"
:
"返回 Agent 工作台后点击重新上线"
}
onClick=
{
onGoToAgent
}
><
Rocket
size=
{
13
}
/>
返回 Agent 点击重新上线
</
button
></
div
>
{
(
notice
||
error
)
&&
<
div
className=
{
`article-side-notice ${error ? "error" : ""}`
}
>
{
error
?
error
.
message
:
notice
}
</
div
>
}
<
div
className=
"article-side-tools"
><
button
type=
"button"
onClick=
{
onCreate
}
><
Plus
size=
{
12
}
/>
新建
</
button
><
label
><
Upload
size=
{
12
}
/>
{
importMutation
.
isPending
?
"导入中"
:
"导入 Markdown"
}
<
input
ref=
{
importInput
}
type=
"file"
accept=
".md,.markdown,text/markdown"
disabled=
{
busy
}
onChange=
{
(
event
)
=>
{
const
file
=
event
.
target
.
files
?.[
0
];
if
(
file
)
importMutation
.
mutate
(
file
);
event
.
target
.
value
=
""
;
}
}
/></
label
></
div
>
<
div
className=
"article-side-list-heading"
><
strong
>
内容列表
</
strong
><
span
>
{
articlesQuery
.
data
?.
articles
.
length
||
0
}
篇 ·
{
published
}
篇已设为发布
</
span
></
div
>
<
div
className=
"article-side-list"
>
{
articlesQuery
.
data
?.
articles
.
length
?
articlesQuery
.
data
.
articles
.
map
((
article
)
=>
<
button
type=
"button"
className=
{
selectedSlug
===
article
.
slug
&&
!
creating
?
"active"
:
""
}
key=
{
article
.
slug
}
onClick=
{
()
=>
onSelect
(
article
.
slug
)
}
><
span
><
strong
>
{
article
.
title
}
</
strong
><
small
>
{
article
.
slug
}
</
small
></
span
><
em
className=
{
article
.
status
}
>
{
article
.
status
===
"published"
?
"已发布"
:
"草稿"
}
</
em
>
{
article
.
pendingBuild
&&
<
i
>
待编译
</
i
>
}
</
button
>)
:
<
div
className=
"article-empty"
><
FileText
size=
{
22
}
/><
span
>
还没有文章
</
span
></
div
>
}
</
div
>
</
div
>;
}
function
ArticleManagement
({
site
,
selectedSlug
,
creating
,
onSelect
,
onCreating
}:
{
site
:
SiteInfo
;
selectedSlug
:
string
;
creating
:
boolean
;
onSelect
:
(
slug
:
string
)
=>
void
;
onCreating
:
(
creating
:
boolean
)
=>
void
})
{
const
queryClient
=
useQueryClient
();
const
[
draft
,
setDraft
]
=
useState
<
ArticleInput
>
(
emptyArticle
);
const
[
notice
,
setNotice
]
=
useState
(
""
);
const
imageInput
=
useRef
<
HTMLInputElement
>
(
null
);
const
articlesQuery
=
useQuery
({
queryKey
:
[
"articles"
,
site
.
siteId
],
queryFn
:
()
=>
api
.
articles
(
site
.
siteId
)
});
const
articleQuery
=
useQuery
({
queryKey
:
[
"article"
,
site
.
siteId
,
selectedSlug
],
queryFn
:
()
=>
api
.
article
(
site
.
siteId
,
selectedSlug
),
enabled
:
Boolean
(
selectedSlug
)
&&
!
creating
});
useEffect
(()
=>
{
if
(
creating
)
setDraft
(
emptyArticle
());
},
[
creating
]);
useEffect
(()
=>
{
if
(
articleQuery
.
data
&&
!
creating
)
setDraft
(
articleQuery
.
data
);
},
[
articleQuery
.
data
,
creating
]);
const
refresh
=
async
(
slug
?:
string
)
=>
{
await
Promise
.
all
([
queryClient
.
invalidateQueries
({
queryKey
:
[
"articles"
,
site
.
siteId
]
}),
queryClient
.
invalidateQueries
({
queryKey
:
[
"article"
,
site
.
siteId
]
}),
]);
if
(
slug
)
onSelect
(
slug
);
};
const
saveMutation
=
useMutation
({
mutationFn
:
(
status
:
ArticleInput
[
"status"
])
=>
{
const
today
=
new
Date
().
toISOString
().
slice
(
0
,
10
);
const
input
=
{
...
draft
,
status
,
publishedAt
:
status
===
"published"
?
draft
.
publishedAt
||
today
:
draft
.
publishedAt
||
undefined
,
updatedAt
:
today
};
return
creating
?
api
.
createArticle
(
site
.
siteId
,
input
)
:
api
.
updateArticle
(
site
.
siteId
,
selectedSlug
,
input
);
},
onSuccess
:
async
(
result
)
=>
{
const
slug
=
result
.
article
?.
slug
||
draft
.
slug
;
if
(
result
.
article
)
setDraft
(
result
.
article
);
onCreating
(
false
);
setNotice
(
result
.
article
?.
status
===
"published"
?
"已加入发布队列,尚未编译。"
:
"草稿已保存,本次没有触发编译。"
);
await
refresh
(
slug
);
},
});
const
deleteMutation
=
useMutation
({
mutationFn
:
()
=>
api
.
deleteArticle
(
site
.
siteId
,
selectedSlug
),
onSuccess
:
async
()
=>
{
onSelect
(
""
);
onCreating
(
false
);
setDraft
(
emptyArticle
());
setNotice
(
"文章已删除,待统一编译后从预览移除。"
);
await
refresh
();
},
});
const
imageMutation
=
useMutation
({
mutationFn
:
async
(
file
:
File
)
=>
api
.
uploadArticleImage
(
site
.
siteId
,
file
.
name
,
await
fileDataUrl
(
file
)),
onSuccess
:
(
result
)
=>
{
const
alt
=
result
.
filename
.
replace
(
/-
[
a-f0-9
]{10}\.[^
.
]
+$/
,
""
);
setDraft
((
value
)
=>
({
...
value
,
body
:
`
${
value
.
body
.
trimEnd
()}
\n\n\n`
,
cover
:
value
.
cover
||
result
.
path
}));
setNotice
(
"图片已上传并插入正文;保存文章并统一编译后即可在预览中查看。"
);
},
});
const
error
=
saveMutation
.
error
||
deleteMutation
.
error
||
imageMutation
.
error
||
articleQuery
.
error
;
const
busy
=
saveMutation
.
isPending
||
deleteMutation
.
isPending
||
imageMutation
.
isPending
;
const
update
=
<
K
extends
keyof
ArticleInput
>
(key: K, value: ArticleInput[K]) =
>
setDraft((current) =
>
(
{
...
current
,
[
key
]:
value
}
));
const updateSeo =
<
K
extends
keyof
ArticleInput
["
seo
"]
>
(key: K, value: ArticleInput["seo"][K]) =
>
setDraft((current) =
>
(
{
...
current
,
seo
:
{
...
current
.
seo
,
[
key
]:
value
}
}
));
const selectedPending = articlesQuery.data?.articles.find((article) =
>
article.slug === selectedSlug)?.pendingBuild || false;
if (articlesQuery.isLoading) return
<
section
className=
"article-workspace article-loading"
><
LoaderCircle
className=
"spin"
size=
{
24
}
/>
正在加载文章…
</
section
>
;
if (articlesQuery.data
&&
!articlesQuery.data.supported) return
<
section
className=
"article-workspace article-unsupported"
><
BookOpen
size=
{
30
}
/><
h1
>
当前站点尚未启用文章中心
</
h1
><
p
>
该站点使用旧版基础模版,升级模版后即可使用外部 Markdown 文章管理。
</
p
></
section
>
;
return
<
section
className=
"article-workspace"
>
<
header
className=
"article-header"
><
div
><
span
><
BookOpen
size=
{
18
}
/></
span
><
div
><
h1
>
{
creating
?
"新建文章"
:
"文章编辑器"
}
</
h1
><
p
>
保存只写入内容库;完成多篇编辑后在左侧统一编译。
</
p
></
div
></
div
></
header
>
<
div
className=
"article-layout article-editor-only"
>
<
form
className=
"article-editor"
onSubmit=
{
(
event
)
=>
event
.
preventDefault
()
}
>
<
div
className=
"article-editor-heading"
><
div
><
span
>
{
creating
?
"NEW ARTICLE"
:
"EDIT ARTICLE"
}
</
span
><
h2
>
{
creating
?
"创建文章"
:
draft
.
title
||
"编辑文章"
}
</
h2
><
em
className=
{
`article-status-badge ${draft.status}`
}
>
{
draft
.
status
===
"published"
?
selectedPending
?
"待编译发布"
:
"已发布"
:
selectedPending
?
"待编译草稿"
:
"草稿"
}
</
em
></
div
><
div
>
{
!
creating
&&
draft
.
status
===
"published"
&&
!
selectedPending
&&
<
a
href=
{
`${site.previewUrl}articles/${draft.slug}/`
}
target=
"_blank"
rel=
"noreferrer"
><
ExternalLink
size=
{
13
}
/>
打开预览
</
a
>
}{
!
creating
&&
<
button
className=
"article-delete"
type=
"button"
disabled=
{
busy
}
onClick=
{
()
=>
{
if
(
window
.
confirm
(
`确定删除“${draft.title}”吗?`
))
deleteMutation
.
mutate
();
}
}
><
Trash2
size=
{
13
}
/>
删除
</
button
>
}
<
button
className=
"article-save-draft"
type=
"button"
disabled=
{
busy
||
!
draft
.
title
.
trim
()
||
!
draft
.
summary
.
trim
()
||
!
draft
.
slug
.
trim
()
}
onClick=
{
()
=>
saveMutation
.
mutate
(
"draft"
)
}
>
{
saveMutation
.
isPending
&&
saveMutation
.
variables
===
"draft"
?
<
LoaderCircle
className=
"spin"
size=
{
14
}
/>
:
<
Save
size=
{
14
}
/>
}{
saveMutation
.
isPending
&&
saveMutation
.
variables
===
"draft"
?
"保存中…"
:
draft
.
status
===
"published"
?
"转为草稿"
:
"保存草稿"
}
</
button
><
button
className=
"article-save"
type=
"button"
disabled=
{
busy
||
!
draft
.
title
.
trim
()
||
!
draft
.
summary
.
trim
()
||
!
draft
.
slug
.
trim
()
}
onClick=
{
()
=>
saveMutation
.
mutate
(
"published"
)
}
>
{
saveMutation
.
isPending
&&
saveMutation
.
variables
===
"published"
?
<
LoaderCircle
className=
"spin"
size=
{
14
}
/>
:
<
Rocket
size=
{
14
}
/>
}{
saveMutation
.
isPending
&&
saveMutation
.
variables
===
"published"
?
"保存中…"
:
draft
.
status
===
"published"
?
"保存修改"
:
"加入发布队列"
}
</
button
></
div
></
div
>
{
(
notice
||
error
)
&&
<
div
className=
{
`article-notice ${error ? "error" : ""}`
}
>
{
error
?
<
X
size=
{
14
}
/>
:
<
CheckCircle2
size=
{
14
}
/>
}{
error
?
error
.
message
:
notice
}
</
div
>
}
<
div
className=
"article-fields"
>
<
label
className=
"wide"
><
span
>
标题 *
</
span
><
input
required
maxLength=
{
120
}
value=
{
draft
.
title
}
onChange=
{
(
event
)
=>
update
(
"title"
,
event
.
target
.
value
)
}
/></
label
>
<
label
><
span
>
Slug *
</
span
><
input
required
pattern=
"[a-z0-9]+(?:-[a-z0-9]+)*"
value=
{
draft
.
slug
}
onChange=
{
(
event
)
=>
update
(
"slug"
,
event
.
target
.
value
.
toLowerCase
().
replace
(
/
[^
a-z0-9-
]
/g
,
""
))
}
/></
label
>
<
div
className=
"article-publish-help"
><
span
>
内容状态
</
span
><
strong
>
{
draft
.
status
===
"published"
?
selectedPending
?
"等待统一编译"
:
"测试预览已公开"
:
"仅保存为草稿"
}
</
strong
><
small
>
保存不会触发构建,请在左侧统一编译。
</
small
></
div
>
<
label
className=
"wide"
><
span
>
摘要 *
</
span
><
textarea
required
rows=
{
2
}
maxLength=
{
500
}
value=
{
draft
.
summary
}
onChange=
{
(
event
)
=>
update
(
"summary"
,
event
.
target
.
value
)
}
/></
label
>
<
label
><
span
>
作者
</
span
><
input
maxLength=
{
80
}
value=
{
draft
.
author
||
""
}
onChange=
{
(
event
)
=>
update
(
"author"
,
event
.
target
.
value
)
}
/></
label
>
<
label
><
span
>
标签(逗号分隔)
</
span
><
input
value=
{
draft
.
tags
.
join
(
", "
)
}
onChange=
{
(
event
)
=>
update
(
"tags"
,
event
.
target
.
value
.
split
(
/
[
,,
]
/
).
map
((
tag
)
=>
tag
.
trim
()).
filter
(
Boolean
).
slice
(
0
,
20
))
}
/></
label
>
<
label
><
span
>
发布日期
</
span
><
input
type=
"date"
value=
{
draft
.
publishedAt
||
""
}
onChange=
{
(
event
)
=>
update
(
"publishedAt"
,
event
.
target
.
value
)
}
/></
label
>
<
label
><
span
>
封面地址
</
span
><
input
value=
{
draft
.
cover
||
""
}
placeholder=
"/images/articles/example.jpg"
onChange=
{
(
event
)
=>
update
(
"cover"
,
event
.
target
.
value
)
}
/></
label
>
<
div
className=
"article-body-field wide"
><
div
><
span
>
Markdown 正文
</
span
><
button
type=
"button"
disabled=
{
busy
}
onClick=
{
()
=>
imageInput
.
current
?.
click
()
}
>
{
imageMutation
.
isPending
?
<
LoaderCircle
className=
"spin"
size=
{
13
}
/>
:
<
ImagePlus
size=
{
13
}
/>
}
上传并插入图片
</
button
><
input
ref=
{
imageInput
}
type=
"file"
accept=
"image/png,image/jpeg,image/webp,image/gif"
onChange=
{
(
event
)
=>
{
const
file
=
event
.
target
.
files
?.[
0
];
if
(
file
)
imageMutation
.
mutate
(
file
);
event
.
target
.
value
=
""
;
}
}
/></
div
><
textarea
rows=
{
18
}
value=
{
draft
.
body
}
placeholder=
{
'## 小标题
\
n
\
n正文内容
\
n
\
n'
}
onChange=
{
(
event
)
=>
update
(
"body"
,
event
.
target
.
value
)
}
/></
div
>
</
div
>
<
details
className=
"article-seo"
><
summary
>
SEO / GEO 页面信息
</
summary
><
div
className=
"article-fields"
><
label
><
span
>
SEO 标题
</
span
><
input
maxLength=
{
70
}
value=
{
draft
.
seo
.
title
||
""
}
onChange=
{
(
event
)
=>
updateSeo
(
"title"
,
event
.
target
.
value
)
}
/></
label
><
label
><
span
>
OG 图片
</
span
><
input
value=
{
draft
.
seo
.
ogImage
||
""
}
onChange=
{
(
event
)
=>
updateSeo
(
"ogImage"
,
event
.
target
.
value
)
}
/></
label
><
label
className=
"wide"
><
span
>
SEO 描述
</
span
><
textarea
rows=
{
2
}
maxLength=
{
200
}
value=
{
draft
.
seo
.
description
||
""
}
onChange=
{
(
event
)
=>
updateSeo
(
"description"
,
event
.
target
.
value
)
}
/></
label
><
label
className=
"wide"
><
span
>
OG 智能摘要
</
span
><
textarea
rows=
{
2
}
maxLength=
{
240
}
value=
{
draft
.
seo
.
ogDescription
||
""
}
onChange=
{
(
event
)
=>
updateSeo
(
"ogDescription"
,
event
.
target
.
value
)
}
/></
label
><
label
className=
"article-checkbox wide"
><
input
type=
"checkbox"
checked=
{
draft
.
seo
.
noindex
}
onChange=
{
(
event
)
=>
updateSeo
(
"noindex"
,
event
.
target
.
checked
)
}
/><
span
>
禁止搜索引擎索引此文章
</
span
></
label
></
div
></
details
>
</
form
>
</
div
>
</
section
>
;
}
function fileDataUrl(file: File): Promise
<
string
>
{
return
new
Promise
((
resolve
,
reject
)
=>
{
const
reader
=
new
FileReader
();
reader
.
onload
=
()
=>
typeof
reader
.
result
===
"string"
?
resolve
(
reader
.
result
)
:
reject
(
new
Error
(
"图片读取失败"
));
reader
.
onerror
=
()
=>
reject
(
new
Error
(
"图片读取失败"
));
reader
.
readAsDataURL
(
file
);
});
}
function DomainSidePanel(
{
site
}
:
{
site
:
SiteInfo
}
)
{
const
domainsQuery
=
useQuery
({
queryKey
:
[
"domains"
,
site
.
siteId
],
queryFn
:
()
=>
api
.
domains
(
site
.
siteId
)
});
const
active
=
domainsQuery
.
data
?.
domains
.
filter
((
domain
)
=>
domain
.
status
===
"active"
).
length
||
0
;
...
...
frontend/src/api.ts
View file @
467e65cb
import
type
{
AgentRunEvent
,
AgentRunInfo
,
AgentSettings
,
CreateSiteInput
,
CreateTenantInput
,
DomainBinding
,
DomainListResult
,
DraftPreviewResult
,
GitHistoryItem
,
PreviewVersionResult
,
PublishResult
,
SessionInfo
,
SiteInfo
,
TenantAdminInfo
,
TenantAdminSiteInfo
}
from
"@webagent/shared"
;
import
type
{
AgentRunEvent
,
AgentRunInfo
,
AgentSettings
,
ArticleCompileResult
,
ArticleDocument
,
ArticleImageUploadResult
,
ArticleInput
,
ArticleListResult
,
ArticleMutationResult
,
CreateSiteInput
,
CreateTenantInput
,
DomainBinding
,
DomainListResult
,
DraftPreviewResult
,
GitHistoryItem
,
PreviewVersionResult
,
PublishResult
,
SessionInfo
,
SiteInfo
,
TenantAdminInfo
,
TenantAdminSiteInfo
}
from
"@webagent/shared"
;
async
function
request
<
T
>
(
url
:
string
,
options
?:
RequestInit
):
Promise
<
T
>
{
const
headers
=
new
Headers
(
options
?.
headers
);
...
...
@@ -52,6 +52,14 @@ export const api = {
restoreVersion
:
(
siteId
:
string
,
commit
:
string
)
=>
request
<
PreviewVersionResult
>
(
"/api/sites/"
+
siteId
+
"/restore-version"
,
{
method
:
"POST"
,
body
:
JSON
.
stringify
({
commit
})
}),
rebuild
:
(
siteId
:
string
)
=>
request
<
{
previewUrl
:
string
}
>
(
"/api/sites/"
+
siteId
+
"/build"
,
{
method
:
"POST"
}),
publish
:
(
siteId
:
string
)
=>
request
<
PublishResult
>
(
"/api/sites/"
+
siteId
+
"/publish"
,
{
method
:
"POST"
}),
articles
:
(
siteId
:
string
)
=>
request
<
ArticleListResult
>
(
`/api/sites/
${
siteId
}
/articles`
),
article
:
(
siteId
:
string
,
slug
:
string
)
=>
request
<
ArticleDocument
>
(
`/api/sites/
${
siteId
}
/articles/
${
slug
}
`
),
createArticle
:
(
siteId
:
string
,
input
:
ArticleInput
)
=>
request
<
ArticleMutationResult
>
(
`/api/sites/
${
siteId
}
/articles`
,
{
method
:
"POST"
,
body
:
JSON
.
stringify
(
input
)
}),
updateArticle
:
(
siteId
:
string
,
slug
:
string
,
input
:
ArticleInput
)
=>
request
<
ArticleMutationResult
>
(
`/api/sites/
${
siteId
}
/articles/
${
slug
}
`
,
{
method
:
"PUT"
,
body
:
JSON
.
stringify
(
input
)
}),
deleteArticle
:
(
siteId
:
string
,
slug
:
string
)
=>
request
<
ArticleMutationResult
>
(
`/api/sites/
${
siteId
}
/articles/
${
slug
}
`
,
{
method
:
"DELETE"
}),
importArticle
:
(
siteId
:
string
,
filename
:
string
,
markdown
:
string
)
=>
request
<
ArticleMutationResult
>
(
`/api/sites/
${
siteId
}
/articles/import`
,
{
method
:
"POST"
,
body
:
JSON
.
stringify
({
filename
,
markdown
})
}),
compileArticles
:
(
siteId
:
string
)
=>
request
<
ArticleCompileResult
>
(
`/api/sites/
${
siteId
}
/articles/compile`
,
{
method
:
"POST"
}),
uploadArticleImage
:
(
siteId
:
string
,
filename
:
string
,
dataUrl
:
string
)
=>
request
<
ArticleImageUploadResult
>
(
`/api/sites/
${
siteId
}
/article-images`
,
{
method
:
"POST"
,
body
:
JSON
.
stringify
({
filename
,
dataUrl
})
}),
domains
:
(
siteId
:
string
)
=>
request
<
DomainListResult
>
(
"/api/sites/"
+
siteId
+
"/domains"
),
addDomain
:
(
siteId
:
string
,
hostname
:
string
)
=>
request
<
DomainBinding
>
(
"/api/sites/"
+
siteId
+
"/domains"
,
{
method
:
"POST"
,
body
:
JSON
.
stringify
({
hostname
})
}),
verifyDomain
:
(
siteId
:
string
,
domainId
:
string
)
=>
request
<
DomainBinding
>
(
"/api/sites/"
+
siteId
+
"/domains/"
+
domainId
+
"/verify"
,
{
method
:
"POST"
}),
...
...
frontend/src/styles.css
View file @
467e65cb
...
...
@@ -33,7 +33,7 @@
@container
(
max-width
:
620px
){
.preview-toolbar
{
min-height
:
0
;
grid-template-columns
:
minmax
(
0
,
1
fr
);
grid-template-rows
:
auto
;
grid-template-areas
:
"title"
"modes"
"actions"
;
gap
:
8px
;
padding
:
9px
10px
}
.preview-title
{
justify-content
:
space-between
}
.preview-modes
,
.toolbar-actions
{
justify-content
:
center
}
.toolbar-actions
{
flex-wrap
:
wrap
}}
@media
(
max-width
:
800px
){
.landing-header
,
.create-layout
{
width
:
min
(
100%
-
30px
,
700px
)}
.landing-status
{
display
:
none
}
.feature-row
{
grid-template-columns
:
1
fr
}
.create-card
{
padding
:
26px
20px
}
.form-grid
{
grid-template-columns
:
1
fr
}
.field.wide
{
grid-column
:
auto
}
.workspace
{
grid-template-columns
:
52px
1
fr
}
.control-panel
{
border-right
:
0
}
.preview-shell
{
display
:
none
}
.create-intro
h1
{
font-size
:
48px
}
.create-layout
{
padding-bottom
:
35px
}
.create-workspace
{
grid-template-columns
:
52px
1
fr
}
.create-workspace
.create-control-panel
{
display
:
none
}
.workspace-create-content
{
padding
:
18px
}
.login-card
{
padding
:
30px
24px
}}
.panel-tabs
{
grid-template-columns
:
repeat
(
3
,
1
fr
)
}
.panel-tabs
{
grid-template-columns
:
repeat
(
4
,
1
fr
);
padding-inline
:
8px
}
.panel-tabs
button
{
gap
:
4px
;
font-size
:
9px
}
.domain-side-panel
{
flex
:
1
;
min-height
:
0
;
overflow
:
auto
;
padding
:
30px
25px
;
background
:
linear-gradient
(
180deg
,
#fff
,
#faf9ff
)}
.domain-side-icon
{
display
:
grid
;
place-items
:
center
;
width
:
42px
;
height
:
42px
;
color
:
var
(
--primary
);
background
:
var
(
--soft
);
border
:
1px
solid
#dfd6ff
;
border-radius
:
13px
}
.domain-side-panel
h2
{
margin
:
18px
0
8px
;
font-size
:
20px
}
.domain-side-panel
>
p
{
margin
:
0
;
color
:
var
(
--muted
);
font-size
:
10px
;
line-height
:
1.8
}
...
...
@@ -79,3 +79,10 @@
.toolbar-actions
.publish-button.unavailable
,
.toolbar-actions
.publish-button.unavailable
:hover
{
color
:
#8e9099
;
background
:
#efeff4
;
border
:
1px
solid
#e1e1e7
;
box-shadow
:
none
;
cursor
:
pointer
}
@media
(
max-width
:
1000px
){
.managed-site-card
{
grid-template-columns
:
46px
minmax
(
0
,
1
fr
)}
.managed-site-actions
{
grid-column
:
2
;
justify-content
:
flex-start
;
flex-wrap
:
wrap
}
.managed-site-states
{
flex-wrap
:
wrap
}}
@media
(
max-width
:
800px
){
.site-management-workspace
{
grid-template-columns
:
52px
1
fr
}
.site-management-page
{
padding
:
28px
16px
42px
}
.site-management-header
{
align-items
:
flex-start
;
flex-direction
:
column
}
.site-management-filters
{
overflow-x
:
auto
}
.site-management-tools
{
align-items
:
flex-start
;
flex-direction
:
column
}
.site-management-tools
label
{
width
:
100%
}
.managed-site-card
{
grid-template-columns
:
38px
minmax
(
0
,
1
fr
);
padding
:
13px
}
.managed-site-avatar
{
width
:
38px
;
height
:
38px
}
.managed-site-states
{
gap
:
8px
}
.managed-site-actions
a
,
.managed-site-actions
button
{
height
:
30px
}
.site-management-header
>
button
{
height
:
36px
}}
/* External Markdown article management */
.article-workspace
{
min-width
:
0
;
min-height
:
0
;
display
:
flex
;
flex-direction
:
column
;
background
:
#f4f5f9
}
.article-loading
,
.article-unsupported
{
align-items
:
center
;
justify-content
:
center
;
gap
:
10px
;
color
:
var
(
--muted
)}
.article-unsupported
h1
{
margin
:
5px
0
0
;
color
:
var
(
--text
);
font-size
:
20px
}
.article-unsupported
p
{
margin
:
0
;
font-size
:
10px
}
.article-header
{
height
:
66px
;
flex
:
0
0
66px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
gap
:
16px
;
padding
:
0
22px
;
background
:
#fff
;
border-bottom
:
1px
solid
var
(
--border
)}
.article-header
>
div
{
display
:
flex
;
align-items
:
center
;
gap
:
10px
}
.article-header
>
div
:first-child
>
span
{
display
:
grid
;
place-items
:
center
;
width
:
35px
;
height
:
35px
;
color
:
var
(
--primary
);
background
:
var
(
--soft
);
border-radius
:
10px
}
.article-header
h1
{
margin
:
0
;
font-size
:
15px
}
.article-header
p
{
margin
:
4px
0
0
;
color
:
var
(
--muted
);
font-size
:
8px
}
.article-header
button
,
.article-import
{
height
:
33px
;
display
:
flex
;
align-items
:
center
;
gap
:
6px
;
padding
:
0
11px
;
color
:
#fff
;
background
:
var
(
--gradient
);
border
:
0
;
border-radius
:
9px
;
font-size
:
8px
;
font-weight
:
800
;
cursor
:
pointer
}
.article-import
{
color
:
var
(
--primary
);
background
:
var
(
--soft
);
border
:
1px
solid
#ded5ff
}
.article-import
input
,
.article-body-field
input
{
display
:
none
}
.article-layout
{
min-height
:
0
;
display
:
grid
;
grid-template-columns
:
250px
minmax
(
0
,
1
fr
);
flex
:
1
}
.article-list
{
min-height
:
0
;
overflow
:
auto
;
padding
:
13px
;
background
:
#fff
;
border-right
:
1px
solid
var
(
--border
)}
.article-list-heading
{
height
:
35px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
padding
:
0
5px
}
.article-list-heading
strong
{
font-size
:
10px
}
.article-list-heading
span
{
display
:
grid
;
place-items
:
center
;
width
:
22px
;
height
:
22px
;
color
:
var
(
--primary
);
background
:
var
(
--soft
);
border-radius
:
7px
;
font-size
:
8px
;
font-weight
:
800
}
.article-list
>
button
{
width
:
100%
;
display
:
grid
;
grid-template-columns
:
minmax
(
0
,
1
fr
)
auto
;
gap
:
6px
;
margin-bottom
:
6px
;
padding
:
11px
;
color
:
var
(
--text
);
text-align
:
left
;
background
:
#fff
;
border
:
1px
solid
transparent
;
border-radius
:
10px
}
.article-list
>
button
:hover
,
.article-list
>
button
.active
{
background
:
#f7f5ff
;
border-color
:
#e1d9ff
}
.article-list
>
button
>
span
{
min-width
:
0
}
.article-list
>
button
strong
,
.article-list
>
button
small
{
display
:
block
;
overflow
:
hidden
;
white-space
:
nowrap
;
text-overflow
:
ellipsis
}
.article-list
>
button
strong
{
font-size
:
9px
}
.article-list
>
button
span
small
{
margin-top
:
4px
;
color
:
var
(
--muted
);
font-size
:
7px
}
.article-list
>
button
>
small
{
grid-column
:
1
/
-1
;
color
:
var
(
--muted
);
font-size
:
7px
}
.article-list
em
{
padding
:
3px
5px
;
border-radius
:
5px
;
font-size
:
7px
;
font-style
:
normal
;
font-weight
:
800
}
.article-list
em
.published
{
color
:
#13765b
;
background
:
#e7f8f2
}
.article-list
em
.draft
{
color
:
#8c6200
;
background
:
#fff7df
}
.article-empty
{
height
:
180px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
flex-direction
:
column
;
gap
:
8px
;
color
:
var
(
--muted
);
font-size
:
9px
}
.article-empty
button
{
padding
:
6px
9px
;
color
:
var
(
--primary
);
background
:
var
(
--soft
);
border
:
0
;
border-radius
:
7px
;
font-size
:
8px
;
font-weight
:
750
}
.article-editor
{
min-width
:
0
;
overflow
:
auto
;
padding
:
22px
}
.article-editor-heading
,
.article-editor-heading
>
div
{
display
:
flex
;
align-items
:
center
}
.article-editor-heading
{
justify-content
:
space-between
;
gap
:
14px
;
margin-bottom
:
14px
}
.article-editor-heading
>
div
:last-child
{
gap
:
6px
}
.article-editor-heading
span
{
color
:
var
(
--primary
);
font-size
:
7px
;
font-weight
:
850
;
letter-spacing
:
.12em
}
.article-editor-heading
h2
{
margin
:
4px
0
0
;
font-size
:
18px
}
.article-editor-heading
button
,
.article-editor-heading
a
{
height
:
31px
;
display
:
flex
;
align-items
:
center
;
gap
:
5px
;
padding
:
0
9px
;
color
:
var
(
--text-2
);
background
:
#fff
;
border
:
1px
solid
var
(
--border
);
border-radius
:
8px
;
font-size
:
8px
;
font-weight
:
750
;
text-decoration
:
none
}
.article-editor-heading
.article-delete
{
color
:
#b42318
}
.article-editor-heading
.article-save
{
color
:
#fff
;
background
:
var
(
--gradient
);
border
:
0
}
.article-notice
{
display
:
flex
;
align-items
:
center
;
gap
:
6px
;
margin-bottom
:
12px
;
padding
:
9px
11px
;
color
:
#13765b
;
background
:
#e9f9f3
;
border-radius
:
9px
;
font-size
:
8px
}
.article-notice.error
{
color
:
#b42318
;
background
:
#fff0ee
}
.article-fields
{
display
:
grid
;
grid-template-columns
:
1
fr
1
fr
;
gap
:
12px
;
padding
:
17px
;
background
:
#fff
;
border
:
1px
solid
var
(
--border
);
border-radius
:
14px
}
.article-fields
label
{
min-width
:
0
;
display
:
flex
;
flex-direction
:
column
;
gap
:
6px
}
.article-fields
label
>
span
,
.article-body-field
>
div
>
span
{
color
:
var
(
--text-2
);
font-size
:
8px
;
font-weight
:
750
}
.article-fields
.wide
{
grid-column
:
1
/
-1
}
.article-fields
input
,
.article-fields
textarea
,
.article-fields
select
,
.article-body-field
textarea
{
width
:
100%
;
padding
:
9px
10px
;
color
:
var
(
--text
);
background
:
#fafafd
;
border
:
1px
solid
var
(
--border
);
border-radius
:
8px
;
outline
:
0
;
font
:
inherit
;
font-size
:
10px
;
line-height
:
1.6
}
.article-fields
input
:focus
,
.article-fields
textarea
:focus
,
.article-fields
select
:focus
,
.article-body-field
textarea
:focus
{
background
:
#fff
;
border-color
:
#a98cff
;
box-shadow
:
0
0
0
3px
rgba
(
112
,
40
,
255
,
.07
)}
.article-body-field
>
div
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
margin-bottom
:
6px
}
.article-body-field
button
{
height
:
28px
;
display
:
flex
;
align-items
:
center
;
gap
:
5px
;
padding
:
0
8px
;
color
:
var
(
--primary
);
background
:
var
(
--soft
);
border
:
1px
solid
#ded5ff
;
border-radius
:
7px
;
font-size
:
8px
;
font-weight
:
750
}
.article-body-field
textarea
{
resize
:
vertical
;
font-family
:
"SFMono-Regular"
,
Consolas
,
monospace
}
.article-seo
{
margin-top
:
12px
;
padding
:
14px
17px
;
background
:
#fff
;
border
:
1px
solid
var
(
--border
);
border-radius
:
14px
}
.article-seo
summary
{
color
:
var
(
--text-2
);
font-size
:
9px
;
font-weight
:
800
;
cursor
:
pointer
}
.article-seo
[
open
]
summary
{
margin-bottom
:
12px
}
.article-seo
.article-fields
{
padding
:
0
;
border
:
0
}
.article-checkbox
{
flex-direction
:
row
!important
;
align-items
:
center
}
.article-checkbox
input
{
width
:
auto
}
.article-side-panel
{
background
:
linear-gradient
(
180deg
,
#fff
,
#f8f5ff
)}
@media
(
max-width
:
1150px
){
.article-layout
{
grid-template-columns
:
210px
minmax
(
0
,
1
fr
)}
.article-fields
{
grid-template-columns
:
1
fr
}
.article-fields
.wide
{
grid-column
:
auto
}
.article-header
p
{
display
:
none
}}
@media
(
max-width
:
800px
){
.article-workspace
{
display
:
none
}
.workspace.articles-mode
.control-panel
{
display
:
none
}
.workspace.articles-mode
.article-workspace
{
display
:
flex
;
grid-column
:
2
}
.article-layout
{
grid-template-columns
:
170px
minmax
(
0
,
1
fr
)}
.article-header
{
padding
:
0
12px
}
.article-editor
{
padding
:
14px
}
.article-editor-heading
{
align-items
:
flex-start
;
flex-direction
:
column
}
.article-editor-heading
>
div
:last-child
{
flex-wrap
:
wrap
}}
.article-editor-heading
>
div
:first-child
{
flex-wrap
:
wrap
}
.article-status-badge
{
margin-left
:
8px
;
padding
:
4px
7px
;
border-radius
:
6px
;
font-size
:
7px
;
font-style
:
normal
;
font-weight
:
800
}
.article-status-badge.draft
{
color
:
#8c6200
;
background
:
#fff7df
}
.article-status-badge.published
{
color
:
#13765b
;
background
:
#e7f8f2
}
.article-editor-heading
.article-save-draft
{
color
:
var
(
--primary
);
background
:
var
(
--soft
);
border-color
:
#d9ceff
}
.article-publish-help
{
display
:
flex
;
flex-direction
:
column
;
justify-content
:
center
;
gap
:
5px
;
padding
:
8px
10px
;
background
:
#fafafd
;
border
:
1px
solid
var
(
--border
);
border-radius
:
8px
}
.article-publish-help
>
span
{
color
:
var
(
--text-2
);
font-size
:
8px
;
font-weight
:
750
}
.article-publish-help
strong
{
font-size
:
10px
}
.article-publish-help
small
{
color
:
var
(
--muted
);
font-size
:
8px
}
.article-editor-only
{
display
:
block
;
overflow
:
auto
}
.article-editor-only
.article-editor
{
width
:
min
(
980px
,
100%
);
margin
:
0
auto
}
.article-side-panel
{
min-height
:
0
;
display
:
flex
;
flex-direction
:
column
;
padding
:
17px
13px
;
background
:
linear-gradient
(
180deg
,
#fff
,
#faf9ff
)}
.article-side-intro
{
display
:
flex
;
align-items
:
center
;
gap
:
10px
;
padding
:
0
4px
}
.article-side-intro
h2
{
margin
:
0
;
font-size
:
15px
}
.article-side-intro
p
{
margin
:
4px
0
0
;
color
:
var
(
--muted
);
font-size
:
8px
;
line-height
:
1.5
}
.article-side-intro
.domain-side-icon
{
width
:
35px
;
height
:
35px
;
flex
:
0
0
auto
}
.article-flow-guide
{
margin-top
:
13px
;
padding
:
11px
;
background
:
#f7f5ff
;
border
:
1px
solid
#e2dcf8
;
border-radius
:
11px
}
.article-flow-guide
>
strong
{
color
:
var
(
--primary
);
font-size
:
8px
}
.article-flow-guide
ol
{
display
:
flex
;
flex-direction
:
column
;
gap
:
7px
;
margin
:
9px
0
0
;
padding
:
0
;
list-style
:
none
}
.article-flow-guide
li
{
display
:
flex
;
align-items
:
flex-start
;
gap
:
7px
}
.article-flow-guide
li
>
b
{
display
:
grid
;
place-items
:
center
;
width
:
18px
;
height
:
18px
;
flex
:
0
0
auto
;
color
:
#fff
;
background
:
var
(
--primary
);
border-radius
:
6px
;
font-size
:
7px
}
.article-flow-guide
li
span
{
font-size
:
8px
;
font-weight
:
750
}
.article-flow-guide
li
small
{
display
:
block
;
margin-top
:
2px
;
color
:
var
(
--muted
);
font-size
:
7px
;
font-weight
:
400
;
line-height
:
1.4
}
.article-batch-card
{
display
:
grid
;
grid-template-columns
:
1
fr
auto
;
gap
:
7px
;
margin-top
:
9px
;
padding
:
10px
;
background
:
#fff
;
border
:
1px
solid
var
(
--border
);
border-radius
:
11px
}
.article-batch-card.pending
{
border-color
:
#decf9a
;
background
:
#fffdf5
}
.article-batch-card
>
div
span
,
.article-batch-card
>
div
small
{
display
:
block
}
.article-batch-card
>
div
span
{
font-size
:
9px
;
font-weight
:
800
}
.article-batch-card
>
div
small
{
margin-top
:
4px
;
color
:
var
(
--muted
);
font-size
:
7px
}
.article-batch-card
button
{
height
:
29px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
gap
:
5px
;
padding
:
0
8px
;
color
:
var
(
--primary
);
background
:
var
(
--soft
);
border
:
1px
solid
#ddd4ff
;
border-radius
:
7px
;
font-size
:
7px
;
font-weight
:
800
}
.article-batch-card
button
:disabled
{
opacity
:
.5
}
.article-batch-card
.article-go-live
{
grid-column
:
1
/
-1
;
color
:
#fff
;
background
:
var
(
--gradient
);
border
:
0
}
.article-side-notice
{
margin-top
:
8px
;
padding
:
7px
9px
;
color
:
#13765b
;
background
:
#e9f9f3
;
border-radius
:
7px
;
font-size
:
7px
}
.article-side-notice.error
{
color
:
#b42318
;
background
:
#fff0ee
}
.article-side-tools
{
display
:
grid
;
grid-template-columns
:
1
fr
1
fr
;
gap
:
6px
;
margin-top
:
10px
}
.article-side-tools
button
,
.article-side-tools
label
{
height
:
29px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
gap
:
5px
;
color
:
var
(
--text-2
);
background
:
#fff
;
border
:
1px
solid
var
(
--border
);
border-radius
:
7px
;
font-size
:
7px
;
font-weight
:
750
;
cursor
:
pointer
}
.article-side-tools
input
{
display
:
none
}
.article-side-list-heading
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
margin
:
13px
3px
7px
}
.article-side-list-heading
strong
{
font-size
:
9px
}
.article-side-list-heading
span
{
color
:
var
(
--muted
);
font-size
:
7px
}
.article-side-list
{
min-height
:
0
;
overflow
:
auto
;
display
:
flex
;
flex-direction
:
column
;
gap
:
4px
}
.article-side-list
>
button
{
width
:
100%
;
display
:
grid
;
grid-template-columns
:
minmax
(
0
,
1
fr
)
auto
;
gap
:
4px
;
padding
:
8px
;
color
:
var
(
--text
);
text-align
:
left
;
background
:
#fff
;
border
:
1px
solid
transparent
;
border-radius
:
8px
}
.article-side-list
>
button
:hover
,
.article-side-list
>
button
.active
{
background
:
#f5f2ff
;
border-color
:
#ddd4ff
}
.article-side-list
>
button
>
span
{
min-width
:
0
}
.article-side-list
strong
,
.article-side-list
small
{
display
:
block
;
overflow
:
hidden
;
white-space
:
nowrap
;
text-overflow
:
ellipsis
}
.article-side-list
strong
{
font-size
:
8px
}
.article-side-list
small
{
margin-top
:
3px
;
color
:
var
(
--muted
);
font-size
:
6px
}
.article-side-list
em
{
padding
:
3px
4px
;
border-radius
:
4px
;
font-size
:
6px
;
font-style
:
normal
;
font-weight
:
800
}
.article-side-list
em
.draft
{
color
:
#8c6200
;
background
:
#fff7df
}
.article-side-list
em
.published
{
color
:
#13765b
;
background
:
#e7f8f2
}
.article-side-list
i
{
grid-column
:
1
/
-1
;
width
:
max-content
;
color
:
#8c6200
;
font-size
:
6px
;
font-style
:
normal
;
font-weight
:
800
}
pnpm-lock.yaml
View file @
467e65cb
...
...
@@ -17,6 +17,9 @@ importers:
'
@astrojs/check'
:
specifier
:
^0.9.4
version
:
0.9.9(prettier@3.9.5)(typescript@5.8.3)
'
@types/node'
:
specifier
:
^22.15.3
version
:
22.20.1
astro
:
specifier
:
^5.7.0
version
:
5.18.2(@types/node@22.20.1)(rollup@4.62.2)(tsx@4.23.1)(typescript@5.8.3)(yaml@2.9.0)
...
...
shared/src/index.ts
View file @
467e65cb
...
...
@@ -222,6 +222,7 @@ export interface ArticleInput {
export
interface
ArticleSummary
extends
Omit
<
ArticleInput
,
"body"
>
{
wordCount
:
number
;
pendingBuild
:
boolean
;
}
export
interface
ArticleDocument
extends
ArticleInput
{
...
...
@@ -231,12 +232,33 @@ export interface ArticleDocument extends ArticleInput {
export
interface
ArticleListResult
{
supported
:
boolean
;
articles
:
ArticleSummary
[];
pendingBuild
:
boolean
;
pendingCount
:
number
;
lastCompiledAt
?:
string
;
}
export
interface
ArticleMutationResult
{
article
?:
ArticleDocument
;
deletedSlug
?:
string
;
previewUrl
:
string
;
pendingBuild
:
boolean
;
}
export
interface
ArticleCompileResult
{
previewUrl
:
string
;
compiledAt
:
string
;
articleCount
:
number
;
}
export
interface
ArticleImageUploadInput
{
filename
:
string
;
dataUrl
:
string
;
}
export
interface
ArticleImageUploadResult
{
path
:
string
;
filename
:
string
;
size
:
number
;
}
export
type
DomainOwnershipStatus
=
"pending"
|
"verified"
|
"failed"
;
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment