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:
yarnom 2026-06-01 15:48:04 +08:00
parent b78f4b39c9
commit f0b50d13ea
121 changed files with 27139 additions and 550 deletions

View 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>
}