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
9b34b595
Commit
9b34b595
authored
Jul 20, 2026
by
xuchentao
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: add site management and recycle bin
parent
57609ddd
Changes
9
Show whitespace changes
Inline
Side-by-side
Showing
9 changed files
with
263 additions
and
11 deletions
+263
-11
config.ts
backend/src/config.ts
+1
-0
server.ts
backend/src/server.ts
+13
-1
site-lifecycle-service.ts
backend/src/sites/site-lifecycle-service.ts
+95
-0
site-repository.test.ts
backend/src/sites/site-repository.test.ts
+7
-1
site-repository.ts
backend/src/sites/site-repository.ts
+30
-0
App.tsx
frontend/src/App.tsx
+98
-9
api.ts
frontend/src/api.ts
+4
-0
styles.css
frontend/src/styles.css
+11
-0
index.ts
shared/src/index.ts
+4
-0
No files found.
backend/src/config.ts
View file @
9b34b595
...
...
@@ -90,6 +90,7 @@ export const config = {
export
const
runtimePaths
=
{
sites
:
config
.
sitesDir
,
archivedSites
:
path
.
join
(
config
.
sitesDir
,
".trash"
),
builds
:
path
.
join
(
config
.
runtimeDir
,
"builds"
),
uploads
:
path
.
join
(
config
.
runtimeDir
,
"uploads"
),
pnpmStore
:
path
.
join
(
config
.
runtimeDir
,
"pnpm-store"
),
...
...
backend/src/server.ts
View file @
9b34b595
...
...
@@ -18,6 +18,7 @@ import { CreateSiteService } from "./sites/create-site.js";
import
{
SiteAgentService
}
from
"./sites/site-agent-service.js"
;
import
{
SiteRepository
}
from
"./sites/site-repository.js"
;
import
{
SiteVersionService
}
from
"./sites/site-version-service.js"
;
import
{
SiteLifecycleService
}
from
"./sites/site-lifecycle-service.js"
;
import
{
DomainDeploymentService
}
from
"./domains/domain-deployment-service.js"
;
import
{
DomainRepository
}
from
"./domains/domain-repository.js"
;
import
{
DomainRoutingConfig
}
from
"./domains/domain-routing-config.js"
;
...
...
@@ -42,8 +43,10 @@ const siteAgent = new SiteAgentService(sites, git, new AgentLoop());
const
versions
=
new
SiteVersionService
(
sites
,
git
,
builds
,
previews
);
const
domainRepository
=
new
DomainRepository
();
const
domainRouting
=
new
DomainRoutingConfig
(
domainRepository
);
const
domainProvider
=
createDomainProvider
();
const
domainDeployments
=
new
DomainDeploymentService
(
sites
,
domainRepository
,
git
,
builds
,
domainRouting
);
const
domainService
=
new
DomainService
(
sites
,
domainRepository
,
domainRouting
,
createDomainProvider
());
const
domainService
=
new
DomainService
(
sites
,
domainRepository
,
domainRouting
,
domainProvider
);
const
siteLifecycle
=
new
SiteLifecycleService
(
sites
,
previews
,
domainRepository
,
domainRouting
,
domainProvider
);
app
.
addHook
(
"onRequest"
,
async
(
request
,
reply
)
=>
{
if
(
!
request
.
url
.
startsWith
(
"/api/"
)
||
request
.
url
===
"/api/health"
||
request
.
url
===
"/api/login"
)
return
;
...
...
@@ -67,8 +70,15 @@ app.post("/api/logout", async (request) => {
app
.
get
(
"/api/session"
,
async
(
request
)
=>
request
.
auth
!
);
app
.
get
(
"/api/sites"
,
async
(
request
)
=>
sites
.
list
(
requireTenant
(
request
.
auth
)));
app
.
get
(
"/api/archived-sites"
,
async
(
request
)
=>
sites
.
listArchived
(
requireTenant
(
request
.
auth
)));
app
.
get
<
{
Params
:
{
siteId
:
string
}
}
>
(
"/api/sites/:siteId"
,
async
(
request
)
=>
sites
.
get
(
requireTenant
(
request
.
auth
),
request
.
params
.
siteId
));
app
.
post
(
"/api/sites"
,
async
(
request
,
reply
)
=>
reply
.
code
(
201
).
send
(
await
createSite
.
execute
(
requireTenant
(
request
.
auth
),
createSiteSchema
.
parse
(
request
.
body
))));
app
.
post
<
{
Params
:
{
siteId
:
string
}
}
>
(
"/api/sites/:siteId/archive"
,
async
(
request
)
=>
siteLifecycle
.
archive
(
requireTenant
(
request
.
auth
),
request
.
params
.
siteId
));
app
.
post
<
{
Params
:
{
siteId
:
string
}
}
>
(
"/api/archived-sites/:siteId/restore"
,
async
(
request
)
=>
siteLifecycle
.
restore
(
requireTenant
(
request
.
auth
),
request
.
params
.
siteId
));
app
.
delete
<
{
Params
:
{
siteId
:
string
}
}
>
(
"/api/archived-sites/:siteId"
,
async
(
request
,
reply
)
=>
{
await
siteLifecycle
.
permanentlyDelete
(
requireTenant
(
request
.
auth
),
request
.
params
.
siteId
);
return
reply
.
code
(
204
).
send
();
});
app
.
post
<
{
Params
:
{
siteId
:
string
}
}
>
(
"/api/sites/:siteId/chat"
,
async
(
request
)
=>
{
const
body
=
chatSchema
.
parse
(
request
.
body
);
return
siteAgent
.
execute
(
requireTenant
(
request
.
auth
),
request
.
params
.
siteId
,
body
.
message
);
...
...
@@ -168,6 +178,7 @@ app.setErrorHandler((error, _request, reply) => {
});
await
sites
.
ensureRuntime
();
await
siteLifecycle
.
purgeExpired
().
catch
((
error
)
=>
app
.
log
.
warn
(
error
));
await
domainRouting
.
sync
();
await
rm
(
runtimePaths
.
builds
,
{
recursive
:
true
,
force
:
true
});
await
mkdir
(
runtimePaths
.
builds
,
{
recursive
:
true
});
...
...
@@ -219,6 +230,7 @@ const checkPendingDomains = async () => {
}
finally
{
checkingDomains
=
false
;
}
};
setInterval
(()
=>
void
checkPendingDomains
(),
config
.
domains
.
checkIntervalMs
).
unref
();
setInterval
(()
=>
void
siteLifecycle
.
purgeExpired
().
catch
((
error
)
=>
app
.
log
.
warn
(
error
)),
24
*
60
*
60
*
1000
).
unref
();
function
bearerToken
(
header
:
string
|
undefined
):
string
|
undefined
{
const
match
=
header
?.
match
(
/^Bearer
\s
+
(
.+
)
$/i
);
...
...
backend/src/sites/site-lifecycle-service.ts
0 → 100644
View file @
9b34b595
import
path
from
"node:path"
;
import
{
mkdir
,
rename
,
rm
,
stat
}
from
"node:fs/promises"
;
import
type
{
SiteInfo
}
from
"@webagent/shared"
;
import
{
config
,
runtimePaths
}
from
"../config.js"
;
import
type
{
DomainProvider
}
from
"../domains/domain-provider.js"
;
import
{
DomainRepository
}
from
"../domains/domain-repository.js"
;
import
{
DomainRoutingConfig
}
from
"../domains/domain-routing-config.js"
;
import
{
PreviewProcessManager
}
from
"../preview/preview-process-manager.js"
;
import
{
SiteRepository
}
from
"./site-repository.js"
;
export
class
SiteLifecycleService
{
constructor
(
private
readonly
sites
:
SiteRepository
,
private
readonly
previews
:
PreviewProcessManager
,
private
readonly
domains
:
DomainRepository
,
private
readonly
routing
:
DomainRoutingConfig
,
private
readonly
provider
:
DomainProvider
,
)
{}
async
archive
(
tenantId
:
string
,
siteId
:
string
):
Promise
<
SiteInfo
>
{
const
site
=
await
this
.
sites
.
get
(
tenantId
,
siteId
);
const
archivedAt
=
new
Date
();
const
bindings
=
await
this
.
domains
.
list
(
tenantId
,
siteId
);
for
(
const
domain
of
bindings
)
await
this
.
provider
.
remove
(
domain
);
if
(
bindings
.
length
)
{
await
this
.
domains
.
updateSite
(
tenantId
,
siteId
,
()
=>
({
providerHostnameId
:
undefined
,
providerStatus
:
undefined
,
deploymentStatus
:
"pending"
,
deployedCommit
:
undefined
,
tlsStatus
:
this
.
provider
.
initialTlsStatus
(),
}));
}
await
this
.
sites
.
update
(
tenantId
,
siteId
,
{
lifecycleStatus
:
"archived"
,
archivedAt
:
archivedAt
.
toISOString
(),
deleteAfter
:
new
Date
(
archivedAt
.
getTime
()
+
30
*
24
*
60
*
60
*
1000
).
toISOString
(),
});
await
this
.
previews
.
stop
(
tenantId
,
siteId
);
await
this
.
move
(
this
.
sites
.
getSiteRoot
(
tenantId
,
siteId
),
this
.
sites
.
getArchivedSiteRoot
(
tenantId
,
siteId
),
true
);
await
Promise
.
all
(
this
.
artifactRoots
().
map
(({
active
,
archived
})
=>
this
.
move
(
path
.
join
(
active
,
tenantId
,
siteId
),
path
.
join
(
archived
,
tenantId
,
siteId
),
false
)));
await
this
.
routing
.
sync
();
return
this
.
sites
.
getArchived
(
tenantId
,
siteId
);
}
async
restore
(
tenantId
:
string
,
siteId
:
string
):
Promise
<
SiteInfo
>
{
await
this
.
sites
.
getArchived
(
tenantId
,
siteId
);
if
(
await
this
.
exists
(
this
.
sites
.
getSiteRoot
(
tenantId
,
siteId
)))
throw
Object
.
assign
(
new
Error
(
"已有同标识的网站,无法恢复"
),
{
statusCode
:
409
});
await
this
.
move
(
this
.
sites
.
getArchivedSiteRoot
(
tenantId
,
siteId
),
this
.
sites
.
getSiteRoot
(
tenantId
,
siteId
),
true
);
await
Promise
.
all
(
this
.
artifactRoots
().
map
(({
active
,
archived
})
=>
this
.
move
(
path
.
join
(
archived
,
tenantId
,
siteId
),
path
.
join
(
active
,
tenantId
,
siteId
),
false
)));
const
restored
=
await
this
.
sites
.
update
(
tenantId
,
siteId
,
{
lifecycleStatus
:
"active"
,
archivedAt
:
undefined
,
deleteAfter
:
undefined
});
const
previewPath
=
path
.
join
(
config
.
previewsDir
,
tenantId
,
siteId
);
if
(
await
this
.
exists
(
previewPath
))
{
await
this
.
previews
.
start
(
tenantId
,
siteId
,
previewPath
,
restored
.
previewPort
).
catch
(
async
(
error
)
=>
{
await
this
.
sites
.
update
(
tenantId
,
siteId
,
{
status
:
"failed"
,
lastError
:
error
instanceof
Error
?
error
.
message
:
String
(
error
)
});
});
}
await
this
.
routing
.
sync
();
return
this
.
sites
.
get
(
tenantId
,
siteId
);
}
async
permanentlyDelete
(
tenantId
:
string
,
siteId
:
string
):
Promise
<
void
>
{
await
this
.
sites
.
getArchived
(
tenantId
,
siteId
);
await
Promise
.
all
([
rm
(
this
.
sites
.
getArchivedSiteRoot
(
tenantId
,
siteId
),
{
recursive
:
true
,
force
:
true
}),
...
this
.
artifactRoots
().
flatMap
(({
active
,
archived
})
=>
[
rm
(
path
.
join
(
active
,
tenantId
,
siteId
),
{
recursive
:
true
,
force
:
true
}),
rm
(
path
.
join
(
archived
,
tenantId
,
siteId
),
{
recursive
:
true
,
force
:
true
}),
]),
]);
}
async
purgeExpired
(
now
=
new
Date
()):
Promise
<
number
>
{
const
expired
=
(
await
this
.
sites
.
listAllArchived
()).
filter
((
site
)
=>
site
.
deleteAfter
&&
new
Date
(
site
.
deleteAfter
).
getTime
()
<=
now
.
getTime
());
for
(
const
site
of
expired
)
await
this
.
permanentlyDelete
(
site
.
tenantId
,
site
.
siteId
);
return
expired
.
length
;
}
private
artifactRoots
():
Array
<
{
active
:
string
;
archived
:
string
}
>
{
return
[
config
.
previewsDir
,
config
.
productionDir
,
config
.
customDomainsDir
].
map
((
active
)
=>
({
active
,
archived
:
path
.
join
(
active
,
".trash"
)
}));
}
private
async
move
(
source
:
string
,
destination
:
string
,
required
:
boolean
):
Promise
<
void
>
{
if
(
!
await
this
.
exists
(
source
))
{
if
(
required
)
throw
Object
.
assign
(
new
Error
(
"站点不存在"
),
{
statusCode
:
404
});
return
;
}
await
mkdir
(
path
.
dirname
(
destination
),
{
recursive
:
true
});
await
rename
(
source
,
destination
);
}
private
async
exists
(
target
:
string
):
Promise
<
boolean
>
{
return
stat
(
target
).
then
(()
=>
true
).
catch
(()
=>
false
);
}
}
backend/src/sites/site-repository.test.ts
View file @
9b34b595
import
assert
from
"node:assert/strict"
;
import
{
mkd
temp
,
rm
}
from
"node:fs/promises"
;
import
{
mkd
ir
,
mkdtemp
,
rename
,
rm
}
from
"node:fs/promises"
;
import
os
from
"node:os"
;
import
path
from
"node:path"
;
import
test
from
"node:test"
;
...
...
@@ -32,5 +32,11 @@ test("repository paths and queries are tenant-scoped", async () => {
await
assert
.
rejects
(()
=>
sites
.
get
(
tenantA
,
siteB
),
(
error
:
NodeJS
.
ErrnoException
)
=>
error
.
code
===
"ENOENT"
);
assert
.
equal
(
sites
.
getProjectPath
(
tenantA
,
siteA
),
path
.
join
(
directory
,
"sites"
,
tenantA
,
siteA
,
"project"
));
assert
.
notEqual
(
sites
.
getSiteRoot
(
tenantA
,
siteA
),
sites
.
getSiteRoot
(
tenantB
,
siteA
));
await
mkdir
(
path
.
dirname
(
sites
.
getArchivedSiteRoot
(
tenantA
,
siteA
)),
{
recursive
:
true
});
await
rename
(
sites
.
getSiteRoot
(
tenantA
,
siteA
),
sites
.
getArchivedSiteRoot
(
tenantA
,
siteA
));
assert
.
deepEqual
(
await
sites
.
list
(
tenantA
),
[]);
assert
.
deepEqual
((
await
sites
.
listArchived
(
tenantA
)).
map
((
site
)
=>
site
.
siteId
),
[
siteA
]);
assert
.
equal
((
await
sites
.
getArchived
(
tenantA
,
siteA
)).
lifecycleStatus
,
"archived"
);
}
finally
{
await
rm
(
directory
,
{
recursive
:
true
,
force
:
true
});
}
});
backend/src/sites/site-repository.ts
View file @
9b34b595
...
...
@@ -22,6 +22,12 @@ export class SiteRepository {
return
path
.
join
(
runtimePaths
.
sites
,
tenantId
,
siteId
);
}
getArchivedSiteRoot
(
tenantId
:
string
,
siteId
:
string
):
string
{
this
.
assertTenantId
(
tenantId
);
this
.
assertSiteId
(
siteId
);
return
path
.
join
(
runtimePaths
.
archivedSites
,
tenantId
,
siteId
);
}
getProjectPath
(
tenantId
:
string
,
siteId
:
string
):
string
{
return
path
.
join
(
this
.
getSiteRoot
(
tenantId
,
siteId
),
"project"
);
}
...
...
@@ -56,6 +62,30 @@ export class SiteRepository {
return
results
.
filter
((
site
):
site
is
SiteInfo
=>
site
!==
null
).
sort
((
a
,
b
)
=>
b
.
updatedAt
.
localeCompare
(
a
.
updatedAt
));
}
async
getArchived
(
tenantId
:
string
,
siteId
:
string
):
Promise
<
SiteInfo
>
{
const
raw
=
await
readFile
(
path
.
join
(
this
.
getArchivedSiteRoot
(
tenantId
,
siteId
),
"metadata"
,
"site.json"
),
"utf8"
);
const
stored
=
JSON
.
parse
(
raw
)
as
SiteInfo
;
if
(
stored
.
tenantId
!==
tenantId
||
stored
.
siteId
!==
siteId
)
throw
Object
.
assign
(
new
Error
(
"站点不存在"
),
{
statusCode
:
404
});
return
{
...
stored
,
lifecycleStatus
:
"archived"
,
previewUrl
:
""
,
productionUrl
:
undefined
};
}
async
listArchived
(
tenantId
:
string
):
Promise
<
SiteInfo
[]
>
{
await
this
.
ensureRuntime
();
const
root
=
path
.
join
(
runtimePaths
.
archivedSites
,
tenantId
);
const
entries
=
await
readdir
(
root
,
{
withFileTypes
:
true
}).
catch
(()
=>
[]);
const
results
=
await
Promise
.
all
(
entries
.
filter
((
entry
)
=>
entry
.
isDirectory
()
&&
/^site_
[
a-z0-9
]
+$/
.
test
(
entry
.
name
)).
map
(
async
(
entry
)
=>
{
try
{
return
await
this
.
getArchived
(
tenantId
,
entry
.
name
);
}
catch
{
return
null
;
}
}));
return
results
.
filter
((
site
):
site
is
SiteInfo
=>
site
!==
null
).
sort
((
a
,
b
)
=>
(
b
.
archivedAt
||
b
.
updatedAt
).
localeCompare
(
a
.
archivedAt
||
a
.
updatedAt
));
}
async
listAllArchived
():
Promise
<
SiteInfo
[]
>
{
await
this
.
ensureRuntime
();
const
tenants
=
await
readdir
(
runtimePaths
.
archivedSites
,
{
withFileTypes
:
true
}).
catch
(()
=>
[]);
const
groups
=
await
Promise
.
all
(
tenants
.
filter
((
entry
)
=>
entry
.
isDirectory
()
&&
/^tenant_
[
a-z0-9_
]
+$/
.
test
(
entry
.
name
)).
map
((
entry
)
=>
this
.
listArchived
(
entry
.
name
)));
return
groups
.
flat
().
sort
((
a
,
b
)
=>
(
b
.
archivedAt
||
b
.
updatedAt
).
localeCompare
(
a
.
archivedAt
||
a
.
updatedAt
));
}
async
listAll
():
Promise
<
SiteInfo
[]
>
{
await
this
.
ensureRuntime
();
const
tenants
=
await
readdir
(
runtimePaths
.
sites
,
{
withFileTypes
:
true
}).
catch
(()
=>
[]);
...
...
frontend/src/App.tsx
View file @
9b34b595
import
{
useEffect
,
useRef
,
useState
,
type
FormEvent
}
from
"react"
;
import
{
useMutation
,
useQuery
,
useQueryClient
}
from
"@tanstack/react-query"
;
import
{
ArrowLeft
,
ArrowRight
,
BookOpen
,
Bot
,
Check
,
CheckCircle2
,
ChevronDown
,
Clock3
,
Code2
,
Copy
,
ExternalLink
,
Globe2
,
History
,
Laptop
,
Link2
,
LoaderCircle
,
MessageSquareText
,
Monitor
,
Plus
,
RefreshCw
,
Rocket
,
Send
,
Settings2
,
ShieldCheck
,
Smartphone
,
Sparkles
,
Trash2
,
Undo2
,
Ar
chive
,
Ar
rowLeft
,
ArrowRight
,
BookOpen
,
Bot
,
Check
,
CheckCircle2
,
ChevronDown
,
Clock3
,
Code2
,
Copy
,
ExternalLink
,
Globe2
,
History
,
Laptop
,
L
ayoutGrid
,
L
ink2
,
LoaderCircle
,
MessageSquareText
,
Monitor
,
Plus
,
RefreshCw
,
Rocket
,
RotateCcw
,
Search
,
Send
,
Settings2
,
ShieldCheck
,
Smartphone
,
Sparkles
,
Trash2
,
Undo2
,
WandSparkles
,
X
,
}
from
"lucide-react"
;
import
type
{
CreateSiteInput
,
CreateTenantInput
,
DomainBinding
,
SessionInfo
,
SiteInfo
,
TenantAdminInfo
}
from
"@webagent/shared"
;
...
...
@@ -19,6 +19,7 @@ export default function App() {
const
healthQuery
=
useQuery
({
queryKey
:
[
"health"
],
queryFn
:
api
.
health
});
const
[
currentSiteId
,
setCurrentSiteId
]
=
useState
(()
=>
localStorage
.
getItem
(
"webagent-current-site"
)
||
""
);
const
[
creating
,
setCreating
]
=
useState
(
false
);
const
[
managing
,
setManaging
]
=
useState
(
false
);
useEffect
(()
=>
{
const
invalidate
=
()
=>
{
setAuthenticated
(
false
);
setCreating
(
false
);
queryClient
.
clear
();
};
...
...
@@ -41,7 +42,7 @@ export default function App() {
},
[
sessionQuery
.
isError
]);
const
openSite
=
(
site
:
SiteInfo
)
=>
{
setCurrentSiteId
(
site
.
siteId
);
setCreating
(
false
);
setCurrentSiteId
(
site
.
siteId
);
setCreating
(
false
);
setManaging
(
false
);
queryClient
.
invalidateQueries
({
queryKey
:
[
"sites"
]
});
};
...
...
@@ -56,8 +57,11 @@ export default function App() {
if
(
sessionQuery
.
isLoading
)
return
<
Splash
/>;
if
(
sessionQuery
.
data
?.
accountType
===
"system_admin"
)
return
<
AdminWorkspace
session=
{
sessionQuery
.
data
}
onLogout=
{
logout
}
/>;
if
(
sitesQuery
.
isLoading
)
return
<
Splash
/>;
if
(
managing
)
{
return
<
SiteManagementWorkspace
sites=
{
sitesQuery
.
data
||
[]
}
currentSiteId=
{
currentSiteId
}
onEdit=
{
(
site
)
=>
openSite
(
site
)
}
onCreate=
{
()
=>
{
setManaging
(
false
);
setCreating
(
true
);
}
}
onBack=
{
()
=>
setManaging
(
false
)
}
onCurrentSiteChange=
{
setCurrentSiteId
}
onLogout=
{
logout
}
/>;
}
if
(
creating
||
!
sitesQuery
.
data
?.
length
)
{
return
<
CreateSiteWorkspace
sites=
{
sitesQuery
.
data
||
[]
}
currentSiteId=
{
currentSiteId
}
onCreated=
{
openSite
}
onCancel=
{
sitesQuery
.
data
?.
length
?
()
=>
setCreating
(
false
)
:
undefined
}
onSelectSite=
{
(
id
)
=>
{
setCurrentSiteId
(
id
);
setCreating
(
false
);
}
}
onLogout=
{
logout
}
/>;
return
<
CreateSiteWorkspace
sites=
{
sitesQuery
.
data
||
[]
}
currentSiteId=
{
currentSiteId
}
onCreated=
{
openSite
}
onCancel=
{
sitesQuery
.
data
?.
length
?
()
=>
setCreating
(
false
)
:
undefined
}
onSelectSite=
{
(
id
)
=>
{
setCurrentSiteId
(
id
);
setCreating
(
false
);
}
}
on
Manage=
{
()
=>
setManaging
(
true
)
}
on
Logout=
{
logout
}
/>;
}
return
<
Workspace
siteId=
{
currentSiteId
}
...
...
@@ -65,6 +69,7 @@ export default function App() {
agentMode=
{
healthQuery
.
data
?.
agentMode
||
"local"
}
onSelectSite=
{
setCurrentSiteId
}
onCreate=
{
()
=>
setCreating
(
true
)
}
onManage=
{
()
=>
setManaging
(
true
)
}
onLogout=
{
logout
}
/>;
}
...
...
@@ -251,13 +256,13 @@ function RailSettings({ onLogout }: { onLogout: () => void }) {
</
div
>;
}
function
CreateSiteWorkspace
({
sites
,
currentSiteId
,
onCreated
,
onCancel
,
onSelectSite
,
on
Logout
}:
{
sites
:
SiteInfo
[];
currentSiteId
:
string
;
onCreated
:
(
site
:
SiteInfo
)
=>
void
;
onCancel
?:
()
=>
void
;
onSelectSite
:
(
id
:
string
)
=>
void
;
onLogout
:
()
=>
void
})
{
function
CreateSiteWorkspace
({
sites
,
currentSiteId
,
onCreated
,
onCancel
,
onSelectSite
,
on
Manage
,
onLogout
}:
{
sites
:
SiteInfo
[];
currentSiteId
:
string
;
onCreated
:
(
site
:
SiteInfo
)
=>
void
;
onCancel
?:
()
=>
void
;
onSelectSite
:
(
id
:
string
)
=>
void
;
onManage
:
(
)
=>
void
;
onLogout
:
()
=>
void
})
{
const
selectedSite
=
sites
.
find
((
site
)
=>
site
.
siteId
===
currentSiteId
)
||
sites
[
0
];
return
<
main
className=
"workspace create-workspace"
>
<
aside
className=
"rail"
>
<
Logo
compact
/>
<
button
className=
"rail-create-top active"
title=
"创建官网"
data
-
tooltip=
"创建官网"
aria
-
label=
"创建官网"
><
Plus
size=
{
18
}
/></
button
>
<
div
className=
"rail-nav"
><
button
title=
"Agent 工作台"
data
-
tooltip=
"Agent 工作台"
aria
-
label=
"Agent 工作台"
onClick=
{
onCancel
}
><
Monitor
size=
{
18
}
/></
button
></
div
>
<
div
className=
"rail-nav"
><
button
title=
"Agent 工作台"
data
-
tooltip=
"Agent 工作台"
aria
-
label=
"Agent 工作台"
onClick=
{
onCancel
}
><
Monitor
size=
{
18
}
/></
button
><
button
title=
"网站管理"
data
-
tooltip=
"网站管理"
aria
-
label=
"网站管理"
onClick=
{
onManage
}
><
LayoutGrid
size=
{
18
}
/></
button
><
/
div
>
<
RailSettings
onLogout=
{
onLogout
}
/>
</
aside
>
<
section
className=
"control-panel create-control-panel"
>
...
...
@@ -312,7 +317,91 @@ function SiteSwitcher({ site, sites, onSelect }: { site: SiteInfo; sites: SiteIn
</
div
>;
}
function
Workspace
({
siteId
,
sites
,
agentMode
,
onSelectSite
,
onCreate
,
onLogout
}:
{
siteId
:
string
;
sites
:
SiteInfo
[];
agentMode
:
"model"
|
"local"
;
onSelectSite
:
(
id
:
string
)
=>
void
;
onCreate
:
()
=>
void
;
onLogout
:
()
=>
void
})
{
type
SiteManagementFilter
=
"all"
|
"published"
|
"unpublished"
|
"failed"
|
"archived"
;
function
SiteManagementWorkspace
({
sites
,
currentSiteId
,
onEdit
,
onCreate
,
onBack
,
onCurrentSiteChange
,
onLogout
}:
{
sites
:
SiteInfo
[];
currentSiteId
:
string
;
onEdit
:
(
site
:
SiteInfo
)
=>
void
;
onCreate
:
()
=>
void
;
onBack
:
()
=>
void
;
onCurrentSiteChange
:
(
siteId
:
string
)
=>
void
;
onLogout
:
()
=>
void
})
{
const
queryClient
=
useQueryClient
();
const
archivedQuery
=
useQuery
({
queryKey
:
[
"archived-sites"
],
queryFn
:
api
.
archivedSites
});
const
[
filter
,
setFilter
]
=
useState
<
SiteManagementFilter
>
(
"all"
);
const
[
search
,
setSearch
]
=
useState
(
""
);
const
[
notice
,
setNotice
]
=
useState
(
""
);
const
[
archiveTarget
,
setArchiveTarget
]
=
useState
<
SiteInfo
>
();
const
[
deleteTarget
,
setDeleteTarget
]
=
useState
<
SiteInfo
>
();
const
[
deleteConfirmation
,
setDeleteConfirmation
]
=
useState
(
""
);
const
refresh
=
async
()
=>
{
await
Promise
.
all
([
queryClient
.
invalidateQueries
({
queryKey
:
[
"sites"
]
}),
queryClient
.
invalidateQueries
({
queryKey
:
[
"archived-sites"
]
}),
]);
};
const
archiveMutation
=
useMutation
({
mutationFn
:
(
siteId
:
string
)
=>
api
.
archiveSite
(
siteId
),
onSuccess
:
async
(
archived
)
=>
{
if
(
currentSiteId
===
archived
.
siteId
)
onCurrentSiteChange
(
sites
.
find
((
site
)
=>
site
.
siteId
!==
archived
.
siteId
)?.
siteId
||
""
);
setArchiveTarget
(
undefined
);
setNotice
(
`“
${
archived
.
name
}
”已移入回收站,可在 30 天内恢复。`
);
await
refresh
();
},
});
const
restoreMutation
=
useMutation
({
mutationFn
:
(
siteId
:
string
)
=>
api
.
restoreSite
(
siteId
),
onSuccess
:
async
(
restored
)
=>
{
setNotice
(
`“
${
restored
.
name
}
”已恢复。`
);
setFilter
(
"all"
);
await
refresh
();
},
});
const
deleteMutation
=
useMutation
({
mutationFn
:
(
siteId
:
string
)
=>
api
.
deleteSite
(
siteId
),
onSuccess
:
async
()
=>
{
setNotice
(
`“
${
deleteTarget
?.
name
||
"网站"
}
”已永久删除。
`); setDeleteTarget(undefined); setDeleteConfirmation(""); await refresh(); },
});
const archived = archivedQuery.data || [];
const isFailed = (site: SiteInfo) => site.status === "failed" || site.publishStatus === "failed" || Boolean(site.lastError || site.lastPublishError);
const counts: Record<SiteManagementFilter, number> = {
all: sites.length,
published: sites.filter((site) => Boolean(site.publishedCommit)).length,
unpublished: sites.filter((site) => !site.publishedCommit).length,
failed: sites.filter(isFailed).length,
archived: archived.length,
};
const source = filter === "archived" ? archived : sites.filter((site) => filter === "all" || (filter === "published" ? Boolean(site.publishedCommit) : filter === "unpublished" ? !site.publishedCommit : isFailed(site)));
const keyword = search.trim().toLowerCase();
const visible = source.filter((site) => !keyword || `
$
{
site
.
name
}
$
{
site
.
industry
}
$
{
site
.
siteId
}
`.toLowerCase().includes(keyword));
const pendingError = archiveMutation.error || restoreMutation.error || deleteMutation.error;
return <main className="site-management-workspace">
<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 onClick={onBack} title="Agent 工作台" data-tooltip="Agent 工作台" aria-label="Agent 工作台"><Monitor size={18} /></button><button className="active" title="网站管理" data-tooltip="网站管理" aria-label="网站管理"><LayoutGrid size={18} /></button></div>
<RailSettings onLogout={onLogout} />
</aside>
<section className="site-management-page">
<header className="site-management-header"><div><span>WEBSITE MANAGEMENT</span><h1>网站管理</h1><p>集中查看和管理当前租户下的所有网站与发布环境。</p></div><button type="button" onClick={onCreate}><Plus size={16} />创建新网站</button></header>
{notice && <div className="management-notice"><CheckCircle2 size={16} /><span>{notice}</span><button type="button" onClick={() => setNotice("")}><X size={14} /></button></div>}
{pendingError && <div className="form-error"><X size={15} />{pendingError.message}</div>}
<nav className="site-management-filters" aria-label="网站状态筛选">{([
["all", "全部"], ["published", "已上线"], ["unpublished", "未上线"], ["failed", "异常"], ["archived", "回收站"],
] as Array<[SiteManagementFilter, string]>).map(([value, label]) => <button type="button" className={filter === value ? "active" : ""} key={value} onClick={() => setFilter(value)}>{label}<span>{counts[value]}</span></button>)}</nav>
<div className="site-management-tools"><label><Search size={16} /><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索网站名称、行业或 ID" /></label><span>{filter === "archived" ? "回收站内容保留 30 天" : "按最近更新时间排序"}</span></div>
<div className="managed-site-list">
{visible.map((site) => filter === "archived" ? <ArchivedSiteCard key={site.siteId} site={site} busy={restoreMutation.isPending || deleteMutation.isPending} onRestore={() => restoreMutation.mutate(site.siteId)} onDelete={() => { setDeleteTarget(site); setDeleteConfirmation(""); }} /> : <ManagedSiteCard key={site.siteId} site={site} selected={site.siteId === currentSiteId} busy={archiveMutation.isPending} onEdit={() => onEdit(site)} onArchive={() => setArchiveTarget(site)} />)}
{!visible.length && <div className="managed-sites-empty">{filter === "archived" ? <Archive size={28} /> : <LayoutGrid size={28} />}<h2>{search ? "没有匹配的网站" : filter === "archived" ? "回收站是空的" : "这个分类下暂无网站"}</h2><p>{search ? "换一个关键词试试。" : filter === "archived" ? "移入回收站的网站会显示在这里。" : "可以切换筛选条件或创建一个新网站。"}</p></div>}
</div>
</section>
{archiveTarget && <ConfirmDialog title="移入回收站?" description={`
“
$
{
archiveTarget
.
name
}
”将停止编辑和发布,测试与线上访问会下线,数据保留
30
天。
`} confirmLabel="移入回收站" busy={archiveMutation.isPending} onCancel={() => setArchiveTarget(undefined)} onConfirm={() => archiveMutation.mutate(archiveTarget.siteId)} />}
{deleteTarget && <div className="confirm-backdrop" role="presentation"><section className="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="delete-site-title"><span className="confirm-icon danger"><Trash2 size={20} /></span><h2 id="delete-site-title">永久删除网站?</h2><p>网站代码、版本历史、草稿和构建文件都会被删除,此操作无法恢复。</p><label>请输入 <strong>{deleteTarget.name}</strong> 确认<input autoFocus value={deleteConfirmation} onChange={(event) => setDeleteConfirmation(event.target.value)} /></label><div><button type="button" onClick={() => { setDeleteTarget(undefined); setDeleteConfirmation(""); }}>取消</button><button className="danger" type="button" disabled={deleteConfirmation !== deleteTarget.name || deleteMutation.isPending} onClick={() => deleteMutation.mutate(deleteTarget.siteId)}>{deleteMutation.isPending && <LoaderCircle className="spin" size={14} />}永久删除</button></div></section></div>}
</main>;
}
function ManagedSiteCard({ site, selected, busy, onEdit, onArchive }: { site: SiteInfo; selected: boolean; busy: boolean; onEdit: () => void; onArchive: () => void }) {
const failed = site.status === "failed" || site.publishStatus === "failed" || Boolean(site.lastError || site.lastPublishError);
return <article className={`
managed
-
site
-
card
$
{
selected
?
"selected"
:
""
}
`}><span className="managed-site-avatar">{site.name.slice(0, 1)}</span><div className="managed-site-main"><div><strong>{site.name}</strong>{selected && <em>当前网站</em>}<span className={failed ? "failed" : site.publishedCommit ? "published" : "unpublished"}>{failed ? "存在异常" : site.publishedCommit ? "已上线" : "未上线"}</span></div><small>{site.industry} · 更新于 {formatDate(site.updatedAt)}</small><div className="managed-site-states"><span><i className={site.status === "ready" ? "ok" : site.status} />测试环境:{site.lastError ? "异常" : site.status === "ready" ? "正常" : site.status === "building" ? "构建中" : site.status === "creating" ? "创建中" : "异常"}</span><span><i className={site.publishedCommit && !site.lastPublishError ? "ok" : site.publishStatus} />生产环境:{site.lastPublishError ? "异常" : site.publishedCommit ? "正常" : "未发布"}</span>{site.draftBaseCommit && <span className="draft"><Sparkles size={11} />存在工作草稿</span>}</div></div><div className="managed-site-actions"><button className="primary" type="button" onClick={onEdit}>进入编辑</button><a href={site.previewUrl} target="_blank" rel="noreferrer"><Monitor size={13} />测试预览</a>{site.publishedCommit && site.productionUrl && <a href={site.productionUrl} target="_blank" rel="noreferrer"><Globe2 size={13} />线上网站</a>}<button className="archive" type="button" disabled={busy} onClick={onArchive} title="移入回收站"><Archive size={14} /></button></div></article>;
}
function ArchivedSiteCard({ site, busy, onRestore, onDelete }: { site: SiteInfo; busy: boolean; onRestore: () => void; onDelete: () => void }) {
return <article className="managed-site-card archived"><span className="managed-site-avatar"><Archive size={18} /></span><div className="managed-site-main"><div><strong>{site.name}</strong><span className="archived">回收站</span></div><small>{site.industry} · 移入于 {formatDate(site.archivedAt || site.updatedAt)}</small><div className="managed-site-states"><span>自动清理时间:{site.deleteAfter ? formatDate(site.deleteAfter) : "30 天后"}</span><span><code>{site.siteId}</code></span></div></div><div className="managed-site-actions"><button className="restore" type="button" disabled={busy} onClick={onRestore}><RotateCcw size={13} />恢复网站</button><button className="delete" type="button" disabled={busy} onClick={onDelete}><Trash2 size={13} />永久删除</button></div></article>;
}
function ConfirmDialog({ title, description, confirmLabel, busy, onCancel, onConfirm }: { title: string; description: string; confirmLabel: string; busy: boolean; onCancel: () => void; onConfirm: () => void }) {
return <div className="confirm-backdrop" role="presentation"><section className="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="confirm-title"><span className="confirm-icon"><Archive size={20} /></span><h2 id="confirm-title">{title}</h2><p>{description}</p><div><button type="button" onClick={onCancel}>取消</button><button className="danger" type="button" disabled={busy} onClick={onConfirm}>{busy && <LoaderCircle className="spin" size={14} />}{confirmLabel}</button></div></section></div>;
}
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) });
const historyQuery = useQuery({ queryKey: ["history", siteId], queryFn: () => api.history(siteId) });
...
...
@@ -421,7 +510,7 @@ function Workspace({ siteId, sites, agentMode, onSelectSite, onCreate, onLogout
<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
></
div
>
<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>
<RailSettings onLogout={onLogout} />
</aside>
<section className="control-panel">
...
...
frontend/src/api.ts
View file @
9b34b595
...
...
@@ -24,8 +24,12 @@ export const api = {
session
:
()
=>
request
<
SessionInfo
>
(
"/api/session"
),
health
:
()
=>
request
<
{
status
:
string
;
agentMode
:
"model"
|
"local"
}
>
(
"/api/health"
),
sites
:
()
=>
request
<
SiteInfo
[]
>
(
"/api/sites"
),
archivedSites
:
()
=>
request
<
SiteInfo
[]
>
(
"/api/archived-sites"
),
site
:
(
siteId
:
string
)
=>
request
<
SiteInfo
>
(
"/api/sites/"
+
siteId
),
createSite
:
(
input
:
CreateSiteInput
)
=>
request
<
SiteInfo
>
(
"/api/sites"
,
{
method
:
"POST"
,
body
:
JSON
.
stringify
(
input
)
}),
archiveSite
:
(
siteId
:
string
)
=>
request
<
SiteInfo
>
(
"/api/sites/"
+
siteId
+
"/archive"
,
{
method
:
"POST"
}),
restoreSite
:
(
siteId
:
string
)
=>
request
<
SiteInfo
>
(
"/api/archived-sites/"
+
siteId
+
"/restore"
,
{
method
:
"POST"
}),
deleteSite
:
(
siteId
:
string
)
=>
request
<
void
>
(
"/api/archived-sites/"
+
siteId
,
{
method
:
"DELETE"
}),
chat
:
(
siteId
:
string
,
message
:
string
)
=>
request
<
ChatResult
>
(
"/api/sites/"
+
siteId
+
"/chat"
,
{
method
:
"POST"
,
body
:
JSON
.
stringify
({
message
})
}),
history
:
(
siteId
:
string
)
=>
request
<
GitHistoryItem
[]
>
(
"/api/sites/"
+
siteId
+
"/history"
),
previewVersion
:
(
siteId
:
string
,
commit
:
string
)
=>
request
<
PreviewVersionResult
>
(
"/api/sites/"
+
siteId
+
"/preview-version"
,
{
method
:
"POST"
,
body
:
JSON
.
stringify
({
commit
})
}),
...
...
frontend/src/styles.css
View file @
9b34b595
...
...
@@ -65,3 +65,14 @@
.admin-page
{
min-height
:
100vh
;
background
:
#f6f6fa
;
color
:
#252333
}
.admin-topbar
{
height
:
72px
;
padding
:
0
34px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
background
:
#fff
;
border-bottom
:
1px
solid
#ebe9f1
}
.admin-topbar
>
div
{
display
:
flex
;
align-items
:
center
;
gap
:
10px
}
.admin-topbar
>
div
>
span
:nth-child
(
2
)
{
display
:
flex
;
flex-direction
:
column
}
.admin-topbar
small
{
color
:
#8a8795
;
font-size
:
11px
}
.admin-topbar
button
{
margin-left
:
14px
;
padding
:
8px
12px
;
display
:
flex
;
align-items
:
center
;
gap
:
6px
;
border
:
1px
solid
#e3e0ea
;
border-radius
:
9px
;
background
:
#fff
;
color
:
#666273
}
.admin-layout
{
min-height
:
calc
(
100vh
-
73px
);
display
:
grid
;
grid-template-columns
:
286px
1
fr
}
.admin-sidebar
{
padding
:
28px
20px
;
background
:
#fff
;
border-right
:
1px
solid
#ebe9f1
}
.admin-sidebar
>
div
{
padding
:
0
8px
18px
;
display
:
flex
;
justify-content
:
space-between
;
color
:
#706c7b
;
font-size
:
13px
}
.admin-sidebar
>
div
strong
{
padding
:
2px
7px
;
border-radius
:
20px
;
background
:
#f0edf7
;
color
:
#6d43cf
}
.admin-create-button
{
width
:
100%
;
padding
:
11px
14px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
gap
:
7px
;
border
:
0
;
border-radius
:
10px
;
background
:
#6d3fe0
;
color
:
#fff
;
font-weight
:
700
}
.admin-sidebar
nav
{
margin-top
:
18px
;
display
:
grid
;
gap
:
5px
}
.admin-sidebar
nav
button
{
width
:
100%
;
padding
:
10px
;
display
:
grid
;
grid-template-columns
:
36px
1
fr
8px
;
align-items
:
center
;
gap
:
9px
;
text-align
:
left
;
border
:
1px
solid
transparent
;
border-radius
:
11px
;
background
:
transparent
}
.admin-sidebar
nav
button
.active
{
background
:
#f5f1ff
;
border-color
:
#e7dcff
}
.admin-sidebar
nav
button
>
span
:first-child
{
width
:
34px
;
height
:
34px
;
display
:
grid
;
place-items
:
center
;
border-radius
:
9px
;
background
:
#ece8f6
;
color
:
#6337c8
;
font-weight
:
800
}
.admin-sidebar
nav
button
>
span
:nth-child
(
2
)
{
min-width
:
0
;
display
:
flex
;
flex-direction
:
column
}
.admin-sidebar
nav
strong
,
.admin-sidebar
nav
small
{
overflow
:
hidden
;
text-overflow
:
ellipsis
}
.admin-sidebar
nav
small
{
margin-top
:
3px
;
color
:
#9894a1
}
.admin-sidebar
nav
i
{
width
:
7px
;
height
:
7px
;
border-radius
:
50%
}
.admin-sidebar
nav
i
.enabled
{
background
:
#35ae75
}
.admin-sidebar
nav
i
.disabled
{
background
:
#bbb7c1
}
.admin-content
{
max-width
:
1040px
;
width
:
100%
;
padding
:
48px
56px
;
margin
:
0
auto
}
.admin-heading
{
display
:
flex
;
align-items
:
flex-start
;
justify-content
:
space-between
}
.admin-heading
>
div
>
span
{
color
:
#7548dd
;
font-size
:
11px
;
font-weight
:
800
;
letter-spacing
:
.14em
}
.admin-heading
h1
{
margin
:
6px
0
8px
;
font-size
:
30px
}
.admin-heading
p
{
margin
:
0
;
color
:
#85818f
}
.admin-heading
code
{
font-size
:
12px
}
.admin-heading
>
button
{
border
:
0
;
background
:
transparent
}
.admin-tenant-heading
h1
{
display
:
flex
;
align-items
:
center
;
gap
:
10px
}
.admin-tenant-heading
h1
em
{
padding
:
4px
8px
;
border-radius
:
6px
;
background
:
#eee7ff
;
color
:
#6940c5
;
font-size
:
11px
;
font-style
:
normal
}
.admin-tenant-heading
>
button
{
padding
:
9px
14px
;
border-radius
:
9px
;
font-weight
:
700
}
.admin-tenant-heading
>
button
.disable
{
background
:
#fff0f0
;
color
:
#bf4545
}
.admin-tenant-heading
>
button
.enable
{
background
:
#e9f8f0
;
color
:
#27835a
}
.admin-stats
{
margin
:
28px
0
22px
;
display
:
grid
;
grid-template-columns
:
repeat
(
3
,
1
fr
);
gap
:
14px
}
.admin-stats
article
{
padding
:
22px
;
border
:
1px
solid
#e8e5ed
;
border-radius
:
14px
;
background
:
#fff
;
display
:
flex
;
flex-direction
:
column
;
gap
:
5px
}
.admin-stats
strong
{
font-size
:
24px
}
.admin-stats
strong
.ok
{
font-size
:
17px
;
color
:
#27835a
}
.admin-stats
strong
.off
{
font-size
:
17px
;
color
:
#b84747
}
.admin-stats
span
{
color
:
#8c8896
;
font-size
:
12px
}
.admin-card
{
padding
:
26px
;
border
:
1px
solid
#e8e5ed
;
border-radius
:
16px
;
background
:
#fff
;
box-shadow
:
0
12px
40px
rgba
(
47
,
36
,
76
,
.04
)}
.admin-card
+
.admin-card
{
margin-top
:
18px
}
.admin-section-title
h2
{
margin
:
0
0
5px
;
font-size
:
17px
}
.admin-section-title
p
{
margin
:
0
;
color
:
#8c8896
;
font-size
:
12px
}
.admin-account-list
{
margin-top
:
18px
;
display
:
grid
;
gap
:
10px
}
.admin-account-list
article
{
padding
:
13px
0
;
display
:
grid
;
grid-template-columns
:
38px
1
fr
auto
auto
;
align-items
:
center
;
gap
:
10px
;
border-top
:
1px
solid
#f0edf3
}
.admin-account-list
article
>
div
{
display
:
flex
;
flex-direction
:
column
;
gap
:
3px
}
.admin-account-list
small
{
color
:
#9995a2
;
font-size
:
10px
}
.admin-account-list
button
{
padding
:
7px
10px
;
border
:
1px
solid
#dfdbe7
;
border-radius
:
8px
;
background
:
#fff
;
color
:
#615d6c
}
.admin-info-card
dl
{
margin
:
18px
0
0
;
display
:
grid
;
grid-template-columns
:
repeat
(
3
,
1
fr
);
gap
:
16px
}
.admin-info-card
dl
div
{
display
:
flex
;
flex-direction
:
column
;
gap
:
5px
}
.admin-info-card
dt
{
color
:
#9894a1
;
font-size
:
11px
}
.admin-info-card
dd
{
margin
:
0
;
font-size
:
13px
}
.admin-create-form
{
max-width
:
700px
;
margin
:
20px
auto
}
.admin-form-grid
{
margin
:
28px
0
;
display
:
grid
;
grid-template-columns
:
1
fr
1
fr
;
gap
:
16px
}
.admin-empty
{
min-height
:
400px
;
display
:
grid
;
place-content
:
center
;
justify-items
:
center
;
gap
:
10px
;
color
:
#85818f
}
.admin-empty
h2
{
margin
:
5px
}
.admin-empty
button
{
padding
:
10px
15px
;
border
:
0
;
border-radius
:
9px
;
background
:
#6d3fe0
;
color
:
#fff
}
.settings-avatar
{
width
:
34px
;
height
:
34px
;
display
:
grid
;
place-items
:
center
;
border-radius
:
9px
;
background
:
#ebe5fa
;
color
:
#6138c1
;
font-weight
:
800
}
.admin-sites-card
>
.admin-section-title
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
}
.admin-sites-card
>
.admin-section-title
>
span
{
padding
:
3px
8px
;
border-radius
:
20px
;
background
:
#f0edf7
;
color
:
#6d43cf
;
font-size
:
12px
;
font-weight
:
700
}
.admin-site-list
{
margin-top
:
18px
;
display
:
grid
;
gap
:
9px
}
.admin-site-list
article
{
padding
:
14px
;
display
:
grid
;
grid-template-columns
:
40px
minmax
(
0
,
1
fr
)
auto
auto
;
align-items
:
center
;
gap
:
12px
;
border
:
1px
solid
#ece9f1
;
border-radius
:
12px
;
background
:
#fcfbfd
}
.admin-site-avatar
{
width
:
40px
;
height
:
40px
;
display
:
grid
;
place-items
:
center
;
border-radius
:
10px
;
background
:
#eee8fc
;
color
:
#6740c4
;
font-weight
:
800
}
.admin-site-copy
{
min-width
:
0
;
display
:
flex
;
flex-direction
:
column
;
gap
:
4px
}
.admin-site-copy
strong
,
.admin-site-copy
small
{
overflow
:
hidden
;
text-overflow
:
ellipsis
;
white-space
:
nowrap
}
.admin-site-copy
small
{
color
:
#8f8b98
;
font-size
:
10px
}
.admin-site-copy
code
{
font-size
:
9px
}
.admin-site-state
{
display
:
flex
;
align-items
:
center
;
gap
:
5px
}
.admin-site-state
span
{
padding
:
4px
7px
;
border-radius
:
6px
;
background
:
#f0eff3
;
color
:
#777380
;
font-size
:
9px
;
white-space
:
nowrap
}
.admin-site-state
span
.ready
,
.admin-site-state
span
.published
{
color
:
#187558
;
background
:
#e7f8f1
}
.admin-site-state
span
.building
,
.admin-site-state
span
.creating
,
.admin-site-state
span
.publishing
{
color
:
#946300
;
background
:
#fff6df
}
.admin-site-state
span
.failed
{
color
:
#b74343
;
background
:
#fff0f0
}
.admin-site-links
{
display
:
flex
;
align-items
:
center
;
gap
:
6px
}
.admin-site-links
a
,
.admin-site-links
>
span
{
height
:
31px
;
padding
:
0
9px
;
display
:
flex
;
align-items
:
center
;
gap
:
5px
;
border
:
1px
solid
#ded9e7
;
border-radius
:
8px
;
color
:
#6540bc
;
background
:
#fff
;
font-size
:
9px
;
font-weight
:
700
;
text-decoration
:
none
;
white-space
:
nowrap
}
.admin-site-links
a
.production
{
color
:
#187558
}
.admin-site-links
>
span
{
color
:
#aaa6b1
;
background
:
#f5f4f6
}
.admin-sites-loading
,
.admin-sites-empty
{
min-height
:
90px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
gap
:
8px
;
color
:
#8c8896
;
font-size
:
12px
}
@media
(
max-width
:
800px
){
.admin-layout
{
grid-template-columns
:
1
fr
}
.admin-sidebar
{
border-right
:
0
;
border-bottom
:
1px
solid
#ebe9f1
}
.admin-sidebar
nav
{
grid-template-columns
:
repeat
(
2
,
minmax
(
0
,
1
fr
))}
.admin-content
{
padding
:
28px
20px
}
.admin-stats
{
grid-template-columns
:
1
fr
}
.admin-account-list
article
{
grid-template-columns
:
38px
1
fr
}
.admin-account-list
button
{
grid-column
:
auto
}
.admin-info-card
dl
,
.admin-form-grid
{
grid-template-columns
:
1
fr
}
.admin-site-list
article
{
grid-template-columns
:
40px
minmax
(
0
,
1
fr
)}
.admin-site-state
,
.admin-site-links
{
grid-column
:
2
;
justify-content
:
flex-start
}
.admin-topbar
{
padding
:
0
18px
}}
/* Tenant website management */
.site-management-workspace
{
height
:
100vh
;
height
:
100
dvh
;
min-height
:
0
;
display
:
grid
;
grid-template-columns
:
64px
1
fr
;
overflow
:
hidden
;
background
:
#f6f6fa
}
.site-management-page
{
min-width
:
0
;
overflow
:
auto
;
padding
:
46px
clamp
(
24px
,
5vw
,
76px
)
64px
}
.site-management-header
{
max-width
:
1180px
;
margin
:
0
auto
28px
;
display
:
flex
;
align-items
:
flex-end
;
justify-content
:
space-between
;
gap
:
24px
}
.site-management-header
>
div
>
span
{
color
:
var
(
--primary
);
font-size
:
10px
;
font-weight
:
850
;
letter-spacing
:
.14em
}
.site-management-header
h1
{
margin
:
7px
0
7px
;
font-size
:
30px
;
letter-spacing
:
-.035em
}
.site-management-header
p
{
margin
:
0
;
color
:
var
(
--muted
);
font-size
:
12px
}
.site-management-header
>
button
{
height
:
40px
;
display
:
flex
;
align-items
:
center
;
gap
:
7px
;
padding
:
0
15px
;
color
:
white
;
background
:
var
(
--gradient
);
border
:
0
;
border-radius
:
10px
;
font-size
:
10px
;
font-weight
:
800
;
box-shadow
:
0
9px
22px
rgba
(
112
,
40
,
255
,
.18
)}
.management-notice
{
max-width
:
1180px
;
margin
:
0
auto
14px
;
min-height
:
42px
;
display
:
flex
;
align-items
:
center
;
gap
:
9px
;
padding
:
8px
12px
;
color
:
#176d55
;
background
:
#eaf8f3
;
border
:
1px
solid
#ccecdf
;
border-radius
:
10px
;
font-size
:
10px
}
.management-notice
>
span
{
flex
:
1
}
.management-notice
button
{
display
:
grid
;
place-items
:
center
;
width
:
27px
;
height
:
27px
;
color
:
#4b806f
;
background
:
transparent
;
border
:
0
;
border-radius
:
7px
}
.site-management-page
>
.form-error
{
max-width
:
1180px
;
margin
:
0
auto
14px
}
.site-management-filters
{
max-width
:
1180px
;
margin
:
0
auto
;
display
:
flex
;
gap
:
4px
;
padding-bottom
:
13px
;
border-bottom
:
1px
solid
#e5e3ea
}
.site-management-filters
button
{
height
:
34px
;
display
:
flex
;
align-items
:
center
;
gap
:
6px
;
padding
:
0
12px
;
color
:
#777482
;
background
:
transparent
;
border
:
0
;
border-radius
:
8px
;
font-size
:
10px
;
font-weight
:
700
}
.site-management-filters
button
:hover
,
.site-management-filters
button
.active
{
color
:
var
(
--primary
);
background
:
var
(
--soft
)}
.site-management-filters
span
{
min-width
:
18px
;
padding
:
2px
5px
;
color
:
#8f8a99
;
background
:
#ebe9ef
;
border-radius
:
12px
;
font-size
:
8px
;
text-align
:
center
}
.site-management-filters
button
.active
span
{
color
:
var
(
--primary
);
background
:
#e5dcff
}
.site-management-tools
{
max-width
:
1180px
;
margin
:
17px
auto
13px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
gap
:
18px
}
.site-management-tools
label
{
width
:
min
(
360px
,
100%
);
height
:
38px
;
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
padding
:
0
11px
;
color
:
#9b98a3
;
background
:
#fff
;
border
:
1px
solid
#e4e1e9
;
border-radius
:
9px
}
.site-management-tools
label
:focus-within
{
color
:
var
(
--primary
);
border-color
:
#c7b6fb
;
box-shadow
:
0
0
0
3px
rgba
(
112
,
40
,
255
,
.06
)}
.site-management-tools
input
{
width
:
100%
;
height
:
100%
;
padding
:
0
;
background
:
transparent
;
border
:
0
;
outline
:
0
;
font-size
:
10px
}
.site-management-tools
>
span
{
color
:
#9a97a2
;
font-size
:
9px
}
.managed-site-list
{
max-width
:
1180px
;
margin
:
auto
;
display
:
grid
;
gap
:
10px
}
.managed-site-card
{
min-width
:
0
;
min-height
:
108px
;
padding
:
17px
;
display
:
grid
;
grid-template-columns
:
46px
minmax
(
260px
,
1
fr
)
auto
;
align-items
:
center
;
gap
:
14px
;
background
:
#fff
;
border
:
1px
solid
#e7e4eb
;
border-radius
:
14px
;
box-shadow
:
0
7px
24px
rgba
(
41
,
31
,
69
,
.035
);
transition
:
.15s
ease
}
.managed-site-card
:hover
{
border-color
:
#d9d2e7
;
box-shadow
:
0
10px
30px
rgba
(
41
,
31
,
69
,
.065
)}
.managed-site-card.selected
{
border-color
:
#d2c3ff
;
box-shadow
:
0
0
0
3px
rgba
(
112
,
40
,
255
,
.045
)}
.managed-site-avatar
{
width
:
46px
;
height
:
46px
;
display
:
grid
;
place-items
:
center
;
color
:
#6739cd
;
background
:
linear-gradient
(
145deg
,
#f1edff
,
#e7e1fa
);
border-radius
:
12px
;
font-size
:
15px
;
font-weight
:
850
}
.managed-site-card.archived
.managed-site-avatar
{
color
:
#77727f
;
background
:
#efedf2
}
.managed-site-main
{
min-width
:
0
}
.managed-site-main
>
div
:first-child
{
display
:
flex
;
align-items
:
center
;
gap
:
7px
}
.managed-site-main
strong
{
font-size
:
13px
}
.managed-site-main
em
,
.managed-site-main
>
div
:first-child
>
span
{
padding
:
3px
6px
;
border-radius
:
5px
;
font-size
:
8px
;
font-style
:
normal
;
font-weight
:
750
}
.managed-site-main
em
{
color
:
var
(
--primary
);
background
:
var
(
--soft
)}
.managed-site-main
>
div
:first-child
>
span
.published
{
color
:
#177457
;
background
:
#e6f7f0
}
.managed-site-main
>
div
:first-child
>
span
.unpublished
{
color
:
#77727f
;
background
:
#efedf2
}
.managed-site-main
>
div
:first-child
>
span
.failed
{
color
:
#b23e3e
;
background
:
#fff0f0
}
.managed-site-main
>
div
:first-child
>
span
.archived
{
color
:
#68636e
;
background
:
#efedf2
}
.managed-site-main
>
small
{
display
:
block
;
margin-top
:
6px
;
color
:
#96929d
;
font-size
:
9px
}
.managed-site-states
{
display
:
flex
;
align-items
:
center
;
gap
:
15px
;
margin-top
:
11px
;
color
:
#706d79
;
font-size
:
8px
}
.managed-site-states
>
span
{
display
:
flex
;
align-items
:
center
;
gap
:
5px
}
.managed-site-states
i
{
width
:
6px
;
height
:
6px
;
border-radius
:
50%
;
background
:
#aaa6b0
}
.managed-site-states
i
.ok
{
background
:
#25a777
}
.managed-site-states
i
.building
,
.managed-site-states
i
.creating
,
.managed-site-states
i
.publishing
{
background
:
#d89b20
}
.managed-site-states
i
.failed
{
background
:
#d55454
}
.managed-site-states
span
.draft
{
color
:
#7250bf
}
.managed-site-states
code
{
font-size
:
8px
}
.managed-site-actions
{
display
:
flex
;
align-items
:
center
;
justify-content
:
flex-end
;
gap
:
6px
}
.managed-site-actions
button
,
.managed-site-actions
a
{
height
:
32px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
gap
:
5px
;
padding
:
0
10px
;
color
:
#625e6b
;
background
:
#fff
;
border
:
1px
solid
#dfdbe5
;
border-radius
:
8px
;
font-size
:
8px
;
font-weight
:
750
;
text-decoration
:
none
;
white-space
:
nowrap
}
.managed-site-actions
button
:hover
,
.managed-site-actions
a
:hover
{
color
:
var
(
--primary
);
background
:
#faf9ff
;
border-color
:
#cfc1fa
}
.managed-site-actions
button
.primary
{
color
:
white
;
background
:
var
(
--gradient
);
border
:
0
}
.managed-site-actions
button
.archive
{
width
:
32px
;
padding
:
0
;
color
:
#8b6570
}
.managed-site-actions
button
.delete
{
color
:
#b13c3c
}
.managed-site-actions
button
.restore
{
color
:
#1a755a
}
.managed-sites-empty
{
min-height
:
310px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
flex-direction
:
column
;
color
:
#aaa6b1
;
background
:
#fff
;
border
:
1px
dashed
#dedbe3
;
border-radius
:
14px
}
.managed-sites-empty
h2
{
margin
:
14px
0
5px
;
color
:
#55515e
;
font-size
:
15px
}
.managed-sites-empty
p
{
margin
:
0
;
font-size
:
10px
}
.confirm-backdrop
{
position
:
fixed
;
z-index
:
100
;
inset
:
0
;
display
:
grid
;
place-items
:
center
;
padding
:
20px
;
background
:
rgba
(
27
,
22
,
39
,
.36
);
backdrop-filter
:
blur
(
4px
)}
.confirm-dialog
{
width
:
min
(
430px
,
100%
);
padding
:
27px
;
background
:
#fff
;
border
:
1px
solid
#e4dfeb
;
border-radius
:
18px
;
box-shadow
:
0
24px
75px
rgba
(
24
,
14
,
50
,
.25
)}
.confirm-icon
{
display
:
grid
;
place-items
:
center
;
width
:
42px
;
height
:
42px
;
color
:
#9b5d19
;
background
:
#fff5df
;
border-radius
:
12px
}
.confirm-icon.danger
{
color
:
#b43d3d
;
background
:
#fff0f0
}
.confirm-dialog
h2
{
margin
:
17px
0
8px
;
font-size
:
19px
}
.confirm-dialog
p
{
margin
:
0
;
color
:
#77737f
;
font-size
:
11px
;
line-height
:
1.75
}
.confirm-dialog
>
label
{
display
:
block
;
margin-top
:
18px
;
color
:
#686471
;
font-size
:
10px
;
line-height
:
1.6
}
.confirm-dialog
>
label
strong
{
color
:
#27242d
}
.confirm-dialog
>
label
input
{
width
:
100%
;
height
:
39px
;
margin-top
:
7px
;
padding
:
0
11px
;
border
:
1px
solid
#ddd9e2
;
border-radius
:
9px
;
outline
:
0
}
.confirm-dialog
>
label
input
:focus
{
border-color
:
#c2aff8
;
box-shadow
:
0
0
0
3px
rgba
(
112
,
40
,
255
,
.07
)}
.confirm-dialog
>
div
:last-child
{
display
:
flex
;
justify-content
:
flex-end
;
gap
:
8px
;
margin-top
:
23px
}
.confirm-dialog
>
div
:last-child
button
{
height
:
36px
;
display
:
flex
;
align-items
:
center
;
gap
:
6px
;
padding
:
0
13px
;
color
:
#625e6a
;
background
:
#fff
;
border
:
1px
solid
#ded9e4
;
border-radius
:
9px
;
font-size
:
9px
;
font-weight
:
750
}
.confirm-dialog
>
div
:last-child
button
.danger
{
color
:
white
;
background
:
#bd4141
;
border-color
:
#bd4141
}
@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
}}
shared/src/index.ts
View file @
9b34b595
export
type
SiteStatus
=
"creating"
|
"ready"
|
"building"
|
"failed"
;
export
type
PublishStatus
=
"unpublished"
|
"publishing"
|
"published"
|
"failed"
;
export
type
SiteLifecycleStatus
=
"active"
|
"archived"
;
export
interface
CreateSiteInput
{
name
:
string
;
...
...
@@ -35,6 +36,9 @@ export interface SiteInfo {
updatedAt
:
string
;
lastError
?:
string
;
lastPublishError
?:
string
;
lifecycleStatus
?:
SiteLifecycleStatus
;
archivedAt
?:
string
;
deleteAfter
?:
string
;
}
export
type
AccountType
=
"system_admin"
|
"tenant_user"
;
...
...
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