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
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>
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue