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
bb9d04f4
Commit
bb9d04f4
authored
Jul 22, 2026
by
xuchentao
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
优化文章双版本管理流程
parent
467e65cb
Changes
12
Show whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
305 additions
and
49 deletions
+305
-49
article-service.test.ts
backend/src/articles/article-service.test.ts
+23
-5
article-service.ts
backend/src/articles/article-service.ts
+41
-8
article-storage.ts
backend/src/articles/article-storage.ts
+51
-0
domain-deployment-service.ts
backend/src/domains/domain-deployment-service.ts
+1
-1
server.ts
backend/src/server.ts
+3
-0
create-site.ts
backend/src/sites/create-site.ts
+2
-1
site-repository.ts
backend/src/sites/site-repository.ts
+26
-0
site-version-service.ts
backend/src/sites/site-version-service.ts
+2
-0
App.tsx
frontend/src/App.tsx
+149
-34
api.ts
frontend/src/api.ts
+1
-0
styles.css
frontend/src/styles.css
+2
-0
index.ts
shared/src/index.ts
+4
-0
No files found.
backend/src/articles/article-service.test.ts
View file @
bb9d04f4
...
...
@@ -8,6 +8,7 @@ import type { BuildManager, SiteBuildContext } from "../build/build-manager.js";
import
type
{
PreviewProcessManager
}
from
"../preview/preview-process-manager.js"
;
import
type
{
SiteRepository
}
from
"../sites/site-repository.js"
;
import
{
ArticleService
}
from
"./article-service.js"
;
import
{
syncPublishedArticles
}
from
"./article-storage.js"
;
const
exists
=
(
target
:
string
)
=>
access
(
target
).
then
(()
=>
true
).
catch
(()
=>
false
);
...
...
@@ -15,6 +16,7 @@ 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
liveArticles
=
path
.
join
(
root
,
"content"
,
"live-articles"
);
const
images
=
path
.
join
(
root
,
"content"
,
"images"
);
const
articleState
=
path
.
join
(
root
,
"metadata"
,
"articles.json"
);
const
dist
=
path
.
join
(
root
,
"dist"
);
...
...
@@ -38,6 +40,7 @@ test("article mutations use external content storage without creating Git-manage
getProjectPath
:
()
=>
project
,
getDraftPath
:
()
=>
path
.
join
(
root
,
"draft"
),
getArticlesPath
:
()
=>
articles
,
getLiveArticlesPath
:
()
=>
liveArticles
,
getArticleImagesPath
:
()
=>
images
,
getArticleStatePath
:
()
=>
articleState
,
markArticlesCompiled
:
async
()
=>
{
...
...
@@ -69,16 +72,31 @@ test("article mutations use external content storage without creating Git-manage
assert
.
equal
(
await
exists
(
path
.
join
(
articles
,
"hello.md"
)),
true
);
assert
.
equal
(
await
exists
(
path
.
join
(
project
,
"src"
,
"content"
,
"articles"
)),
false
);
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"
});
const
service
=
new
ArticleService
(
sites
,
builds
,
previews
);
const
initialList
=
await
service
.
list
(
"tenant_test"
,
"site_test"
);
assert
.
equal
(
initialList
.
pendingBuild
,
true
);
assert
.
equal
(
initialList
.
articles
[
0
]?.
liveState
,
"draft"
);
const
published
=
await
service
.
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"
);
await
service
.
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"
,
{
assert
.
equal
((
await
service
.
list
(
"tenant_test"
,
"site_test"
)).
pendingBuild
,
false
);
await
syncPublishedArticles
(
articles
,
liveArticles
);
assert
.
equal
((
await
service
.
list
(
"tenant_test"
,
"site_test"
)).
articles
[
0
]?.
liveState
,
"live"
);
await
service
.
update
(
"tenant_test"
,
"site_test"
,
"hello"
,
{
...
article
,
body
:
"Updated draft"
,
status
:
"published"
});
assert
.
equal
((
await
service
.
list
(
"tenant_test"
,
"site_test"
)).
articles
[
0
]?.
liveState
,
"modified"
);
await
service
.
restoreLive
(
"tenant_test"
,
"site_test"
,
"hello"
);
assert
.
equal
((
await
service
.
list
(
"tenant_test"
,
"site_test"
)).
articles
[
0
]?.
liveState
,
"live"
);
const
pendingRemoval
=
await
service
.
remove
(
"tenant_test"
,
"site_test"
,
"hello"
);
assert
.
equal
(
pendingRemoval
.
article
?.
liveState
,
"pending-removal"
);
assert
.
equal
(
await
exists
(
path
.
join
(
articles
,
"hello.md"
)),
true
);
await
syncPublishedArticles
(
articles
,
liveArticles
);
assert
.
equal
(
await
exists
(
path
.
join
(
liveArticles
,
"hello.md"
)),
false
);
assert
.
equal
((
await
service
.
list
(
"tenant_test"
,
"site_test"
)).
articles
[
0
]?.
liveState
,
"draft"
);
const
uploaded
=
await
service
.
uploadImage
(
"tenant_test"
,
"site_test"
,
{
filename
:
"pixel.png"
,
dataUrl
:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
,
});
...
...
backend/src/articles/article-service.ts
View file @
bb9d04f4
...
...
@@ -8,9 +8,11 @@ import { getPublicPreviewUrl } from "../config.js";
import
{
PreviewProcessManager
}
from
"../preview/preview-process-manager.js"
;
import
{
articleInputSchema
}
from
"../schemas.js"
;
import
{
SiteRepository
}
from
"../sites/site-repository.js"
;
import
{
articleLiveState
}
from
"./article-storage.js"
;
type
Frontmatter
=
Record
<
string
,
unknown
>
;
type
ArticleBuildState
=
{
pendingSlugs
:
string
[];
lastCompiledAt
?:
string
};
type
StoredArticle
=
Omit
<
ArticleDocument
,
"liveState"
>
;
export
class
ArticleService
{
private
readonly
queues
=
new
Map
<
string
,
Promise
<
unknown
>>
();
...
...
@@ -23,13 +25,19 @@ 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
:
[],
pendingBuild
:
false
,
pendingCount
:
0
};
if
(
!
await
this
.
supported
(
this
.
sites
.
getProjectPath
(
tenantId
,
siteId
)))
return
{
supported
:
false
,
articles
:
[],
pendingBuild
:
false
,
pendingCount
:
0
,
pendingLiveCount
:
0
};
const
directory
=
this
.
sites
.
getArticlesPath
(
tenantId
,
siteId
);
await
mkdir
(
directory
,
{
recursive
:
true
});
const
liveDirectory
=
this
.
sites
.
getLiveArticlesPath
(
tenantId
,
siteId
);
await
Promise
.
all
([
mkdir
(
directory
,
{
recursive
:
true
}),
mkdir
(
liveDirectory
,
{
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
articles
=
await
Promise
.
all
(
files
.
map
(
async
(
file
)
=>
{
const
slug
=
file
.
replace
(
/
\.
md$/i
,
""
);
const
draftFile
=
path
.
join
(
directory
,
file
);
const
article
=
await
this
.
readArticleFile
(
draftFile
,
slug
);
return
{
...
article
,
liveState
:
await
articleLiveState
(
draftFile
,
this
.
articlePath
(
liveDirectory
,
slug
),
article
.
status
)
};
}));
const
state
=
await
this
.
readState
(
tenantId
,
siteId
);
const
pending
=
new
Set
(
state
.
pendingSlugs
);
return
{
...
...
@@ -37,6 +45,7 @@ export class ArticleService {
pendingBuild
:
pending
.
size
>
0
,
pendingCount
:
pending
.
size
,
lastCompiledAt
:
state
.
lastCompiledAt
,
pendingLiveCount
:
articles
.
filter
((
article
)
=>
article
.
liveState
===
"modified"
||
article
.
liveState
===
"pending-removal"
||
(
article
.
liveState
===
"draft"
&&
article
.
status
===
"published"
)).
length
,
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
||
""
;
...
...
@@ -49,7 +58,9 @@ export class ArticleService {
this
.
assertSlug
(
slug
);
await
this
.
sites
.
get
(
tenantId
,
siteId
);
await
this
.
assertSupported
(
this
.
sites
.
getProjectPath
(
tenantId
,
siteId
));
return
this
.
readArticleFile
(
this
.
articlePath
(
this
.
sites
.
getArticlesPath
(
tenantId
,
siteId
),
slug
),
slug
);
const
draftFile
=
this
.
articlePath
(
this
.
sites
.
getArticlesPath
(
tenantId
,
siteId
),
slug
);
const
article
=
await
this
.
readArticleFile
(
draftFile
,
slug
);
return
{
...
article
,
liveState
:
await
articleLiveState
(
draftFile
,
this
.
articlePath
(
this
.
sites
.
getLiveArticlesPath
(
tenantId
,
siteId
),
slug
),
article
.
status
)
};
}
async
create
(
tenantId
:
string
,
siteId
:
string
,
input
:
ArticleInput
):
Promise
<
ArticleMutationResult
>
{
...
...
@@ -86,11 +97,29 @@ export class ArticleService {
const
source
=
this
.
articlePath
(
directory
,
slug
);
if
(
!
await
this
.
exists
(
source
))
throw
Object
.
assign
(
new
Error
(
"文章不存在"
),
{
statusCode
:
404
});
const
article
=
await
this
.
readArticleFile
(
source
,
slug
);
const
liveFile
=
this
.
articlePath
(
this
.
sites
.
getLiveArticlesPath
(
tenantId
,
siteId
),
slug
);
if
(
await
this
.
exists
(
liveFile
))
{
const
next
=
{
...
article
,
status
:
"draft"
as
const
};
await
writeFile
(
source
,
serializeArticle
(
next
),
"utf8"
);
return
{
article
:
next
,
changedSlugs
:
[
slug
]
};
}
await
rm
(
source
,
{
force
:
true
});
return
{
deletedSlug
:
slug
,
changedSlugs
:
[
slug
]
};
});
}
async
restoreLive
(
tenantId
:
string
,
siteId
:
string
,
slug
:
string
):
Promise
<
ArticleMutationResult
>
{
this
.
assertSlug
(
slug
);
return
this
.
mutate
(
tenantId
,
siteId
,
async
(
directory
)
=>
{
const
liveFile
=
this
.
articlePath
(
this
.
sites
.
getLiveArticlesPath
(
tenantId
,
siteId
),
slug
);
if
(
!
await
this
.
exists
(
liveFile
))
throw
Object
.
assign
(
new
Error
(
"文章还没有线上版本"
),
{
statusCode
:
409
});
const
destination
=
this
.
articlePath
(
directory
,
slug
);
await
cp
(
liveFile
,
destination
);
const
{
wordCount
:
_wordCount
,
...
article
}
=
await
this
.
readArticleFile
(
liveFile
,
slug
);
return
{
article
,
changedSlugs
:
[
slug
]
};
});
}
async
importMarkdown
(
tenantId
:
string
,
siteId
:
string
,
filename
:
string
,
markdown
:
string
):
Promise
<
ArticleMutationResult
>
{
const
parsed
=
importedArticle
(
filename
,
markdown
);
return
this
.
create
(
tenantId
,
siteId
,
articleInputSchema
.
parse
(
parsed
));
...
...
@@ -178,9 +207,7 @@ export class ArticleService {
swapped
=
true
;
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
;
const
article
=
changed
.
article
?
await
this
.
articleDocument
(
tenantId
,
siteId
,
changed
.
article
.
slug
)
:
undefined
;
return
{
article
,
deletedSlug
:
changed
.
deletedSlug
,
previewUrl
:
site
.
previewUrl
,
pendingBuild
:
true
};
}
catch
(
error
)
{
if
(
swapped
)
{
...
...
@@ -204,7 +231,13 @@ export class ArticleService {
return
draft
;
}
private
async
readArticleFile
(
file
:
string
,
slug
:
string
):
Promise
<
ArticleDocument
>
{
private
async
articleDocument
(
tenantId
:
string
,
siteId
:
string
,
slug
:
string
):
Promise
<
ArticleDocument
>
{
const
draftFile
=
this
.
articlePath
(
this
.
sites
.
getArticlesPath
(
tenantId
,
siteId
),
slug
);
const
article
=
await
this
.
readArticleFile
(
draftFile
,
slug
);
return
{
...
article
,
liveState
:
await
articleLiveState
(
draftFile
,
this
.
articlePath
(
this
.
sites
.
getLiveArticlesPath
(
tenantId
,
siteId
),
slug
),
article
.
status
)
};
}
private
async
readArticleFile
(
file
:
string
,
slug
:
string
):
Promise
<
StoredArticle
>
{
try
{
const
parsed
=
parseMarkdown
(
await
readFile
(
file
,
"utf8"
));
const
input
=
articleInputSchema
.
parse
({
slug
,
...
parsed
.
data
,
body
:
parsed
.
body
});
...
...
backend/src/articles/article-storage.ts
0 → 100644
View file @
bb9d04f4
import
crypto
from
"node:crypto"
;
import
path
from
"node:path"
;
import
{
access
,
cp
,
mkdir
,
readFile
,
readdir
,
rename
,
rm
}
from
"node:fs/promises"
;
import
{
parse
as
parseYaml
}
from
"yaml"
;
import
type
{
ArticleLiveState
,
ArticleStatus
}
from
"@webagent/shared"
;
export
async
function
articleLiveState
(
draftFile
:
string
,
liveFile
:
string
,
status
:
ArticleStatus
):
Promise
<
ArticleLiveState
>
{
if
(
!
await
exists
(
liveFile
))
return
"draft"
;
if
(
status
!==
"published"
)
return
"pending-removal"
;
const
[
draft
,
live
]
=
await
Promise
.
all
([
readFile
(
draftFile
,
"utf8"
),
readFile
(
liveFile
,
"utf8"
)]);
return
draft
===
live
?
"live"
:
"modified"
;
}
export
async
function
syncPublishedArticles
(
draftDirectory
:
string
,
liveDirectory
:
string
):
Promise
<
void
>
{
const
suffix
=
crypto
.
randomBytes
(
5
).
toString
(
"hex"
);
const
staging
=
path
.
join
(
path
.
dirname
(
liveDirectory
),
`.live-articles-next-
${
suffix
}
`
);
const
previous
=
path
.
join
(
path
.
dirname
(
liveDirectory
),
`.live-articles-previous-
${
suffix
}
`
);
await
mkdir
(
draftDirectory
,
{
recursive
:
true
});
await
mkdir
(
staging
,
{
recursive
:
true
});
const
entries
=
await
readdir
(
draftDirectory
,
{
withFileTypes
:
true
});
for
(
const
entry
of
entries
)
{
if
(
!
entry
.
isFile
()
||
entry
.
name
.
startsWith
(
"_"
)
||
!
/
\.
md$/i
.
test
(
entry
.
name
))
continue
;
const
source
=
path
.
join
(
draftDirectory
,
entry
.
name
);
if
(
articleStatus
(
await
readFile
(
source
,
"utf8"
))
!==
"published"
)
continue
;
await
cp
(
source
,
path
.
join
(
staging
,
entry
.
name
));
}
const
hasCurrent
=
await
exists
(
liveDirectory
);
if
(
hasCurrent
)
await
rename
(
liveDirectory
,
previous
);
try
{
await
rename
(
staging
,
liveDirectory
);
}
catch
(
error
)
{
if
(
hasCurrent
)
await
rename
(
previous
,
liveDirectory
).
catch
(()
=>
undefined
);
await
rm
(
staging
,
{
recursive
:
true
,
force
:
true
});
throw
error
;
}
await
rm
(
previous
,
{
recursive
:
true
,
force
:
true
});
}
function
articleStatus
(
markdown
:
string
):
ArticleStatus
{
const
normalized
=
markdown
.
replaceAll
(
"
\
r
\n
"
,
"
\n
"
);
if
(
!
normalized
.
startsWith
(
"---
\n
"
))
return
"draft"
;
const
boundary
=
normalized
.
indexOf
(
"
\n
---"
,
4
);
if
(
boundary
<
0
)
return
"draft"
;
const
data
=
parseYaml
(
normalized
.
slice
(
4
,
boundary
));
return
data
&&
typeof
data
===
"object"
&&
"status"
in
data
&&
data
.
status
===
"published"
?
"published"
:
"draft"
;
}
function
exists
(
target
:
string
):
Promise
<
boolean
>
{
return
access
(
target
).
then
(()
=>
true
).
catch
(()
=>
false
);
}
backend/src/domains/domain-deployment-service.ts
View file @
bb9d04f4
...
...
@@ -45,7 +45,7 @@ export class DomainDeploymentService {
||
bindings
.
find
((
domain
)
=>
domain
.
ownershipStatus
===
"verified"
);
const
dist
=
await
this
.
builds
.
build
(
workspace
,
taskId
,
{
basePath
:
"/"
,
publicOrigin
:
primary
?
`https://
${
primary
.
hostname
}
`
:
undefined
,
indexable
:
true
,
articlesDirectory
:
this
.
sites
.
getArticlesPath
(
tenantId
,
siteId
),
articlesDirectory
:
this
.
sites
.
get
Live
ArticlesPath
(
tenantId
,
siteId
),
articleImagesDirectory
:
this
.
sites
.
getArticleImagesPath
(
tenantId
,
siteId
),
});
await
this
.
builds
.
publishCustomDomain
(
tenantId
,
siteId
,
dist
);
...
...
backend/src/server.ts
View file @
bb9d04f4
...
...
@@ -181,6 +181,9 @@ app.put<{ Params: { siteId: string; slug: string } }>("/api/sites/:siteId/articl
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
;
slug
:
string
}
}
>
(
"/api/sites/:siteId/articles/:slug/restore-live"
,
async
(
request
)
=>
(
articles
.
restoreLive
(
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
));
...
...
backend/src/sites/create-site.ts
View file @
bb9d04f4
...
...
@@ -28,8 +28,9 @@ export class CreateSiteService {
createdAt
:
now
,
updatedAt
:
now
,
};
const
articlesDirectory
=
this
.
sites
.
getArticlesPath
(
tenantId
,
siteId
);
const
liveArticlesDirectory
=
this
.
sites
.
getLiveArticlesPath
(
tenantId
,
siteId
);
const
articleImagesDirectory
=
this
.
sites
.
getArticleImagesPath
(
tenantId
,
siteId
);
await
Promise
.
all
([
mkdir
(
projectPath
,
{
recursive
:
true
}),
mkdir
(
articlesDirectory
,
{
recursive
:
true
}),
mkdir
(
articleImagesDirectory
,
{
recursive
:
true
})]);
await
Promise
.
all
([
mkdir
(
projectPath
,
{
recursive
:
true
}),
mkdir
(
articlesDirectory
,
{
recursive
:
true
}),
mkdir
(
liveArticlesDirectory
,
{
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"
,
"examples"
].
includes
(
path
.
basename
(
source
))
});
...
...
backend/src/sites/site-repository.ts
View file @
bb9d04f4
...
...
@@ -2,6 +2,7 @@ import path from "node:path";
import
{
cp
,
mkdir
,
readFile
,
readdir
,
rename
,
rm
,
stat
,
writeFile
}
from
"node:fs/promises"
;
import
type
{
SiteInfo
}
from
"@webagent/shared"
;
import
{
config
,
getPublicPreviewUrl
,
getPublicProductionUrl
,
runtimePaths
}
from
"../config.js"
;
import
{
syncPublishedArticles
}
from
"../articles/article-storage.js"
;
export
class
SiteRepository
{
private
runtimeReady
?:
Promise
<
void
>
;
...
...
@@ -40,6 +41,10 @@ export class SiteRepository {
return
path
.
join
(
this
.
getSiteRoot
(
tenantId
,
siteId
),
"content"
,
"articles"
);
}
getLiveArticlesPath
(
tenantId
:
string
,
siteId
:
string
):
string
{
return
path
.
join
(
this
.
getSiteRoot
(
tenantId
,
siteId
),
"content"
,
"live-articles"
);
}
getArticleImagesPath
(
tenantId
:
string
,
siteId
:
string
):
string
{
return
path
.
join
(
this
.
getSiteRoot
(
tenantId
,
siteId
),
"content"
,
"images"
);
}
...
...
@@ -155,6 +160,7 @@ export class SiteRepository {
]);
await
this
.
migrateLegacySites
();
await
this
.
migrateFlatSites
();
await
this
.
migrateArticleLiveSlots
();
await
Promise
.
all
([
this
.
migrateLegacyPreviews
(),
this
.
migrateFlatArtifacts
(
config
.
previewsDir
),
...
...
@@ -163,6 +169,26 @@ export class SiteRepository {
]);
}
private
async
migrateArticleLiveSlots
():
Promise
<
void
>
{
const
tenants
=
await
readdir
(
runtimePaths
.
sites
,
{
withFileTypes
:
true
}).
catch
(()
=>
[]);
for
(
const
tenant
of
tenants
)
{
if
(
!
tenant
.
isDirectory
()
||
!
/^tenant_
[
a-z0-9_
]
+$/
.
test
(
tenant
.
name
))
continue
;
const
tenantRoot
=
path
.
join
(
runtimePaths
.
sites
,
tenant
.
name
);
const
siteEntries
=
await
readdir
(
tenantRoot
,
{
withFileTypes
:
true
}).
catch
(()
=>
[]);
for
(
const
entry
of
siteEntries
)
{
if
(
!
entry
.
isDirectory
()
||
!
/^site_
[
a-z0-9
]
+$/
.
test
(
entry
.
name
))
continue
;
const
siteRoot
=
path
.
join
(
tenantRoot
,
entry
.
name
);
const
metadata
=
await
readFile
(
path
.
join
(
siteRoot
,
"metadata"
,
"site.json"
),
"utf8"
)
.
then
((
raw
)
=>
JSON
.
parse
(
raw
)
as
SiteInfo
)
.
catch
(()
=>
undefined
);
if
(
!
metadata
?.
publishedCommit
)
continue
;
const
liveDirectory
=
path
.
join
(
siteRoot
,
"content"
,
"live-articles"
);
if
(
await
stat
(
liveDirectory
).
then
(()
=>
true
).
catch
(()
=>
false
))
continue
;
await
syncPublishedArticles
(
path
.
join
(
siteRoot
,
"content"
,
"articles"
),
liveDirectory
);
}
}
}
private
async
migrateLegacySites
():
Promise
<
void
>
{
const
legacySitesDir
=
path
.
join
(
config
.
runtimeDir
,
"sites"
);
if
(
path
.
resolve
(
legacySitesDir
)
===
path
.
resolve
(
runtimePaths
.
sites
))
return
;
...
...
backend/src/sites/site-version-service.ts
View file @
bb9d04f4
...
...
@@ -8,6 +8,7 @@ import { GitManager } from "../git/git-manager.js";
import
{
PreviewProcessManager
}
from
"../preview/preview-process-manager.js"
;
import
{
ensureEnvironmentConfig
}
from
"./ensure-environment-config.js"
;
import
{
SiteRepository
}
from
"./site-repository.js"
;
import
{
syncPublishedArticles
}
from
"../articles/article-storage.js"
;
export
class
SiteVersionService
{
constructor
(
...
...
@@ -180,6 +181,7 @@ export class SiteVersionService {
articleImagesDirectory
:
this
.
sites
.
getArticleImagesPath
(
tenantId
,
siteId
),
});
await
this
.
builds
.
publishProduction
(
tenantId
,
siteId
,
dist
);
await
syncPublishedArticles
(
this
.
sites
.
getArticlesPath
(
tenantId
,
siteId
),
this
.
sites
.
getLiveArticlesPath
(
tenantId
,
siteId
));
const
publishedAt
=
new
Date
().
toISOString
();
const
productionUrl
=
getPublicProductionUrl
(
tenantId
,
siteId
);
await
this
.
sites
.
update
(
tenantId
,
siteId
,
{
...
...
frontend/src/App.tsx
View file @
bb9d04f4
...
...
@@ -6,7 +6,7 @@ import {
Plus
,
RefreshCw
,
Rocket
,
RotateCcw
,
Search
,
Send
,
Settings2
,
ShieldCheck
,
Smartphone
,
Sparkles
,
Trash2
,
FileText
,
ImagePlus
,
Save
,
Upload
,
WandSparkles
,
X
,
}
from
"lucide-react"
;
import
type
{
AgentRunEvent
,
ArticleInput
,
CreateSiteInput
,
CreateTenantInput
,
DomainBinding
,
GitHistoryItem
,
SessionInfo
,
SiteInfo
,
TenantAdminInfo
}
from
"@webagent/shared"
;
import
type
{
AgentRunEvent
,
ArticleInput
,
ArticleSummary
,
CreateSiteInput
,
CreateTenantInput
,
DomainBinding
,
GitHistoryItem
,
SessionInfo
,
SiteInfo
,
TenantAdminInfo
}
from
"@webagent/shared"
;
import
{
api
}
from
"./api"
;
type
ChatMessage
=
{
role
:
"user"
|
"agent"
;
text
:
string
;
meta
?:
string
};
...
...
@@ -99,7 +99,7 @@ function LoginPage({ onAuthenticated }: { onAuthenticated: () => void }) {
<
p
>
登录你的官网工作台,在一个空间内完成创建、编辑、版本预览与上线。
</
p
>
<
div
className=
"feature-row"
>
<
div
><
span
><
Bot
size=
{
18
}
/></
span
><
strong
>
Agent 自由创作
</
strong
><
small
>
从文案到布局,持续迭代
</
small
></
div
>
<
div
><
span
><
ShieldCheck
size=
{
18
}
/></
span
><
strong
>
草稿
自动保存
</
strong
><
small
>
未完成修改随时继续
</
small
></
div
>
<
div
><
span
><
ShieldCheck
size=
{
18
}
/></
span
><
strong
>
草稿
安全保护
</
strong
><
small
>
离开前提醒未保存修改
</
small
></
div
>
<
div
><
span
><
History
size=
{
18
}
/></
span
><
strong
>
版本随时回看
</
strong
><
small
>
测试与线上相互隔离
</
small
></
div
>
</
div
>
</
section
>
...
...
@@ -464,6 +464,14 @@ type WorkspaceDialog =
| { kind: "publish" }
| { kind: "publish-blocked"; title: string; description: string; goToVersions: boolean };
type ArticleNavigation =
| { kind: "select"; slug: string }
| { kind: "create" }
| { kind: "tab"; tab: "chat" | "history" | "articles" | "domains" }
| { kind: "site"; siteId: string }
| { kind: "create-site" }
| { kind: "manage" };
function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage, onLogout }: { siteId: string; sites: SiteInfo[]; agentMode: "model" | "local"; onSelectSite: (id: string) => void; onCreate: () => void; onManage: () => void; onLogout: () => void }) {
const queryClient = useQueryClient();
const siteQuery = useQuery({ queryKey: ["site", siteId], queryFn: () => api.site(siteId) });
...
...
@@ -471,6 +479,8 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage,
const [tab, setTab] = useState<"chat" | "history" | "articles" | "domains">("chat");
const [selectedArticleSlug, setSelectedArticleSlug] = useState("");
const [creatingArticle, setCreatingArticle] = useState(false);
const [articleDirty, setArticleDirty] = useState(false);
const [articleNavigation, setArticleNavigation] = useState<ArticleNavigation>();
const [device, setDevice] = useState<"desktop" | "mobile">("desktop");
const [environment, setEnvironment] = useState<"preview" | "production">("preview");
const [pendingPreviewCommit, setPendingPreviewCommit] = useState("");
...
...
@@ -491,6 +501,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage,
useEffect(() => {
setEnvironment("preview");
setSelectedArticleSlug(""); setCreatingArticle(false);
setArticleDirty(false); setArticleNavigation(undefined);
setPendingPreviewCommit("");
setMessages([{ role: "agent", text: "已切换到这个网站。你可以继续描述修改要求。", meta: agentMode === "model" ? "模型 Agent 已连接" : "本地演示 Agent" }]);
}, [siteId, agentMode]);
...
...
@@ -632,16 +643,32 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage,
publishMutation
.
reset
();
setWorkspaceDialog
({
kind
:
"publish"
});
};
const
runArticleNavigation
=
(
navigation
:
ArticleNavigation
)
=>
{
setArticleDirty
(
false
);
if
(
navigation
.
kind
===
"select"
)
{
setSelectedArticleSlug
(
navigation
.
slug
);
setCreatingArticle
(
false
);
}
if
(
navigation
.
kind
===
"create"
)
{
setSelectedArticleSlug
(
""
);
setCreatingArticle
(
true
);
}
if
(
navigation
.
kind
===
"tab"
)
{
setTab
(
navigation
.
tab
);
if
(
navigation
.
tab
===
"history"
)
{
void
siteQuery
.
refetch
();
void
historyQuery
.
refetch
();
}
}
if
(
navigation
.
kind
===
"site"
)
onSelectSite
(
navigation
.
siteId
);
if
(
navigation
.
kind
===
"create-site"
)
onCreate
();
if
(
navigation
.
kind
===
"manage"
)
onManage
();
};
const
requestArticleNavigation
=
(
navigation
:
ArticleNavigation
)
=>
{
if
(
tab
===
"articles"
&&
articleDirty
)
{
setArticleNavigation
(
navigation
);
return
;
}
runArticleNavigation
(
navigation
);
};
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"
&&
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
>
<
button
className=
"rail-create-top"
onClick=
{
()
=>
requestArticleNavigation
({
kind
:
"create-site"
})
}
title=
"创建官网"
data
-
tooltip=
"创建官网"
aria
-
label=
"创建官网"
><
Plus
size=
{
18
}
/></
button
>
<
div
className=
"rail-nav"
><
button
className=
{
tab
!==
"domains"
&&
tab
!==
"articles"
?
"active"
:
""
}
onClick=
{
()
=>
requestArticleNavigation
({
kind
:
"tab"
,
tab
:
"chat"
})
}
title=
"Agent 工作台"
data
-
tooltip=
"Agent 工作台"
aria
-
label=
"Agent 工作台"
><
Monitor
size=
{
18
}
/></
button
><
button
onClick=
{
()
=>
requestArticleNavigation
({
kind
:
"manage"
})
}
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
===
"articles"
?
"active"
:
""
}
onClick=
{
()
=>
setTab
(
"articles"
)
}
><
BookOpen
size=
{
15
}
/>
文章
</
button
><
button
className=
{
tab
===
"domains"
?
"active"
:
""
}
onClick=
{
()
=>
setTab
(
"domains"
)
}
><
Globe2
size=
{
15
}
/>
域名
</
button
></
div
>
<
SiteSwitcher
site=
{
site
}
sites=
{
sites
}
onSelect=
{
(
id
)
=>
requestArticleNavigation
({
kind
:
"site"
,
siteId
:
id
})
}
/>
<
div
className=
"panel-tabs"
><
button
className=
{
tab
===
"chat"
?
"active"
:
""
}
onClick=
{
()
=>
requestArticleNavigation
({
kind
:
"tab"
,
tab
:
"chat"
})
}
><
MessageSquareText
size=
{
15
}
/>
Agent
</
button
><
button
className=
{
tab
===
"history"
?
"active"
:
""
}
onClick=
{
()
=>
requestArticleNavigation
({
kind
:
"tab"
,
tab
:
"history"
})
}
><
History
size=
{
15
}
/>
版本
</
button
><
button
className=
{
tab
===
"articles"
?
"active"
:
""
}
onClick=
{
()
=>
requestArticleNavigation
({
kind
:
"tab"
,
tab
:
"articles"
})
}
><
BookOpen
size=
{
15
}
/>
文章
</
button
><
button
className=
{
tab
===
"domains"
?
"active"
:
""
}
onClick=
{
()
=>
requestArticleNavigation
({
kind
:
"tab"
,
tab
:
"domains"
}
)
}
><
Globe2
size=
{
15
}
/>
域名
</
button
></
div
>
{
tab
===
"chat"
?
<>
<
div
className=
"chat-scroll"
ref=
{
scrollRef
}
>
<
div
className=
"chat-date"
>
今天
</
div
>
...
...
@@ -683,9 +710,9 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage,
</
div
>
</
article
>;
})
}
</
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
}
/>
}
</
div
>
:
tab
===
"articles"
?
<
ArticleSidePanel
site=
{
site
}
selectedSlug=
{
selectedArticleSlug
}
creating=
{
creatingArticle
}
articleDirty=
{
articleDirty
}
onSelect=
{
(
slug
)
=>
requestArticleNavigation
({
kind
:
"select"
,
slug
})
}
onCreate=
{
()
=>
requestArticleNavigation
({
kind
:
"create"
})
}
onUpdated=
{
refreshData
}
/>
:
<
DomainSidePanel
site=
{
site
}
/>
}
</
section
>
{
tab
===
"domains"
?
<
DomainManagement
site=
{
site
}
/>
:
tab
===
"articles"
?
<
ArticleManagement
site=
{
site
}
selectedSlug=
{
selectedArticleSlug
}
creating=
{
creatingArticle
}
onSelect=
{
setSelectedArticleSlug
}
onCreating=
{
setCreatingArticle
}
/>
:
<
section
className=
"preview-shell"
>
{
tab
===
"domains"
?
<
DomainManagement
site=
{
site
}
/>
:
tab
===
"articles"
?
<
ArticleManagement
site=
{
site
}
selectedSlug=
{
selectedArticleSlug
}
creating=
{
creatingArticle
}
onSelect=
{
setSelectedArticleSlug
}
onCreating=
{
setCreatingArticle
}
onDirtyChange=
{
setArticleDirty
}
/>
:
<
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 +744,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onManage,
{
workspaceDialog
?.
kind
===
"publish"
&&
<
AppDialog
title=
{
productionMatchesPreview
?
"重新上线当前版本?"
:
"上线当前版本?"
}
description=
{
productionMatchesPreview
?
"系统会重新构建当前保存版本并更新生产环境。"
:
"系统会构建当前保存版本,并更新生产环境和已连接的自定义域名。"
}
confirmLabel=
{
productionMatchesPreview
?
"重新上线"
:
"确认上线"
}
busy=
{
publishMutation
.
isPending
}
error=
{
publishMutation
.
error
?.
message
}
icon=
{
<
Rocket
size=
{
20
}
/>
}
onCancel=
{
()
=>
setWorkspaceDialog
(
undefined
)
}
onConfirm=
{
()
=>
publishMutation
.
mutate
(
undefined
,
{
onSuccess
:
()
=>
setWorkspaceDialog
(
undefined
)
})
}
/>
}
{
workspaceDialog
?.
kind
===
"publish-blocked"
&&
<
AppDialog
title=
{
workspaceDialog
.
title
}
description=
{
workspaceDialog
.
description
}
confirmLabel=
{
workspaceDialog
.
goToVersions
?
"前往版本"
:
"知道了"
}
icon=
{
<
Rocket
size=
{
20
}
/>
}
onCancel=
{
()
=>
setWorkspaceDialog
(
undefined
)
}
onConfirm=
{
()
=>
{
if
(
workspaceDialog
.
goToVersions
)
setTab
(
"history"
);
setWorkspaceDialog
(
undefined
);
}
}
/>
}
</
section
>
}
{
articleNavigation
&&
<
AppDialog
title=
"还有未保存的文章修改"
description=
"继续操作会放弃当前编辑内容。你可以先取消并保存草稿,或确认放弃本次修改。"
confirmLabel=
"放弃修改并继续"
tone=
"danger"
icon=
{
<
FileText
size=
{
20
}
/>
}
onCancel=
{
()
=>
setArticleNavigation
(
undefined
)
}
onConfirm=
{
()
=>
{
const
navigation
=
articleNavigation
;
setArticleNavigation
(
undefined
);
runArticleNavigation
(
navigation
);
}
}
/>
}
</
main
>;
}
...
...
@@ -727,55 +755,112 @@ function emptyArticle(): ArticleInput {
};
}
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
})
{
function
articleSlugFromTitle
(
title
:
string
,
fallback
:
string
):
string
{
const
slug
=
title
.
toLowerCase
().
trim
().
replace
(
/
[^
a-z0-9
]
+/g
,
"-"
).
replace
(
/^-+|-+$/g
,
""
).
slice
(
0
,
100
);
return
slug
.
length
>=
2
?
slug
:
fallback
;
}
type
ArticleFilter
=
"all"
|
"pending"
|
"published"
|
"draft"
;
function
articleMainState
(
article
:
ArticleSummary
):
string
{
if
(
article
.
liveState
===
"live"
)
return
"已上线"
;
if
(
article
.
liveState
===
"modified"
)
return
"已修改"
;
if
(
article
.
liveState
===
"pending-removal"
)
return
"待下线"
;
return
"草稿"
;
}
function
articleSyncState
(
article
:
ArticleSummary
):
string
{
if
(
article
.
pendingBuild
)
return
"待更新测试"
;
if
(
article
.
liveState
===
"modified"
)
return
"测试已更新,待上线"
;
if
(
article
.
liveState
===
"pending-removal"
)
return
"测试已更新,待下线"
;
if
(
article
.
liveState
===
"draft"
&&
article
.
status
===
"published"
)
return
"测试已更新,待首次上线"
;
if
(
article
.
liveState
===
"live"
)
return
"测试与线上已同步"
;
return
"仅保存草稿"
;
}
function
ArticleSidePanel
({
site
,
selectedSlug
,
creating
,
articleDirty
,
onSelect
,
onCreate
,
onUpdated
}:
{
site
:
SiteInfo
;
selectedSlug
:
string
;
creating
:
boolean
;
articleDirty
:
boolean
;
onSelect
:
(
slug
:
string
)
=>
void
;
onCreate
:
()
=>
void
;
onUpdated
:
()
=>
Promise
<
void
>
})
{
const
queryClient
=
useQueryClient
();
const
importInput
=
useRef
<
HTMLInputElement
>
(
null
);
const
[
notice
,
setNotice
]
=
useState
(
""
);
const
[
search
,
setSearch
]
=
useState
(
""
);
const
[
filter
,
setFilter
]
=
useState
<
ArticleFilter
>
(
"all"
);
const
[
confirmPublish
,
setConfirmPublish
]
=
useState
(
false
);
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
testVisible
=
articlesQuery
.
data
?.
articles
.
filter
((
article
)
=>
article
.
status
===
"published"
&&
!
article
.
pendingBuild
).
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
()]);
},
onSuccess
:
async
()
=>
{
setNotice
(
"
测试预览已更新。确认内容无误后,可直接发布整个网站到线上
。"
);
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
();
},
onSuccess
:
async
(
result
)
=>
{
if
(
result
.
article
)
onSelect
(
result
.
article
.
slug
);
setNotice
(
"Markdown 已导入,修改尚未同步到测试预览。"
);
await
refresh
();
},
});
const
publishMutation
=
useMutation
({
mutationFn
:
()
=>
api
.
publish
(
site
.
siteId
),
onSuccess
:
async
()
=>
{
setConfirmPublish
(
false
);
setNotice
(
"整个网站已发布到线上,文章内容已同步。"
);
await
Promise
.
all
([
refresh
(),
onUpdated
()]);
},
});
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
;
const
normalizedSearch
=
search
.
trim
().
toLowerCase
();
const
visibleArticles
=
(
articlesQuery
.
data
?.
articles
||
[]).
filter
((
article
)
=>
{
const
matchesSearch
=
!
normalizedSearch
||
[
article
.
title
,
article
.
slug
,
article
.
author
||
""
,
...
article
.
tags
].
some
((
value
)
=>
value
.
toLowerCase
().
includes
(
normalizedSearch
));
const
matchesFilter
=
filter
===
"all"
||
(
filter
===
"pending"
?
article
.
pendingBuild
:
article
.
status
===
filter
);
return
matchesSearch
&&
matchesFilter
;
});
const
error
=
compileMutation
.
error
||
importMutation
.
error
||
publishMutation
.
error
;
const
busy
=
compileMutation
.
isPending
||
importMutation
.
isPending
||
publishMutation
.
isPending
;
const
pendingLiveCount
=
articlesQuery
.
data
?.
pendingLiveCount
||
0
;
const
publishDisabledReason
=
articleDirty
?
"请先保存当前文章"
:
pending
?
"请先更新测试预览"
:
!
pendingLiveCount
?
"文章草稿与线上已同步"
:
site
.
draftBaseCommit
?
"请先在版本管理中保存或放弃网站草稿"
:
site
.
status
!==
"ready"
?
"测试环境构建成功后才能上线"
:
""
;
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
>
<
div
className=
"article-side-intro"
><
span
className=
"domain-side-icon"
><
BookOpen
size=
{
18
}
/></
span
><
div
><
h2
>
文章中心
</
h2
><
p
>
保存只更新草稿,测试确认后再统一上线。
</
p
></
div
></
div
>
<
div
className=
{
`article-batch-card ${articleDirty || pending ? "pending" : pendingLiveCount ? "staged" : "ready"}`
}
><
div
><
span
>
{
articleDirty
?
"当前文章尚未保存"
:
pending
?
`${articlesQuery.data?.pendingCount || 0} 篇待更新测试`
:
pendingLiveCount
?
`${pendingLiveCount} 篇待上线`
:
"文章已同步"
}
</
span
><
small
>
{
articleDirty
?
"先保存当前编辑,后续测试和上线操作才会包含本次修改"
:
pending
?
"统一生成测试网站,不会影响线上内容"
:
pendingLiveCount
?
"测试内容已准备好,线上版本仍保持不变"
:
articlesQuery
.
data
?.
lastCompiledAt
?
`测试与线上已同步 · ${formatTime(articlesQuery.data.lastCompiledAt)}`
:
"还没有待处理的文章修改"
}
</
small
></
div
>
{
articleDirty
?
<
span
className=
"article-sync-check pending"
><
Save
size=
{
12
}
/>
请先保存
</
span
>
:
pending
?
<
button
type=
"button"
disabled=
{
busy
}
onClick=
{
()
=>
compileMutation
.
mutate
()
}
>
{
compileMutation
.
isPending
?
<
LoaderCircle
className=
"spin"
size=
{
13
}
/>
:
<
RefreshCw
size=
{
13
}
/>
}{
compileMutation
.
isPending
?
"更新中…"
:
"更新测试预览"
}
</
button
>
:
pendingLiveCount
?
<
a
className=
"article-preview-link"
href=
{
site
.
previewUrl
}
target=
"_blank"
rel=
"noreferrer"
><
Monitor
size=
{
12
}
/>
查看测试网站
</
a
>
:
<
span
className=
"article-sync-check"
><
Check
size=
{
13
}
/>
无需操作
</
span
>
}
<
button
className=
"article-go-live"
type=
"button"
aria
-
disabled=
{
Boolean
(
publishDisabledReason
)
||
busy
}
disabled=
{
busy
}
title=
{
publishDisabledReason
||
"把当前网站版本和文章草稿发布到生产环境"
}
onClick=
{
()
=>
{
if
(
!
publishDisabledReason
)
{
publishMutation
.
reset
();
setConfirmPublish
(
true
);
}
}
}
><
Rocket
size=
{
13
}
/>
发布整个网站到线上
</
button
>
{
publishDisabledReason
&&
pendingLiveCount
>
0
&&
<
small
className=
"article-publish-blocker"
>
{
publishDisabledReason
}
</
small
>
}
</
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
className=
"article-side-list-heading"
><
strong
>
内容列表
</
strong
><
span
>
{
articlesQuery
.
data
?.
articles
.
length
||
0
}
篇 ·
{
testVisible
}
篇测试可见
</
span
></
div
>
<
div
className=
"article-list-controls"
><
label
><
Search
size=
{
11
}
/><
input
value=
{
search
}
placeholder=
"搜索标题、Slug 或标签"
onChange=
{
(
event
)
=>
setSearch
(
event
.
target
.
value
)
}
/></
label
><
select
aria
-
label=
"筛选文章状态"
value=
{
filter
}
onChange=
{
(
event
)
=>
setFilter
(
event
.
target
.
value
as
ArticleFilter
)
}
><
option
value=
"all"
>
全部状态
</
option
><
option
value=
"pending"
>
待同步
</
option
><
option
value=
"published"
>
测试可见
</
option
><
option
value=
"draft"
>
草稿
</
option
></
select
></
div
>
<
div
className=
"article-side-list"
>
{
visibleArticles
.
length
?
visibleArticles
.
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
><
i
>
{
articleSyncState
(
article
)
}
</
i
></
span
><
em
className=
{
article
.
liveState
}
>
{
articleMainState
(
article
)
}
</
em
></
button
>)
:
<
div
className=
"article-empty"
><
FileText
size=
{
22
}
/><
span
>
{
articlesQuery
.
data
?.
articles
.
length
?
"没有符合条件的文章"
:
"还没有文章"
}
</
span
>
{
!
articlesQuery
.
data
?.
articles
.
length
&&
<
button
type=
"button"
onClick=
{
onCreate
}
>
新建第一篇文章
</
button
>
}
</
div
>
}
</
div
>
{
confirmPublish
&&
<
AppDialog
title=
"发布整个网站到线上?"
description=
{
`将把当前网站版本以及 ${testVisible} 篇测试可见文章发布到生产环境。发布期间原线上网站保持可用。`
}
confirmLabel=
"确认发布整个网站"
busy=
{
publishMutation
.
isPending
}
error=
{
publishMutation
.
error
?.
message
}
icon=
{
<
Rocket
size=
{
20
}
/>
}
onCancel=
{
()
=>
setConfirmPublish
(
false
)
}
onConfirm=
{
()
=>
publishMutation
.
mutate
()
}
/>
}
</
div
>;
}
function
ArticleManagement
({
site
,
selectedSlug
,
creating
,
onSelect
,
onCreating
}:
{
site
:
SiteInfo
;
selectedSlug
:
string
;
creating
:
boolean
;
onSelect
:
(
slug
:
string
)
=>
void
;
onCreating
:
(
creating
:
boolean
)
=>
void
})
{
function
ArticleManagement
({
site
,
selectedSlug
,
creating
,
onSelect
,
onCreating
,
onDirtyChange
}:
{
site
:
SiteInfo
;
selectedSlug
:
string
;
creating
:
boolean
;
onSelect
:
(
slug
:
string
)
=>
void
;
onCreating
:
(
creating
:
boolean
)
=>
void
;
onDirtyChange
:
(
dirty
:
boolean
)
=>
void
})
{
const
queryClient
=
useQueryClient
();
const
[
draft
,
setDraft
]
=
useState
<
ArticleInput
>
(
emptyArticle
);
const
[
baseline
,
setBaseline
]
=
useState
<
ArticleInput
>
(
draft
);
const
[
slugEdited
,
setSlugEdited
]
=
useState
(
false
);
const
[
notice
,
setNotice
]
=
useState
(
""
);
const
[
articleAction
,
setArticleAction
]
=
useState
<
"remove"
|
"restore"
>
();
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
());
if
(
creating
)
{
const
article
=
emptyArticle
();
setDraft
(
article
);
setBaseline
(
article
);
setSlugEdited
(
false
);
setNotice
(
""
);
}
},
[
creating
]);
useEffect
(()
=>
{
if
(
articleQuery
.
data
&&
!
creating
)
setDraft
(
articleQuery
.
data
);
if
(
articleQuery
.
data
&&
!
creating
)
{
setDraft
(
articleQuery
.
data
);
setBaseline
(
articleQuery
.
data
);
setSlugEdited
(
true
);
setNotice
(
""
);
}
},
[
articleQuery
.
data
,
creating
]);
const
dirty
=
JSON
.
stringify
(
draft
)
!==
JSON
.
stringify
(
baseline
);
useEffect
(()
=>
{
onDirtyChange
(
dirty
);
},
[
dirty
,
onDirtyChange
]);
useEffect
(()
=>
{
if
(
!
dirty
)
return
;
const
protect
=
(
event
:
BeforeUnloadEvent
)
=>
{
event
.
preventDefault
();
event
.
returnValue
=
""
;
};
window
.
addEventListener
(
"beforeunload"
,
protect
);
return
()
=>
window
.
removeEventListener
(
"beforeunload"
,
protect
);
},
[
dirty
]);
useEffect
(()
=>
()
=>
onDirtyChange
(
false
),
[
onDirtyChange
]);
const
refresh
=
async
(
slug
?:
string
)
=>
{
await
Promise
.
all
([
queryClient
.
invalidateQueries
({
queryKey
:
[
"articles"
,
site
.
siteId
]
}),
...
...
@@ -791,42 +876,70 @@ function ArticleManagement({ site, selectedSlug, creating, onSelect, onCreating
},
onSuccess
:
async
(
result
)
=>
{
const
slug
=
result
.
article
?.
slug
||
draft
.
slug
;
if
(
result
.
article
)
setDraft
(
result
.
article
);
if
(
result
.
article
)
{
setDraft
(
result
.
article
);
setBaseline
(
result
.
article
);
}
onCreating
(
false
);
setNotice
(
result
.
article
?.
status
===
"published"
?
"已加入发布队列,尚未编译。"
:
"草稿已保存,本次没有触发编译
。"
);
setNotice
(
result
.
article
?.
status
===
"published"
?
result
.
article
.
liveState
===
"draft"
?
"文章已加入测试,线上版本尚未创建。"
:
"草稿修改已保存,线上文章保持不变。"
:
result
.
article
?.
liveState
===
"pending-removal"
?
"文章已标记为待下线,线上内容暂时保持不变。"
:
"草稿已保存,不会出现在测试或线上网站
。"
);
await
refresh
(
slug
);
},
});
const
deleteMutation
=
useMutation
({
mutationFn
:
()
=>
api
.
deleteArticle
(
site
.
siteId
,
selectedSlug
),
onSuccess
:
async
()
=>
{
onSelect
(
""
);
onCreating
(
false
);
setDraft
(
emptyArticle
());
setNotice
(
"文章已删除,待统一编译后从预览移除。"
);
await
refresh
();
},
onSuccess
:
async
(
result
)
=>
{
setArticleAction
(
undefined
);
if
(
result
.
article
)
{
setDraft
(
result
.
article
);
setBaseline
(
result
.
article
);
setNotice
(
"文章已标记为待下线;更新测试后确认,再发布整个网站。 "
);
await
refresh
(
result
.
article
.
slug
);
return
;
}
const
article
=
emptyArticle
();
onSelect
(
""
);
onCreating
(
false
);
setDraft
(
article
);
setBaseline
(
article
);
setNotice
(
"草稿已删除。"
);
await
refresh
();
},
});
const
restoreMutation
=
useMutation
({
mutationFn
:
()
=>
api
.
restoreLiveArticle
(
site
.
siteId
,
selectedSlug
),
onSuccess
:
async
(
result
)
=>
{
setArticleAction
(
undefined
);
if
(
result
.
article
)
{
setDraft
(
result
.
article
);
setBaseline
(
result
.
article
);
}
setNotice
(
"已恢复线上内容;更新测试预览后即可重新确认。 "
);
await
refresh
(
result
.
article
?.
slug
||
selectedSlug
);
},
});
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
(
"图片已上传并插入正文
;保存文章并统一编译后即可在预览中查看
。"
);
setNotice
(
"图片已上传并插入正文
。请保存文章,再更新测试预览查看效果
。"
);
},
});
const
error
=
saveMutation
.
error
||
deleteMutation
.
error
||
imageMutation
.
error
||
articleQuery
.
error
;
const
busy
=
saveMutation
.
isPending
||
deleteMutation
.
isPending
||
imageMutation
.
isPending
;
const
error
=
saveMutation
.
error
||
deleteMutation
.
error
||
restoreMutation
.
error
||
imageMutation
.
error
||
articleQuery
.
error
;
const
busy
=
saveMutation
.
isPending
||
deleteMutation
.
isPending
||
restoreMutation
.
isPending
||
imageMutation
.
isPending
;
const
update
=
<
K
extends
keyof
ArticleInput
>
(key: K, value: ArticleInput[K]) =
>
setDraft((current) =
>
(
{
...
current
,
[
key
]:
value
}
));
const updateTitle = (title: string) =
>
setDraft((current) =
>
(
{
...
current
,
title
,
slug
:
creating
&&
!
slugEdited
?
articleSlugFromTitle
(
title
,
current
.
slug
)
:
current
.
slug
}
));
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;
const selectedArticle = articlesQuery.data?.articles.find((article) =
>
article.slug === selectedSlug);
const selectedPending = selectedArticle?.pendingBuild || false;
const liveState = articleQuery.data?.liveState || selectedArticle?.liveState || "draft";
const requiredComplete = Boolean(draft.title.trim()
&&
draft.summary.trim()
&&
draft.slug.trim());
const canSaveAs = (status: ArticleInput["status"]) =
>
requiredComplete
&&
(creating || dirty || draft.status !== status);
const articleStateLabel = liveState === "live" ? "已上线" : liveState === "modified" ? "已修改" : liveState === "pending-removal" ? "待下线" : "草稿";
const articleSyncLabel = dirty ? "当前修改尚未保存" : selectedPending ? "保存成功,等待更新测试预览" : liveState === "modified" ? "测试已更新,等待发布上线" : liveState === "pending-removal" ? "测试已更新,等待正式下线" : liveState === "draft"
&&
draft.status === "published" ? "测试已更新,等待首次上线" : liveState === "live" ? "草稿与线上内容一致" : "仅保存在草稿中";
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
>
<
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
>
<
div
className=
"article-editor-heading"
><
div
><
span
>
{
creating
?
"NEW ARTICLE"
:
"EDIT ARTICLE"
}
</
span
><
h2
>
{
creating
?
"创建文章"
:
draft
.
title
||
"编辑文章"
}
</
h2
><
em
className=
{
`article-status-badge ${
liveState}`
}
>
{
articleStateLabel
}
</
em
>
{
dirty
?
<
small
className=
"article-dirty"
><
i
/>
未保存修改
</
small
>
:
creating
?
<
small
className=
"article-unsaved"
>
尚未保存
</
small
>
:
<
small
className=
"article-saved"
><
Check
size=
{
11
}
/>
草稿已保存
</
small
>
}
</
div
><
div
>
{
!
creating
&&
draft
.
status
===
"published"
&&
!
selectedPending
&&
<
a
href=
{
`${site.previewUrl}articles/${draft.slug}/`
}
target=
"_blank"
rel=
"noreferrer"
><
ExternalLink
size=
{
13
}
/>
查看测试页
</
a
>
}{
!
creating
&&
(
liveState
===
"modified"
||
liveState
===
"pending-removal"
)
&&
<
button
className=
"article-restore"
type=
"button"
disabled=
{
busy
}
onClick=
{
()
=>
{
restoreMutation
.
reset
();
setArticleAction
(
"restore"
);
}
}
><
RotateCcw
size=
{
13
}
/>
恢复线上版本
</
button
>
}{
!
creating
&&
liveState
!==
"pending-removal"
&&
<
button
className=
"article-delete"
type=
"button"
disabled=
{
busy
}
onClick=
{
()
=>
{
deleteMutation
.
reset
();
setArticleAction
(
"remove"
);
}
}
><
Trash2
size=
{
13
}
/>
{
liveState
===
"draft"
?
"删除草稿"
:
"下线文章"
}
</
button
>
}{
(
creating
||
draft
.
status
===
"draft"
)
&&
<
button
className=
"article-save-draft"
type=
"button"
disabled=
{
busy
||
!
canSaveAs
(
"draft"
)
}
onClick=
{
()
=>
saveMutation
.
mutate
(
"draft"
)
}
>
{
saveMutation
.
isPending
&&
saveMutation
.
variables
===
"draft"
?
<
LoaderCircle
className=
"spin"
size=
{
14
}
/>
:
<
Save
size=
{
14
}
/>
}{
saveMutation
.
isPending
&&
saveMutation
.
variables
===
"draft"
?
"保存中…"
:
creating
?
"仅保存草稿"
:
"保存修改"
}
</
button
>
}
<
button
className=
"article-save"
type=
"button"
disabled=
{
busy
||
!
canSaveAs
(
"published"
)
}
onClick=
{
()
=>
saveMutation
.
mutate
(
"published"
)
}
>
{
saveMutation
.
isPending
&&
saveMutation
.
variables
===
"published"
?
<
LoaderCircle
className=
"spin"
size=
{
14
}
/>
:
draft
.
status
===
"published"
?
<
Save
size=
{
14
}
/>
:
<
Monitor
size=
{
14
}
/>
}{
saveMutation
.
isPending
&&
saveMutation
.
variables
===
"published"
?
"保存中…"
:
creating
?
"保存并加入测试"
:
draft
.
status
===
"published"
?
"保存修改"
:
liveState
===
"pending-removal"
?
"取消下线"
:
"加入测试
"
}
</
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
><
input
required
maxLength=
{
120
}
value=
{
draft
.
title
}
onChange=
{
(
event
)
=>
update
Title
(
event
.
target
.
value
)
}
/></
label
>
<
label
><
span
>
Slug *
</
span
><
input
required
disabled=
{
liveState
!==
"draft"
}
pattern=
"[a-z0-9]+(?:-[a-z0-9]+)*"
value=
{
draft
.
slug
}
onChange=
{
(
event
)
=>
{
setSlugEdited
(
true
);
update
(
"slug"
,
event
.
target
.
value
.
toLowerCase
().
replace
(
/
[^
a-z0-9-
]
/g
,
""
));
}
}
/><
small
className=
"article-field-help"
>
{
liveState
===
"draft"
?
"新建时会根据英文或拼音标题自动生成,也可手动修改。"
:
"文章上线后固定 Slug,避免已有链接失效。"
}
</
small
></
label
>
<
div
className=
"article-publish-help"
><
span
>
当前状态
</
span
><
strong
>
{
articleStateLabel
}
</
strong
><
small
>
{
articleSyncLabel
}
</
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
>
...
...
@@ -837,6 +950,8 @@ function ArticleManagement({ site, selectedSlug, creating, onSelect, onCreating
<
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
>
{
articleAction
===
"remove"
&&
<
AppDialog
title=
{
liveState
===
"draft"
?
"删除草稿?"
:
"下线这篇文章?"
}
description=
{
liveState
===
"draft"
?
`“${draft.title}”尚未上线,删除后无法恢复。`
:
`线上文章会继续保持可用;更新测试并发布整个网站后才会正式下线“${draft.title}”。`
}
confirmLabel=
{
liveState
===
"draft"
?
"删除草稿"
:
"标记为待下线"
}
busy=
{
deleteMutation
.
isPending
}
error=
{
deleteMutation
.
error
?.
message
}
tone=
"danger"
icon=
{
<
Trash2
size=
{
20
}
/>
}
onCancel=
{
()
=>
setArticleAction
(
undefined
)
}
onConfirm=
{
()
=>
deleteMutation
.
mutate
()
}
/>
}
{
articleAction
===
"restore"
&&
<
AppDialog
title=
"恢复线上版本?"
description=
{
`将放弃“${draft.title}”当前草稿修改,并恢复为正在使用的线上内容。`
}
confirmLabel=
"恢复线上版本"
busy=
{
restoreMutation
.
isPending
}
error=
{
restoreMutation
.
error
?.
message
}
icon=
{
<
RotateCcw
size=
{
20
}
/>
}
onCancel=
{
()
=>
setArticleAction
(
undefined
)
}
onConfirm=
{
()
=>
restoreMutation
.
mutate
()
}
/>
}
</
section
>
;
}
...
...
frontend/src/api.ts
View file @
bb9d04f4
...
...
@@ -57,6 +57,7 @@ export const api = {
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"
}),
restoreLiveArticle
:
(
siteId
:
string
,
slug
:
string
)
=>
request
<
ArticleMutationResult
>
(
`/api/sites/
${
siteId
}
/articles/
${
slug
}
/restore-live`
,
{
method
:
"POST"
}),
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
})
}),
...
...
frontend/src/styles.css
View file @
bb9d04f4
...
...
@@ -86,3 +86,5 @@
@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
}
.article-editor-heading
{
position
:
sticky
;
z-index
:
5
;
top
:
0
;
margin
:
-10px
-10px
14px
;
padding
:
10px
;
background
:
rgba
(
244
,
245
,
249
,
.96
);
border-bottom
:
1px
solid
rgba
(
224
,
222
,
230
,
.8
);
backdrop-filter
:
blur
(
10px
)}
.article-editor-heading
button
:disabled
{
opacity
:
.45
;
cursor
:
not-allowed
}
.article-status-badge.pending
,
.article-side-list
em
.pending
{
color
:
#8c6200
;
background
:
#fff1c9
}
.article-dirty
,
.article-saved
,
.article-unsaved
{
display
:
flex
;
align-items
:
center
;
gap
:
4px
;
margin-left
:
3px
;
font-size
:
7px
;
font-weight
:
750
}
.article-dirty
{
color
:
#9a6500
}
.article-dirty
i
{
width
:
6px
;
height
:
6px
;
background
:
#e2a322
;
border-radius
:
50%
}
.article-saved
{
color
:
#18805f
}
.article-unsaved
{
color
:
var
(
--muted
)}
.article-field-help
{
color
:
var
(
--muted
);
font-size
:
7px
;
line-height
:
1.45
}
.article-list-controls
{
display
:
grid
;
grid-template-columns
:
minmax
(
0
,
1
fr
)
80px
;
gap
:
5px
;
margin-bottom
:
7px
}
.article-list-controls
label
{
height
:
29px
;
display
:
flex
;
align-items
:
center
;
gap
:
5px
;
padding
:
0
7px
;
color
:
var
(
--muted
);
background
:
#fff
;
border
:
1px
solid
var
(
--border
);
border-radius
:
7px
}
.article-list-controls
label
:focus-within
{
color
:
var
(
--primary
);
border-color
:
#cfc0ff
}
.article-list-controls
input
,
.article-list-controls
select
{
min-width
:
0
;
width
:
100%
;
height
:
100%
;
padding
:
0
;
color
:
var
(
--text
);
background
:
transparent
;
border
:
0
;
outline
:
0
;
font-size
:
7px
}
.article-list-controls
select
{
padding
:
0
5px
;
background
:
#fff
;
border
:
1px
solid
var
(
--border
);
border-radius
:
7px
}
.article-publish-blocker
{
grid-column
:
1
/
-1
;
margin
:
0
!important
;
padding
:
0
2px
;
color
:
#9a6500
!important
;
font-size
:
6px
!important
;
line-height
:
1.4
}
.article-batch-card
.article-go-live
[
aria-disabled
=
"true"
]
{
color
:
#8f8b97
;
background
:
#efeff4
;
border
:
1px
solid
#e0dde5
;
cursor
:
not-allowed
}
.article-empty
{
min-height
:
100px
;
height
:
auto
}
.article-batch-card.staged
{
border-color
:
#cfc1fa
;
background
:
#faf8ff
}
.article-preview-link
{
height
:
29px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
gap
:
5px
;
padding
:
0
8px
;
color
:
var
(
--primary
);
background
:
#fff
;
border
:
1px
solid
#d9ceff
;
border-radius
:
7px
;
font-size
:
7px
;
font-weight
:
800
;
text-decoration
:
none
}
.article-sync-check
{
display
:
flex
;
align-items
:
center
;
gap
:
4px
;
color
:
#16805f
;
font-size
:
7px
;
font-weight
:
750
}
.article-sync-check.pending
{
color
:
#9a6500
}
.article-side-list
>
button
>
span
i
{
display
:
block
;
margin-top
:
4px
;
color
:
var
(
--muted
);
font-size
:
6px
;
font-style
:
normal
;
font-weight
:
650
}
.article-side-list
em
.live
,
.article-status-badge.live
{
color
:
#13765b
;
background
:
#e7f8f2
}
.article-side-list
em
.modified
,
.article-status-badge.modified
{
color
:
#7040c3
;
background
:
#eee8ff
}
.article-side-list
em
.pending-removal
,
.article-status-badge.pending-removal
{
color
:
#a54242
;
background
:
#fff0f0
}
.article-status-badge.draft
{
color
:
#8c6200
;
background
:
#fff7df
}
.article-editor-heading
.article-restore
{
color
:
#6845c5
}
.article-fields
input
:disabled
{
color
:
#8c8994
;
background
:
#f0eff3
;
cursor
:
not-allowed
}
shared/src/index.ts
View file @
bb9d04f4
...
...
@@ -197,6 +197,7 @@ export interface DraftPreviewResult {
}
export
type
ArticleStatus
=
"draft"
|
"published"
;
export
type
ArticleLiveState
=
"draft"
|
"live"
|
"modified"
|
"pending-removal"
;
export
interface
ArticleSeoInput
{
title
?:
string
;
...
...
@@ -223,10 +224,12 @@ export interface ArticleInput {
export
interface
ArticleSummary
extends
Omit
<
ArticleInput
,
"body"
>
{
wordCount
:
number
;
pendingBuild
:
boolean
;
liveState
:
ArticleLiveState
;
}
export
interface
ArticleDocument
extends
ArticleInput
{
wordCount
:
number
;
liveState
:
ArticleLiveState
;
}
export
interface
ArticleListResult
{
...
...
@@ -234,6 +237,7 @@ export interface ArticleListResult {
articles
:
ArticleSummary
[];
pendingBuild
:
boolean
;
pendingCount
:
number
;
pendingLiveCount
:
number
;
lastCompiledAt
?:
string
;
}
...
...
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