386 lines
14 KiB
HTML
386 lines
14 KiB
HTML
@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 if (view === 'logs') {
|
|
<button type="button" (click)="backToList()">文章</button>
|
|
<span>/</span>
|
|
<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)="showLogs()">日志</button>
|
|
<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 if (view === 'logs') {
|
|
<section class="list-view">
|
|
<div class="page-heading">
|
|
<div>
|
|
<p class="eyebrow">Audit</p>
|
|
<h1>操作日志</h1>
|
|
</div>
|
|
<div class="page-actions log-actions">
|
|
<input
|
|
name="auditQuery"
|
|
placeholder="搜索用户、动作、资源、详情"
|
|
[(ngModel)]="auditQuery"
|
|
(keyup.enter)="changeAuditFilter()"
|
|
/>
|
|
<select aria-label="日志动作" [(ngModel)]="auditActionFilter" (change)="changeAuditFilter()">
|
|
<option value="">全部动作</option>
|
|
<option value="login">登录</option>
|
|
<option value="login_failed">登录失败</option>
|
|
<option value="logout">退出</option>
|
|
<option value="post_create">新建文章</option>
|
|
<option value="post_update">修改文章</option>
|
|
<option value="post_delete">删除文章</option>
|
|
<option value="post_publish">发布文章</option>
|
|
<option value="build_create">提交构建</option>
|
|
<option value="slug_generate">生成 Slug</option>
|
|
</select>
|
|
<select aria-label="资源类型" [(ngModel)]="auditResourceFilter" (change)="changeAuditFilter()">
|
|
<option value="">全部资源</option>
|
|
<option value="user">用户</option>
|
|
<option value="post">文章</option>
|
|
<option value="build_job">构建</option>
|
|
</select>
|
|
<button type="button" (click)="changeAuditFilter()">查询</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="post-table log-table">
|
|
@if (auditLogs.length === 0) {
|
|
<p class="empty-message">暂无日志</p>
|
|
} @else {
|
|
<div class="log-table-head">
|
|
<span>时间</span>
|
|
<span>用户</span>
|
|
<span>动作</span>
|
|
<span>资源</span>
|
|
<span>详情</span>
|
|
<span>IP</span>
|
|
</div>
|
|
@for (log of auditLogs; track log.id) {
|
|
<div class="log-table-row">
|
|
<span>{{ formatDate(log.createdAt) }}</span>
|
|
<span>{{ log.actorUsername || '匿名' }}</span>
|
|
<span>{{ actionText(log.action) }}</span>
|
|
<span>{{ log.resourceType || '-' }} {{ log.resourceId || '' }}</span>
|
|
<span class="log-details">{{ detailsText(log) }}</span>
|
|
<span>{{ log.ipAddress || '-' }}</span>
|
|
</div>
|
|
}
|
|
}
|
|
</div>
|
|
|
|
<nav class="pagination" aria-label="日志分页">
|
|
<button type="button" class="link-button" [disabled]="auditPage <= 1" (click)="previousAuditPage()">
|
|
← 上一页
|
|
</button>
|
|
<span>第 {{ auditPage }} / {{ totalAuditPages }} 页,共 {{ totalAuditLogs }} 条</span>
|
|
<button
|
|
type="button"
|
|
class="link-button"
|
|
[disabled]="auditPage >= totalAuditPages"
|
|
(click)="nextAuditPage()"
|
|
>
|
|
下一页 →
|
|
</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">
|
|
<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
|
|
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>
|
|
}
|