osaet/frontend/admin/src/app/app.component.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>
}