feat: add admin publishing workflow and yar theme
Add Go/Postgres admin APIs, Angular admin UI, manual build flow, asset uploads, markdown import/export, configurable slug generation, and the Yar reading theme. Exclude local docs and generated development artifacts from version control.
This commit is contained in:
parent
b78f4b39c9
commit
f0b50d13ea
121 changed files with 27139 additions and 550 deletions
30
frontend/admin/README.md
Normal file
30
frontend/admin/README.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Osaet Admin
|
||||
|
||||
Angular admin UI for the Osaet Gin API.
|
||||
|
||||
## Development
|
||||
|
||||
Start the Go admin API first:
|
||||
|
||||
```bash
|
||||
cd /home/yarnom/Codes/osaet/backend
|
||||
DATABASE_URL='postgres://yarnom:数据库密码@10.66.0.30:5432/osaet?sslmode=disable' \
|
||||
go run ./cmd/osaet-admin serve
|
||||
```
|
||||
|
||||
Install dependencies and start Angular:
|
||||
|
||||
```bash
|
||||
cd /home/yarnom/Codes/osaet/frontend/admin
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
Open:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:4200/
|
||||
```
|
||||
|
||||
The Angular dev server proxies `/api`, `/healthz`, and `/readyz` to
|
||||
`http://127.0.0.1:8080`.
|
||||
62
frontend/admin/angular.json
Normal file
62
frontend/admin/angular.json
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"admin": {
|
||||
"projectType": "application",
|
||||
"schematics": {},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "osaet",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"options": {
|
||||
"outputPath": "dist/admin",
|
||||
"index": "src/index.html",
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": ["zone.js"],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"assets": [],
|
||||
"styles": ["src/styles.css"],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "admin:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "admin:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cli": {
|
||||
"analytics": "1f777743-c088-4a64-b88c-12e453da36c1"
|
||||
}
|
||||
}
|
||||
13966
frontend/admin/package-lock.json
generated
Normal file
13966
frontend/admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
28
frontend/admin/package.json
Normal file
28
frontend/admin/package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "@osaet/admin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "ng serve --host 127.0.0.1 --port 4200 --proxy-config proxy.conf.json",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/animations": "^20.0.0",
|
||||
"@angular/common": "^20.0.0",
|
||||
"@angular/compiler": "^20.0.0",
|
||||
"@angular/core": "^20.0.0",
|
||||
"@angular/forms": "^20.0.0",
|
||||
"@angular/platform-browser": "^20.0.0",
|
||||
"@angular/router": "^20.0.0",
|
||||
"rxjs": "^7.8.0",
|
||||
"tslib": "^2.8.0",
|
||||
"zone.js": "^0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/cli": "^20.0.0",
|
||||
"@angular/compiler-cli": "^20.0.0",
|
||||
"@angular-devkit/build-angular": "^20.0.0",
|
||||
"typescript": "~5.8.0"
|
||||
}
|
||||
}
|
||||
17
frontend/admin/proxy.conf.json
Normal file
17
frontend/admin/proxy.conf.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"/api": {
|
||||
"target": "http://127.0.0.1:8080",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
},
|
||||
"/healthz": {
|
||||
"target": "http://127.0.0.1:8080",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
},
|
||||
"/readyz": {
|
||||
"target": "http://127.0.0.1:8080",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
}
|
||||
}
|
||||
117
frontend/admin/src/app/admin-api.service.ts
Normal file
117
frontend/admin/src/app/admin-api.service.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
|
||||
import {
|
||||
AssetResponse,
|
||||
BuildJobResponse,
|
||||
LoginResponse,
|
||||
DeletePostResponse,
|
||||
PostInput,
|
||||
PostResponse,
|
||||
PostStatus,
|
||||
PostsResponse,
|
||||
SlugResponse
|
||||
} from './models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = '/api/admin';
|
||||
|
||||
me() {
|
||||
return this.http.get<{ user: LoginResponse['user'] }>(`${this.baseUrl}/me`, {
|
||||
withCredentials: true
|
||||
});
|
||||
}
|
||||
|
||||
login(username: string, password: string) {
|
||||
return this.http.post<LoginResponse>(
|
||||
`${this.baseUrl}/login`,
|
||||
{ username, password },
|
||||
{ withCredentials: true }
|
||||
);
|
||||
}
|
||||
|
||||
logout() {
|
||||
return this.http.post<{ ok: boolean }>(`${this.baseUrl}/logout`, {}, { withCredentials: true });
|
||||
}
|
||||
|
||||
listPosts(status: PostStatus | '', limit?: number, offset?: number) {
|
||||
let params = new HttpParams();
|
||||
if (status) {
|
||||
params = params.set('status', status);
|
||||
}
|
||||
if (limit) {
|
||||
params = params.set('limit', String(limit));
|
||||
}
|
||||
if (offset) {
|
||||
params = params.set('offset', String(offset));
|
||||
}
|
||||
return this.http.get<PostsResponse>(`${this.baseUrl}/posts`, {
|
||||
params,
|
||||
withCredentials: true
|
||||
});
|
||||
}
|
||||
|
||||
getPost(id: string) {
|
||||
return this.http.get<PostResponse>(`${this.baseUrl}/posts/${id}`, {
|
||||
withCredentials: true
|
||||
});
|
||||
}
|
||||
|
||||
createPost(input: PostInput) {
|
||||
return this.http.post<PostResponse>(`${this.baseUrl}/posts`, input, {
|
||||
withCredentials: true
|
||||
});
|
||||
}
|
||||
|
||||
updatePost(id: string, input: PostInput) {
|
||||
return this.http.put<PostResponse>(`${this.baseUrl}/posts/${id}`, input, {
|
||||
withCredentials: true
|
||||
});
|
||||
}
|
||||
|
||||
deletePost(id: string) {
|
||||
return this.http.delete<DeletePostResponse>(`${this.baseUrl}/posts/${id}`, {
|
||||
withCredentials: true
|
||||
});
|
||||
}
|
||||
|
||||
publishPost(id: string) {
|
||||
return this.http.post<PostResponse & BuildJobResponse>(
|
||||
`${this.baseUrl}/posts/${id}/publish`,
|
||||
{},
|
||||
{ withCredentials: true }
|
||||
);
|
||||
}
|
||||
|
||||
buildPost(id: string) {
|
||||
return this.http.post<BuildJobResponse>(
|
||||
`${this.baseUrl}/posts/${id}/build`,
|
||||
{},
|
||||
{ withCredentials: true }
|
||||
);
|
||||
}
|
||||
|
||||
getBuildJob(id: string) {
|
||||
return this.http.get<BuildJobResponse>(`${this.baseUrl}/build-jobs/${id}`, {
|
||||
withCredentials: true
|
||||
});
|
||||
}
|
||||
|
||||
uploadAsset(file: File) {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
return this.http.post<AssetResponse>(`${this.baseUrl}/assets`, body, {
|
||||
withCredentials: true
|
||||
});
|
||||
}
|
||||
|
||||
generateSlug(title: string, summary: string, postId?: string) {
|
||||
return this.http.post<SlugResponse>(
|
||||
`${this.baseUrl}/slug`,
|
||||
{ title, summary, postId },
|
||||
{ withCredentials: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
807
frontend/admin/src/app/app.component.css
Normal file
807
frontend/admin/src/app/app.component.css
Normal file
|
|
@ -0,0 +1,807 @@
|
|||
:host {
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 0.7em;
|
||||
padding: 0.72em 1.15em;
|
||||
background: #243b53;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #1c3147;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.upload-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.7em;
|
||||
background: #fff;
|
||||
color: #2f4a63;
|
||||
cursor: pointer;
|
||||
padding: 0.72em 1.15em;
|
||||
}
|
||||
|
||||
.upload-button:hover {
|
||||
background: #f1f3f5;
|
||||
}
|
||||
|
||||
.upload-button input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
background: transparent;
|
||||
color: #2f4a63;
|
||||
padding: 0.45em 0.65em;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link-button:hover {
|
||||
background: #f1f3f5;
|
||||
color: #1c3147;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #e8e5df;
|
||||
border-radius: 0.7em;
|
||||
background: #fff;
|
||||
color: #232428;
|
||||
padding: 0.8em 0.95em;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: #9aa9b6;
|
||||
box-shadow: 0 0 0 0.14em rgb(36 59 83 / 5%);
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 0.5em;
|
||||
color: #55575d;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.loading-shell,
|
||||
.shell {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.loading-shell {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #777b82;
|
||||
}
|
||||
|
||||
.login-view {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
width: min(90vw, 27em);
|
||||
display: grid;
|
||||
gap: 1.2em;
|
||||
border: 1px solid #eee;
|
||||
padding: 2em;
|
||||
border-radius: 0.9em;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.1em 25%;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6em;
|
||||
min-width: 0;
|
||||
color: #777b82;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.breadcrumb a,
|
||||
.breadcrumb button {
|
||||
border-radius: 0.65em;
|
||||
background: transparent;
|
||||
color: #2f4a63;
|
||||
padding: 0.35em 0.45em;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.breadcrumb a:hover,
|
||||
.breadcrumb button:hover {
|
||||
background: #f1f3f5;
|
||||
color: #1c3147;
|
||||
}
|
||||
|
||||
.breadcrumb span:last-child {
|
||||
overflow: hidden;
|
||||
color: #3d3d3f;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.user-menu summary {
|
||||
list-style: none;
|
||||
border-radius: 0.75em;
|
||||
color: #2f4a63;
|
||||
cursor: pointer;
|
||||
padding: 0.55em 0.8em;
|
||||
}
|
||||
|
||||
.user-menu summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-menu summary::after {
|
||||
content: "⌄";
|
||||
margin-left: 0.45em;
|
||||
color: #777b82;
|
||||
}
|
||||
|
||||
.user-menu summary:hover {
|
||||
background: #f1f3f5;
|
||||
}
|
||||
|
||||
.user-menu-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.45em);
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
min-width: 8em;
|
||||
display: grid;
|
||||
gap: 0.2em;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 0.75em;
|
||||
background: #fff;
|
||||
padding: 0.45em;
|
||||
}
|
||||
|
||||
.user-menu-panel button {
|
||||
width: 100%;
|
||||
border-radius: 0.55em;
|
||||
background: transparent;
|
||||
color: #2f4a63;
|
||||
padding: 0.6em 0.8em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.user-menu-panel button:hover {
|
||||
background: #f1f3f5;
|
||||
color: #1c3147;
|
||||
}
|
||||
|
||||
.list-view,
|
||||
.editor-view {
|
||||
width: 50%;
|
||||
margin: 0 auto;
|
||||
padding: 3em 0 4em;
|
||||
}
|
||||
|
||||
.feedback-toast {
|
||||
position: fixed;
|
||||
top: 1.2em;
|
||||
right: 1.2em;
|
||||
z-index: 30;
|
||||
width: min(92vw, 22em);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1em;
|
||||
border: 1px solid #eee;
|
||||
border-left: 0.28em solid #2f7d4f;
|
||||
border-radius: 0.75em;
|
||||
background: #fff;
|
||||
box-shadow: 0 0.25em 1.2em rgb(35 36 40 / 8%);
|
||||
color: #3d3d3f;
|
||||
padding: 0.9em 1em;
|
||||
}
|
||||
|
||||
.feedback-toast.info {
|
||||
border-left-color: #2f4a63;
|
||||
}
|
||||
|
||||
.feedback-toast.error {
|
||||
border-left-color: #9b332c;
|
||||
}
|
||||
|
||||
.feedback-toast div {
|
||||
display: grid;
|
||||
gap: 0.3em;
|
||||
}
|
||||
|
||||
.feedback-toast strong {
|
||||
color: #232428;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.feedback-toast span {
|
||||
color: #676b72;
|
||||
font-size: 0.86em;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.feedback-toast button {
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: #777b82;
|
||||
padding: 0.25em 0.4em;
|
||||
}
|
||||
|
||||
.feedback-toast button:hover {
|
||||
background: #f1f3f5;
|
||||
color: #1c3147;
|
||||
}
|
||||
|
||||
.page-heading,
|
||||
.editor-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
.page-heading h1,
|
||||
.editor-head h1,
|
||||
.login-panel h1 {
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6em;
|
||||
}
|
||||
|
||||
.page-actions select,
|
||||
.page-actions button {
|
||||
width: auto;
|
||||
min-height: 2.55em;
|
||||
border-radius: 0.65em;
|
||||
font-size: 0.9em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.page-actions select {
|
||||
min-width: 8em;
|
||||
padding: 0 2.2em 0 0.9em;
|
||||
}
|
||||
|
||||
.page-actions button {
|
||||
padding: 0 1em;
|
||||
}
|
||||
|
||||
.editor-head h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.35em;
|
||||
color: #8b8175;
|
||||
font-size: 0.78em;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dirty-dot {
|
||||
width: 0.45em;
|
||||
height: 0.45em;
|
||||
border-radius: 50%;
|
||||
background: #a86a2d;
|
||||
}
|
||||
|
||||
.post-table {
|
||||
--post-table-columns: minmax(0, 1.5fr) minmax(8em, 0.8fr) 6em 10em 5.6em;
|
||||
|
||||
display: grid;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.post-table-head,
|
||||
.post-table-row {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: var(--post-table-columns);
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding: 0.9em 0.75em;
|
||||
}
|
||||
|
||||
.post-table-head {
|
||||
color: #777b82;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.post-table-head span {
|
||||
font-size: 0.78em;
|
||||
}
|
||||
|
||||
.post-table-row {
|
||||
background: #fff;
|
||||
color: #232428;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.post-table-row:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.post-row-title {
|
||||
overflow: hidden;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.post-row-tags,
|
||||
.post-table-row > span {
|
||||
color: #777b82;
|
||||
font-size: 0.86em;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.75em;
|
||||
}
|
||||
|
||||
.table-action {
|
||||
color: #2f4a63;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table-action:hover {
|
||||
color: #1c3147;
|
||||
}
|
||||
|
||||
.table-action.danger {
|
||||
color: #9b332c;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
margin: 3em 0;
|
||||
color: #777b82;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45em;
|
||||
margin-top: 2em;
|
||||
color: #6d7179;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.page-numbers {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45em;
|
||||
}
|
||||
|
||||
.page-numbers {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.page-number {
|
||||
min-width: 2.2em;
|
||||
border-radius: 0.55em;
|
||||
background: transparent;
|
||||
color: #2f4a63;
|
||||
padding: 0.45em 0.65em;
|
||||
}
|
||||
|
||||
.page-number:hover,
|
||||
.page-number.active {
|
||||
background: #f1f3f5;
|
||||
color: #1c3147;
|
||||
}
|
||||
|
||||
.editor-form {
|
||||
display: grid;
|
||||
gap: 1.2em;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25em;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 0.8em;
|
||||
background: #fff;
|
||||
padding: 0.35em;
|
||||
box-shadow: 0 0.08em 0.35em rgb(35 36 40 / 4%);
|
||||
}
|
||||
|
||||
.editor-actions button,
|
||||
.editor-actions .upload-button {
|
||||
min-height: 2.45em;
|
||||
border-radius: 0.62em;
|
||||
padding: 0 0.9em;
|
||||
font-size: 0.88em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.editor-actions .upload-button,
|
||||
.editor-actions .save-button,
|
||||
.editor-actions .build-button {
|
||||
background: transparent;
|
||||
color: #2f4a63;
|
||||
}
|
||||
|
||||
.editor-actions .upload-button:hover,
|
||||
.editor-actions .save-button:hover,
|
||||
.editor-actions .build-button:hover {
|
||||
background: #243b53;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.fields-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.slug-control {
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.slug-control input {
|
||||
padding-right: 3.05em;
|
||||
}
|
||||
|
||||
.slug-ai-button {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.35em;
|
||||
width: 2.25em;
|
||||
height: 2.25em;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 0.62em;
|
||||
background: #fff;
|
||||
color: #2f4a63;
|
||||
padding: 0;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.slug-ai-button:hover {
|
||||
background: #f1f3f5;
|
||||
color: #1c3147;
|
||||
}
|
||||
|
||||
.slug-ai-button span {
|
||||
display: block;
|
||||
font-size: 1.05em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.wide-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.body-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.mode-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2em;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 0.7em;
|
||||
padding: 0.2em;
|
||||
}
|
||||
|
||||
.mode-switch button {
|
||||
border-radius: 0.55em;
|
||||
background: transparent;
|
||||
color: #2f4a63;
|
||||
padding: 0.45em 0.7em;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.mode-switch button:hover,
|
||||
.mode-switch button.active {
|
||||
background: #f1f3f5;
|
||||
color: #1c3147;
|
||||
}
|
||||
|
||||
.markdown-workspace {
|
||||
display: grid;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.markdown-workspace.split {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.body-field textarea,
|
||||
.markdown-preview {
|
||||
min-height: 48vh;
|
||||
}
|
||||
|
||||
.body-field textarea {
|
||||
font-family:
|
||||
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.markdown-preview {
|
||||
overflow: auto;
|
||||
border: 1px solid #e8e5df;
|
||||
border-radius: 0.7em;
|
||||
color: #3d3d3f;
|
||||
font-size: 1em;
|
||||
line-height: 1.75;
|
||||
padding: 1em 1.1em;
|
||||
}
|
||||
|
||||
.markdown-preview :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-preview :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown-preview h1,
|
||||
.markdown-preview h2,
|
||||
.markdown-preview h3 {
|
||||
color: #232428;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.markdown-preview a {
|
||||
color: #2f4a63;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.22em;
|
||||
}
|
||||
|
||||
.markdown-preview img {
|
||||
display: block;
|
||||
max-width: min(100%, 42em);
|
||||
border-radius: 0.7em;
|
||||
margin: 1.2em auto;
|
||||
}
|
||||
|
||||
.markdown-preview pre {
|
||||
overflow: auto;
|
||||
border-radius: 0.7em;
|
||||
background: #20242b;
|
||||
color: #f2f2ee;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.markdown-preview code {
|
||||
border-radius: 0.35em;
|
||||
background: #f3f4f6;
|
||||
padding: 0.12em 0.32em;
|
||||
}
|
||||
|
||||
.markdown-preview pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.message {
|
||||
min-height: 1.4em;
|
||||
margin: 0;
|
||||
color: #7b4f27;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.editor-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.autosave-status {
|
||||
color: #777b82;
|
||||
font-size: 0.82em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.build-panel {
|
||||
display: grid;
|
||||
gap: 1em;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 0.8em;
|
||||
background: #fbfaf7;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.build-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.build-panel h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.build-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.8em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.build-meta div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.build-meta dt {
|
||||
color: #777b82;
|
||||
font-size: 0.78em;
|
||||
}
|
||||
|
||||
.build-meta dd {
|
||||
overflow-wrap: anywhere;
|
||||
margin: 0.25em 0 0;
|
||||
color: #35383f;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
|
||||
.build-error {
|
||||
margin: 0;
|
||||
color: #9b332c;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.build-log {
|
||||
max-height: 22em;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
border-radius: 0.7em;
|
||||
background: #20242b;
|
||||
color: #f2f2ee;
|
||||
padding: 1em;
|
||||
font-size: 0.84em;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.topbar {
|
||||
padding-inline: 8%;
|
||||
}
|
||||
|
||||
.list-view,
|
||||
.editor-view {
|
||||
width: 84%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.topbar,
|
||||
.page-heading,
|
||||
.editor-head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
padding: 1em 5%;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.list-view,
|
||||
.editor-view {
|
||||
width: 90%;
|
||||
padding-top: 2em;
|
||||
}
|
||||
|
||||
.post-table-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.post-table-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.35em;
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.fields-grid,
|
||||
.build-meta,
|
||||
.markdown-workspace.split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.editor-actions,
|
||||
.editor-status-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.editor-actions button,
|
||||
.editor-actions .upload-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.autosave-status {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.body-label-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
310
frontend/admin/src/app/app.component.html
Normal file
310
frontend/admin/src/app/app.component.html
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
@if (loading) {
|
||||
<main class="loading-shell">载入中</main>
|
||||
} @else if (!user) {
|
||||
<main class="login-view">
|
||||
<form class="login-panel" (ngSubmit)="login()">
|
||||
<div>
|
||||
<p class="eyebrow">Osaet Admin</p>
|
||||
<h1>登录后台</h1>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
用户名
|
||||
<input name="username" autocomplete="username" [(ngModel)]="loginUsername" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
密码
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
[(ngModel)]="loginPassword"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button type="submit">登录</button>
|
||||
<p class="message">{{ loginMessage }}</p>
|
||||
</form>
|
||||
</main>
|
||||
} @else {
|
||||
<main class="shell">
|
||||
<header class="topbar">
|
||||
<nav class="breadcrumb" aria-label="面包屑">
|
||||
<a href="/">首页</a>
|
||||
<span>/</span>
|
||||
@if (view === 'list') {
|
||||
<span>文章</span>
|
||||
} @else {
|
||||
<button type="button" (click)="backToList()">文章</button>
|
||||
<span>/</span>
|
||||
<span>{{ draft.title || '新文章' }}</span>
|
||||
}
|
||||
</nav>
|
||||
|
||||
<details class="user-menu">
|
||||
<summary>{{ user.username }}</summary>
|
||||
<div class="user-menu-panel">
|
||||
<button type="button" (click)="logout()">退出</button>
|
||||
</div>
|
||||
</details>
|
||||
</header>
|
||||
|
||||
@if (feedback) {
|
||||
<aside
|
||||
class="feedback-toast"
|
||||
[class.info]="feedback.tone === 'info'"
|
||||
[class.error]="feedback.tone === 'error'"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ feedback.title }}</strong>
|
||||
<span>{{ feedback.message }}</span>
|
||||
</div>
|
||||
<button type="button" aria-label="关闭提示" (click)="closeFeedback()">x</button>
|
||||
</aside>
|
||||
}
|
||||
|
||||
@if (view === 'list') {
|
||||
<section class="list-view">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Posts</p>
|
||||
<h1>文章管理</h1>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<select aria-label="文章状态" [(ngModel)]="statusFilter" (change)="changeStatusFilter()">
|
||||
<option value="">全部</option>
|
||||
<option value="draft">草稿</option>
|
||||
<option value="published">已发布</option>
|
||||
<option value="archived">归档</option>
|
||||
</select>
|
||||
<button type="button" (click)="newPost()">新文章</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="post-table">
|
||||
@if (posts.length === 0) {
|
||||
<p class="empty-message">暂无文章</p>
|
||||
} @else {
|
||||
<div class="post-table-head">
|
||||
<span>标题</span>
|
||||
<span>标签</span>
|
||||
<span>状态</span>
|
||||
<span>更新时间</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
@for (post of posts; track post.id) {
|
||||
<div
|
||||
class="post-table-row"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
(click)="selectPost(post.id)"
|
||||
>
|
||||
<span class="post-row-title">{{ post.title || '未命名' }}</span>
|
||||
<span class="post-row-tags">{{ tagText(post) }}</span>
|
||||
<span>{{ statusText(post.status) }}</span>
|
||||
<span>{{ formatDate(post.updatedAt) }}</span>
|
||||
<span class="row-actions">
|
||||
<span class="table-action" role="button" tabindex="0" (click)="editPost(post.id, $event)">
|
||||
编辑
|
||||
</span>
|
||||
<span class="table-action danger" role="button" tabindex="0" (click)="deletePost(post, $event)">
|
||||
删除
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<nav class="pagination" aria-label="文章分页">
|
||||
<button type="button" class="link-button" [disabled]="page <= 1" (click)="previousPage()">
|
||||
← 上一页
|
||||
</button>
|
||||
<span class="page-numbers">
|
||||
<button
|
||||
type="button"
|
||||
class="page-number"
|
||||
*ngFor="let pageNumber of pageNumbers"
|
||||
[class.active]="pageNumber === page"
|
||||
(click)="goToPage(pageNumber)"
|
||||
>
|
||||
{{ pageNumber }}
|
||||
</button>
|
||||
</span>
|
||||
<button type="button" class="link-button" [disabled]="page >= totalPages" (click)="nextPage()">
|
||||
下一页 →
|
||||
</button>
|
||||
</nav>
|
||||
</section>
|
||||
} @else {
|
||||
<section class="editor-view">
|
||||
<form class="editor-form" (ngSubmit)="savePost()">
|
||||
<div class="editor-head">
|
||||
<div>
|
||||
<p class="eyebrow">{{ currentPost ? '版本 ' + currentPost.version : '新文章' }}</p>
|
||||
<h1>
|
||||
{{ draft.title || '开始写作' }}
|
||||
@if (hasUnsavedChanges()) {
|
||||
<span class="dirty-dot" title="未保存"></span>
|
||||
}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<label class="upload-button">
|
||||
{{ uploadingAsset ? '上传中' : '上传图片' }}
|
||||
<input type="file" accept="image/*" [disabled]="uploadingAsset" (change)="uploadAsset($event)" />
|
||||
</label>
|
||||
<button type="submit" class="save-button" [disabled]="saving">{{ saving ? '保存中' : '保存' }}</button>
|
||||
<button type="button" class="build-button" (click)="buildPost()">构建</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fields-grid">
|
||||
<label>
|
||||
标题
|
||||
<input name="title" required [(ngModel)]="draft.title" (input)="onTitleInput()" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Slug
|
||||
<span class="slug-control">
|
||||
<input name="slug" required [(ngModel)]="draft.slug" (input)="onSlugInput()" />
|
||||
<button
|
||||
type="button"
|
||||
class="slug-ai-button"
|
||||
[disabled]="generatingSlug"
|
||||
[attr.aria-label]="generatingSlug ? '正在生成 Slug' : 'AI 生成 Slug'"
|
||||
[title]="generatingSlug ? '正在生成 Slug' : 'AI 生成 Slug'"
|
||||
(click)="generateSlug()"
|
||||
>
|
||||
<span aria-hidden="true">✦</span>
|
||||
</button>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
状态
|
||||
<select name="status" [(ngModel)]="draft.status" (change)="onDraftInput()">
|
||||
<option value="draft">草稿</option>
|
||||
<option value="published">已发布</option>
|
||||
<option value="archived">归档</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
创建时间
|
||||
<input
|
||||
name="createdAt"
|
||||
type="datetime-local"
|
||||
[(ngModel)]="draft.createdAt"
|
||||
(input)="onDraftInput()"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
封面
|
||||
<input name="cover" [(ngModel)]="draft.cover" (input)="onDraftInput()" />
|
||||
</label>
|
||||
|
||||
<label class="wide-field">
|
||||
标签
|
||||
<input
|
||||
name="tags"
|
||||
placeholder="用逗号分隔,例如:生活, 技术"
|
||||
[(ngModel)]="tagsText"
|
||||
(input)="onDraftInput()"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
摘要
|
||||
<textarea
|
||||
name="summary"
|
||||
rows="3"
|
||||
[(ngModel)]="draft.summary"
|
||||
(input)="onDraftInput()"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<label class="body-field">
|
||||
<span class="body-label-row">
|
||||
正文 Markdown
|
||||
<span class="mode-switch" role="group" aria-label="编辑模式">
|
||||
<button type="button" [class.active]="editorMode === 'edit'" (click)="setEditorMode('edit')">
|
||||
编辑
|
||||
</button>
|
||||
<button type="button" [class.active]="editorMode === 'preview'" (click)="setEditorMode('preview')">
|
||||
预览
|
||||
</button>
|
||||
<button type="button" [class.active]="editorMode === 'split'" (click)="setEditorMode('split')">
|
||||
分栏
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<div class="markdown-workspace" [class.split]="editorMode === 'split'">
|
||||
@if (editorMode !== 'preview') {
|
||||
<textarea
|
||||
#bodyTextarea
|
||||
name="bodyMarkdown"
|
||||
spellcheck="false"
|
||||
[(ngModel)]="draft.bodyMarkdown"
|
||||
(input)="onDraftInput()"
|
||||
></textarea>
|
||||
}
|
||||
|
||||
@if (editorMode !== 'edit') {
|
||||
<article class="markdown-preview" [innerHTML]="previewHtml"></article>
|
||||
}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="editor-status-row">
|
||||
<p class="message">{{ editorMessage }}</p>
|
||||
<span class="autosave-status">{{ autosaveStatus }}</span>
|
||||
</div>
|
||||
|
||||
@if (currentBuildJob) {
|
||||
<section class="build-panel">
|
||||
<div class="build-panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">Build Job</p>
|
||||
<h3>{{ buildStatusText(currentBuildJob.status) }}</h3>
|
||||
</div>
|
||||
<button type="button" class="link-button" (click)="toggleBuildLog()">
|
||||
{{ showBuildLog ? '收起日志' : '查看日志' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<dl class="build-meta">
|
||||
<div>
|
||||
<dt>ID</dt>
|
||||
<dd>{{ currentBuildJob.id }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>开始</dt>
|
||||
<dd>{{ formatDate(currentBuildJob.startedAt) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>结束</dt>
|
||||
<dd>{{ formatDate(currentBuildJob.finishedAt) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@if (currentBuildJob.error) {
|
||||
<p class="build-error">{{ currentBuildJob.error }}</p>
|
||||
}
|
||||
|
||||
@if (showBuildLog) {
|
||||
<pre class="build-log">{{ currentBuildJob.log || '暂无日志' }}</pre>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</form>
|
||||
</section>
|
||||
}
|
||||
</main>
|
||||
}
|
||||
790
frontend/admin/src/app/app.component.ts
Normal file
790
frontend/admin/src/app/app.component.ts
Normal file
|
|
@ -0,0 +1,790 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Subscription, catchError, firstValueFrom, interval, of, switchMap, takeWhile } from 'rxjs';
|
||||
|
||||
import { AdminApiService } from './admin-api.service';
|
||||
import { BuildJob, Post, PostInput, PostStatus, User } from './models';
|
||||
|
||||
type FeedbackTone = 'success' | 'info' | 'error';
|
||||
|
||||
@Component({
|
||||
selector: 'osaet-root',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.css'
|
||||
})
|
||||
export class AppComponent implements OnInit, OnDestroy {
|
||||
private readonly api = inject(AdminApiService);
|
||||
@ViewChild('bodyTextarea') private bodyTextarea?: ElementRef<HTMLTextAreaElement>;
|
||||
|
||||
user: User | null = null;
|
||||
posts: Post[] = [];
|
||||
currentPost: Post | null = null;
|
||||
statusFilter: PostStatus | '' = '';
|
||||
view: 'list' | 'editor' = 'list';
|
||||
editorMode: 'edit' | 'preview' | 'split' = 'edit';
|
||||
page = 1;
|
||||
readonly pageSize = 12;
|
||||
totalPosts = 0;
|
||||
|
||||
loginUsername = 'yarnom';
|
||||
loginPassword = '';
|
||||
loginMessage = '';
|
||||
editorMessage = '';
|
||||
feedback: { title: string; message: string; tone: FeedbackTone } | null = null;
|
||||
currentBuildJob: BuildJob | null = null;
|
||||
showBuildLog = false;
|
||||
loading = true;
|
||||
saving = false;
|
||||
uploadingAsset = false;
|
||||
generatingSlug = false;
|
||||
autosaveStatus = '未修改';
|
||||
lastAutosavedAt: Date | null = null;
|
||||
|
||||
draft = this.emptyDraft();
|
||||
tagsText = '';
|
||||
private savedSnapshot = '';
|
||||
private readonly autosaveIntervalMs = 12000;
|
||||
private autosaveSubscription: Subscription | null = null;
|
||||
private feedbackTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
ngOnInit() {
|
||||
void this.bootstrap();
|
||||
this.autosaveSubscription = interval(this.autosaveIntervalMs).subscribe(() => {
|
||||
void this.autosave();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.autosaveSubscription?.unsubscribe();
|
||||
if (this.feedbackTimer) {
|
||||
clearTimeout(this.feedbackTimer);
|
||||
}
|
||||
}
|
||||
|
||||
async bootstrap() {
|
||||
try {
|
||||
const response = await firstValueFrom(this.api.me());
|
||||
this.user = response.user;
|
||||
await this.loadPosts();
|
||||
} catch {
|
||||
this.user = null;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.rememberSavedState();
|
||||
this.updateAutosaveStatus();
|
||||
}
|
||||
}
|
||||
|
||||
async login() {
|
||||
this.loginMessage = '';
|
||||
try {
|
||||
const response = await firstValueFrom(this.api.login(this.loginUsername.trim(), this.loginPassword));
|
||||
this.user = response.user;
|
||||
this.loginPassword = '';
|
||||
await this.loadPosts();
|
||||
this.newPost();
|
||||
} catch (error) {
|
||||
this.loginMessage = errorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
async logout() {
|
||||
if (!this.confirmDiscard()) {
|
||||
return;
|
||||
}
|
||||
await firstValueFrom(this.api.logout().pipe(catchError(() => of({ ok: true }))));
|
||||
this.user = null;
|
||||
this.posts = [];
|
||||
this.currentPost = null;
|
||||
this.draft = this.emptyDraft();
|
||||
this.tagsText = '';
|
||||
this.rememberSavedState();
|
||||
this.updateAutosaveStatus();
|
||||
}
|
||||
|
||||
async loadPosts() {
|
||||
const response = await firstValueFrom(
|
||||
this.api.listPosts(this.statusFilter, this.pageSize, (this.page - 1) * this.pageSize)
|
||||
);
|
||||
this.posts = response.posts ?? [];
|
||||
this.totalPosts = response.total ?? 0;
|
||||
if (this.page > this.totalPages) {
|
||||
this.page = this.totalPages;
|
||||
await this.loadPosts();
|
||||
}
|
||||
}
|
||||
|
||||
async selectPost(id: string) {
|
||||
if (!this.confirmDiscard()) {
|
||||
return;
|
||||
}
|
||||
const response = await firstValueFrom(this.api.getPost(id));
|
||||
this.currentPost = response.post;
|
||||
this.draft = this.postToInput(response.post);
|
||||
this.tagsText = this.draft.tags.join(', ');
|
||||
this.editorMessage = '';
|
||||
this.currentBuildJob = null;
|
||||
this.showBuildLog = false;
|
||||
this.rememberSavedState();
|
||||
this.updateAutosaveStatus();
|
||||
this.view = 'editor';
|
||||
}
|
||||
|
||||
editPost(id: string, event: Event) {
|
||||
event.stopPropagation();
|
||||
void this.selectPost(id);
|
||||
}
|
||||
|
||||
async deletePost(post: Post, event: Event) {
|
||||
event.stopPropagation();
|
||||
if (!window.confirm(`确定删除《${post.title || '未命名'}》吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(this.api.deletePost(post.id));
|
||||
if (this.currentPost?.id === post.id) {
|
||||
this.currentPost = null;
|
||||
this.draft = this.emptyDraft();
|
||||
this.tagsText = '';
|
||||
this.view = 'list';
|
||||
this.rememberSavedState();
|
||||
this.updateAutosaveStatus();
|
||||
}
|
||||
await this.loadPosts();
|
||||
if (response.buildJob) {
|
||||
this.currentBuildJob = response.buildJob;
|
||||
this.editorMessage = '已删除,正在重新构建站点';
|
||||
this.watchBuildJob(response.buildJob);
|
||||
}
|
||||
} catch (error) {
|
||||
this.editorMessage = errorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
newPost() {
|
||||
if (!this.confirmDiscard()) {
|
||||
return;
|
||||
}
|
||||
this.currentPost = null;
|
||||
this.draft = this.emptyDraft();
|
||||
this.tagsText = '';
|
||||
this.editorMessage = '';
|
||||
this.currentBuildJob = null;
|
||||
this.showBuildLog = false;
|
||||
this.rememberSavedState();
|
||||
this.updateAutosaveStatus();
|
||||
this.view = 'editor';
|
||||
}
|
||||
|
||||
async backToList() {
|
||||
if (!this.confirmDiscard()) {
|
||||
return;
|
||||
}
|
||||
this.view = 'list';
|
||||
await this.loadPosts();
|
||||
}
|
||||
|
||||
async changeStatusFilter() {
|
||||
this.page = 1;
|
||||
await this.loadPosts();
|
||||
}
|
||||
|
||||
async previousPage() {
|
||||
if (this.page <= 1) {
|
||||
return;
|
||||
}
|
||||
this.page -= 1;
|
||||
await this.loadPosts();
|
||||
}
|
||||
|
||||
async nextPage() {
|
||||
if (this.page >= this.totalPages) {
|
||||
return;
|
||||
}
|
||||
this.page += 1;
|
||||
await this.loadPosts();
|
||||
}
|
||||
|
||||
async goToPage(page: number) {
|
||||
if (page < 1 || page > this.totalPages || page === this.page) {
|
||||
return;
|
||||
}
|
||||
this.page = page;
|
||||
await this.loadPosts();
|
||||
}
|
||||
|
||||
async savePost(silent = false) {
|
||||
this.saving = true;
|
||||
if (!silent) {
|
||||
this.editorMessage = '';
|
||||
}
|
||||
if (silent) {
|
||||
this.autosaveStatus = '自动保存中';
|
||||
}
|
||||
try {
|
||||
const input = this.normalizedDraft();
|
||||
const response = this.currentPost
|
||||
? await firstValueFrom(this.api.updatePost(this.currentPost.id, input))
|
||||
: await firstValueFrom(this.api.createPost(input));
|
||||
this.currentPost = response.post;
|
||||
this.draft = this.postToInput(response.post);
|
||||
this.tagsText = this.draft.tags.join(', ');
|
||||
await this.loadPosts();
|
||||
if (!silent) {
|
||||
this.editorMessage = '已保存';
|
||||
this.showFeedback('保存成功', '文章内容已经保存。');
|
||||
this.lastAutosavedAt = null;
|
||||
}
|
||||
if (silent) {
|
||||
this.lastAutosavedAt = new Date();
|
||||
}
|
||||
this.rememberSavedState();
|
||||
this.updateAutosaveStatus();
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (silent) {
|
||||
this.autosaveStatus = `自动保存失败:${errorMessage(error)}`;
|
||||
} else {
|
||||
const message = errorMessage(error);
|
||||
this.editorMessage = message;
|
||||
this.showFeedback('保存失败', message, 'error');
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async buildPost() {
|
||||
const saved = await this.savePost();
|
||||
if (!saved) {
|
||||
return;
|
||||
}
|
||||
if (!this.currentPost) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.editorMessage = '';
|
||||
try {
|
||||
const response = await firstValueFrom(this.api.buildPost(this.currentPost.id));
|
||||
this.editorMessage = '已开始构建';
|
||||
this.showFeedback('构建已提交', '正在按照当前状态生成静态站点。', 'info');
|
||||
this.currentBuildJob = response.buildJob;
|
||||
this.showBuildLog = false;
|
||||
this.rememberSavedState();
|
||||
this.updateAutosaveStatus();
|
||||
this.watchBuildJob(response.buildJob);
|
||||
} catch (error) {
|
||||
const message = errorMessage(error);
|
||||
this.editorMessage = message;
|
||||
this.showFeedback('构建失败', message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
watchBuildJob(job: BuildJob, notify = true) {
|
||||
interval(1400)
|
||||
.pipe(
|
||||
switchMap(() => this.api.getBuildJob(job.id)),
|
||||
takeWhile((response) => {
|
||||
const status = response.buildJob.status;
|
||||
this.currentBuildJob = response.buildJob;
|
||||
this.editorMessage =
|
||||
status === 'failed' && response.buildJob.error
|
||||
? `构建失败:${response.buildJob.error}`
|
||||
: `构建状态:${buildStatusText(status)}`;
|
||||
if (!notify) {
|
||||
return !['success', 'failed', 'cancelled'].includes(status);
|
||||
}
|
||||
if (status === 'success') {
|
||||
this.showFeedback('构建完成', '静态页面已经更新。');
|
||||
} else if (status === 'failed') {
|
||||
this.showFeedback('构建失败', response.buildJob.error || '请查看构建日志。', 'error');
|
||||
} else if (status === 'cancelled') {
|
||||
this.showFeedback('构建已取消', '本次构建没有完成。', 'error');
|
||||
}
|
||||
return !['success', 'failed', 'cancelled'].includes(status);
|
||||
}, true),
|
||||
catchError((error) => {
|
||||
const message = errorMessage(error);
|
||||
this.editorMessage = message;
|
||||
this.showFeedback('构建状态获取失败', message, 'error');
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
onTitleInput() {
|
||||
if (!this.currentPost && !this.draft.slug.trim()) {
|
||||
this.draft.slug = slugify(this.draft.title);
|
||||
this.draft.slugSource = 'title';
|
||||
this.draft.slugLocked = false;
|
||||
}
|
||||
this.updateAutosaveStatus();
|
||||
}
|
||||
|
||||
onSlugInput() {
|
||||
this.draft.slugSource = 'manual';
|
||||
this.draft.slugLocked = true;
|
||||
this.updateAutosaveStatus();
|
||||
}
|
||||
|
||||
hasUnsavedChanges() {
|
||||
return this.editorSnapshot() !== this.savedSnapshot;
|
||||
}
|
||||
|
||||
@HostListener('window:beforeunload', ['$event'])
|
||||
beforeUnload(event: BeforeUnloadEvent) {
|
||||
if (this.hasUnsavedChanges()) {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('window:keydown', ['$event'])
|
||||
handleKeydown(event: KeyboardEvent) {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
|
||||
event.preventDefault();
|
||||
if (this.user && !this.saving) {
|
||||
void this.savePost();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onDraftInput() {
|
||||
this.updateAutosaveStatus();
|
||||
}
|
||||
|
||||
statusText(status: PostStatus) {
|
||||
return statusText(status);
|
||||
}
|
||||
|
||||
formatDate(value?: string | null) {
|
||||
if (!value) {
|
||||
return '无时间';
|
||||
}
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
tagText(post: Post) {
|
||||
return post.tags?.length ? post.tags.join(' / ') : '无标签';
|
||||
}
|
||||
|
||||
buildStatusText(status: string) {
|
||||
return buildStatusText(status);
|
||||
}
|
||||
|
||||
toggleBuildLog() {
|
||||
this.showBuildLog = !this.showBuildLog;
|
||||
}
|
||||
|
||||
setEditorMode(mode: 'edit' | 'preview' | 'split') {
|
||||
this.editorMode = mode;
|
||||
}
|
||||
|
||||
closeFeedback() {
|
||||
this.feedback = null;
|
||||
if (this.feedbackTimer) {
|
||||
clearTimeout(this.feedbackTimer);
|
||||
this.feedbackTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
get previewHtml() {
|
||||
return renderMarkdown(this.draft.bodyMarkdown);
|
||||
}
|
||||
|
||||
async uploadAsset(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploadingAsset = true;
|
||||
this.editorMessage = '正在上传图片';
|
||||
try {
|
||||
const response = await firstValueFrom(this.api.uploadAsset(file));
|
||||
this.insertMarkdown(``);
|
||||
this.editorMessage = '图片已插入';
|
||||
this.updateAutosaveStatus();
|
||||
} catch (error) {
|
||||
this.editorMessage = errorMessage(error);
|
||||
} finally {
|
||||
this.uploadingAsset = false;
|
||||
}
|
||||
}
|
||||
|
||||
async generateSlug() {
|
||||
const title = this.draft.title.trim();
|
||||
if (!title) {
|
||||
this.showFeedback('无法生成 Slug', '请先填写标题。', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.generatingSlug = true;
|
||||
this.editorMessage = '正在生成 Slug';
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.api.generateSlug(title, this.draft.summary.trim(), this.currentPost?.id)
|
||||
);
|
||||
this.draft.slug = response.slug;
|
||||
this.draft.slugSource = 'ai';
|
||||
this.draft.slugLocked = false;
|
||||
this.editorMessage = 'Slug 已生成';
|
||||
this.showFeedback('Slug 已生成', response.slug);
|
||||
this.updateAutosaveStatus();
|
||||
} catch (error) {
|
||||
const message = errorMessage(error);
|
||||
this.editorMessage = message;
|
||||
this.showFeedback('Slug 生成失败', message, 'error');
|
||||
} finally {
|
||||
this.generatingSlug = false;
|
||||
}
|
||||
}
|
||||
|
||||
get totalPages() {
|
||||
return Math.max(1, Math.ceil(this.totalPosts / this.pageSize));
|
||||
}
|
||||
|
||||
get pageNumbers() {
|
||||
return Array.from({ length: this.totalPages }, (_, index) => index + 1);
|
||||
}
|
||||
|
||||
private async autosave() {
|
||||
if (!this.canAutosave()) {
|
||||
return;
|
||||
}
|
||||
await this.savePost(true);
|
||||
}
|
||||
|
||||
private canAutosave() {
|
||||
const input = this.normalizedDraft();
|
||||
return Boolean(
|
||||
this.user &&
|
||||
!this.saving &&
|
||||
this.hasUnsavedChanges() &&
|
||||
input.title &&
|
||||
input.slug
|
||||
);
|
||||
}
|
||||
|
||||
private normalizedDraft(): PostInput {
|
||||
return {
|
||||
...this.draft,
|
||||
title: this.draft.title.trim(),
|
||||
slug: this.draft.slug.trim(),
|
||||
summary: this.draft.summary.trim(),
|
||||
cover: this.draft.cover.trim(),
|
||||
tags: parseTags(this.tagsText),
|
||||
createdAt: datetimeLocalToIso(this.draft.createdAt)
|
||||
};
|
||||
}
|
||||
|
||||
private postToInput(post: Post): PostInput {
|
||||
return {
|
||||
slug: post.slug,
|
||||
title: post.title,
|
||||
summary: post.summary,
|
||||
bodyMarkdown: post.bodyMarkdown,
|
||||
status: post.status === 'deleted' ? 'draft' : post.status,
|
||||
tags: [...(post.tags ?? [])],
|
||||
cover: post.cover,
|
||||
slugSource: post.slugSource || 'manual',
|
||||
slugLocked: post.slugLocked,
|
||||
createdAt: toDateTimeLocal(post.createdAt)
|
||||
};
|
||||
}
|
||||
|
||||
private emptyDraft(): PostInput {
|
||||
return {
|
||||
slug: '',
|
||||
title: '',
|
||||
summary: '',
|
||||
bodyMarkdown: '',
|
||||
status: 'draft',
|
||||
tags: [],
|
||||
cover: '',
|
||||
slugSource: 'manual',
|
||||
slugLocked: true,
|
||||
createdAt: toDateTimeLocal(new Date().toISOString())
|
||||
};
|
||||
}
|
||||
|
||||
private confirmDiscard() {
|
||||
if (!this.hasUnsavedChanges()) {
|
||||
return true;
|
||||
}
|
||||
return window.confirm('当前文章有未保存的修改,确定要离开吗?');
|
||||
}
|
||||
|
||||
private rememberSavedState() {
|
||||
this.savedSnapshot = this.editorSnapshot();
|
||||
}
|
||||
|
||||
private updateAutosaveStatus() {
|
||||
if (this.saving) {
|
||||
return;
|
||||
}
|
||||
if (this.hasUnsavedChanges()) {
|
||||
this.autosaveStatus = '有未保存修改';
|
||||
return;
|
||||
}
|
||||
if (this.lastAutosavedAt) {
|
||||
this.autosaveStatus = `已自动保存 ${this.formatDate(this.lastAutosavedAt.toISOString())}`;
|
||||
return;
|
||||
}
|
||||
this.autosaveStatus = '已保存';
|
||||
}
|
||||
|
||||
private editorSnapshot() {
|
||||
return JSON.stringify({
|
||||
...this.draft,
|
||||
tags: parseTags(this.tagsText)
|
||||
});
|
||||
}
|
||||
|
||||
private insertMarkdown(markdown: string) {
|
||||
const textarea = this.bodyTextarea?.nativeElement;
|
||||
const current = this.draft.bodyMarkdown ?? '';
|
||||
if (!textarea) {
|
||||
this.draft.bodyMarkdown = current ? `${current}\n\n${markdown}\n` : `${markdown}\n`;
|
||||
return;
|
||||
}
|
||||
|
||||
const start = textarea.selectionStart ?? current.length;
|
||||
const end = textarea.selectionEnd ?? current.length;
|
||||
const prefix = current.slice(0, start);
|
||||
const suffix = current.slice(end);
|
||||
const before = prefix && !prefix.endsWith('\n') ? '\n\n' : '';
|
||||
const after = suffix && !suffix.startsWith('\n') ? '\n\n' : '\n';
|
||||
this.draft.bodyMarkdown = `${prefix}${before}${markdown}${after}${suffix}`;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus();
|
||||
const cursor = start + before.length + markdown.length + after.length;
|
||||
textarea.setSelectionRange(cursor, cursor);
|
||||
});
|
||||
}
|
||||
|
||||
private showFeedback(title: string, message: string, tone: FeedbackTone = 'success') {
|
||||
this.feedback = { title, message, tone };
|
||||
if (this.feedbackTimer) {
|
||||
clearTimeout(this.feedbackTimer);
|
||||
}
|
||||
this.feedbackTimer = setTimeout(() => {
|
||||
this.feedback = null;
|
||||
this.feedbackTimer = null;
|
||||
}, 3200);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTags(tags: string[]) {
|
||||
const seen = new Set<string>();
|
||||
return tags
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => {
|
||||
const key = tag.toLowerCase();
|
||||
if (!tag || seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function parseTags(value: string) {
|
||||
return normalizeTags(value.split(/[,,]/));
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_]+/g, '-')
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5-]+/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
function altText(filename: string) {
|
||||
return filename.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim() || 'image';
|
||||
}
|
||||
|
||||
function toDateTimeLocal(value?: string | null) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
|
||||
return localDate.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function datetimeLocalToIso(value?: string | null) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function renderMarkdown(markdown: string) {
|
||||
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
|
||||
const html: string[] = [];
|
||||
let paragraph: string[] = [];
|
||||
let list: string[] = [];
|
||||
let code: string[] = [];
|
||||
let inCode = false;
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (paragraph.length === 0) {
|
||||
return;
|
||||
}
|
||||
html.push(`<p>${inlineMarkdown(paragraph.join(' '))}</p>`);
|
||||
paragraph = [];
|
||||
};
|
||||
|
||||
const flushList = () => {
|
||||
if (list.length === 0) {
|
||||
return;
|
||||
}
|
||||
html.push(`<ul>${list.map((item) => `<li>${inlineMarkdown(item)}</li>`).join('')}</ul>`);
|
||||
list = [];
|
||||
};
|
||||
|
||||
const flushCode = () => {
|
||||
html.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`);
|
||||
code = [];
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim().startsWith('```')) {
|
||||
if (inCode) {
|
||||
flushCode();
|
||||
inCode = false;
|
||||
} else {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
inCode = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inCode) {
|
||||
code.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = /^(#{1,6})\s+(.+)$/.exec(trimmed);
|
||||
if (heading) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
const level = heading[1].length;
|
||||
html.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^[-*_]{3,}$/.test(trimmed)) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
html.push('<hr>');
|
||||
continue;
|
||||
}
|
||||
|
||||
const listItem = /^[-*]\s+(.+)$/.exec(trimmed);
|
||||
if (listItem) {
|
||||
flushParagraph();
|
||||
list.push(listItem[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
flushList();
|
||||
paragraph.push(trimmed);
|
||||
}
|
||||
|
||||
if (inCode) {
|
||||
flushCode();
|
||||
}
|
||||
flushParagraph();
|
||||
flushList();
|
||||
|
||||
return html.join('\n');
|
||||
}
|
||||
|
||||
function inlineMarkdown(value: string) {
|
||||
return escapeHtml(value)
|
||||
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, '<img src="$2" alt="$1">')
|
||||
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function statusText(status: PostStatus) {
|
||||
return (
|
||||
{
|
||||
draft: '草稿',
|
||||
published: '已发布',
|
||||
archived: '归档',
|
||||
deleted: '已删除'
|
||||
} satisfies Record<PostStatus, string>
|
||||
)[status];
|
||||
}
|
||||
|
||||
function buildStatusText(status: string) {
|
||||
return (
|
||||
{
|
||||
queued: '等待中',
|
||||
running: '构建中',
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
cancelled: '已取消'
|
||||
} as Record<string, string>
|
||||
)[status] ?? status;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (typeof error === 'object' && error && 'error' in error) {
|
||||
const body = (error as { error?: { error?: string } }).error;
|
||||
if (body?.error) {
|
||||
return body.error;
|
||||
}
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return '请求失败';
|
||||
}
|
||||
97
frontend/admin/src/app/models.ts
Normal file
97
frontend/admin/src/app/models.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
export type PostStatus = 'draft' | 'published' | 'archived' | 'deleted';
|
||||
export type BuildJobStatus = 'queued' | 'running' | 'success' | 'failed' | 'cancelled';
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
username: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastLoginAt?: string | null;
|
||||
};
|
||||
|
||||
export type Post = {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
bodyMarkdown: string;
|
||||
status: PostStatus;
|
||||
tags: string[];
|
||||
cover: string;
|
||||
version: number;
|
||||
slugSource: string;
|
||||
slugLocked: boolean;
|
||||
publishedAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string | null;
|
||||
};
|
||||
|
||||
export type PostInput = {
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
bodyMarkdown: string;
|
||||
status: PostStatus;
|
||||
tags: string[];
|
||||
cover: string;
|
||||
slugSource: string;
|
||||
slugLocked: boolean;
|
||||
createdAt?: string | null;
|
||||
};
|
||||
|
||||
export type BuildJob = {
|
||||
id: string;
|
||||
trigger: string;
|
||||
status: BuildJobStatus;
|
||||
postId?: string | null;
|
||||
startedAt?: string | null;
|
||||
finishedAt?: string | null;
|
||||
log: string;
|
||||
error: string;
|
||||
createdAt: string;
|
||||
createdBy?: string | null;
|
||||
};
|
||||
|
||||
export type Asset = {
|
||||
id: string;
|
||||
path: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
createdAt: string;
|
||||
createdBy?: string | null;
|
||||
};
|
||||
|
||||
export type LoginResponse = {
|
||||
user: User;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export type PostResponse = {
|
||||
post: Post;
|
||||
buildJob?: BuildJob | null;
|
||||
};
|
||||
|
||||
export type PostsResponse = {
|
||||
posts: Post[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type BuildJobResponse = {
|
||||
buildJob: BuildJob;
|
||||
};
|
||||
|
||||
export type AssetResponse = {
|
||||
asset: Asset;
|
||||
};
|
||||
|
||||
export type SlugResponse = {
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export type DeletePostResponse = {
|
||||
ok: boolean;
|
||||
buildJob?: BuildJob | null;
|
||||
};
|
||||
12
frontend/admin/src/index.html
Normal file
12
frontend/admin/src/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Osaet Admin</title>
|
||||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body>
|
||||
<osaet-root></osaet-root>
|
||||
</body>
|
||||
</html>
|
||||
9
frontend/admin/src/main.ts
Normal file
9
frontend/admin/src/main.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { provideHttpClient, withFetch } from '@angular/common/http';
|
||||
import { provideAnimations } from '@angular/platform-browser/animations';
|
||||
|
||||
import { AppComponent } from './app/app.component';
|
||||
|
||||
bootstrapApplication(AppComponent, {
|
||||
providers: [provideHttpClient(withFetch()), provideAnimations()]
|
||||
}).catch((error) => console.error(error));
|
||||
29
frontend/admin/src/styles.css
Normal file
29
frontend/admin/src/styles.css
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
:root {
|
||||
color: #24262b;
|
||||
background: #f7f6f2;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans CJK SC",
|
||||
"Source Han Sans SC", "Microsoft YaHei", sans-serif;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: #f7f6f2;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
}
|
||||
9
frontend/admin/tsconfig.app.json
Normal file
9
frontend/admin/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"files": ["src/main.ts"],
|
||||
"include": ["src/**/*.d.ts"]
|
||||
}
|
||||
28
frontend/admin/tsconfig.json
Normal file
28
frontend/admin/tsconfig.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"outDir": "./dist/out-tsc",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"sourceMap": true,
|
||||
"declaration": false,
|
||||
"downlevelIteration": true,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "bundler",
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"lib": ["ES2022", "dom"]
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue