@@ -21,7 +21,7 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<button class="btn primary" @click="ctx.checkSources"><Activity :size="16" />立即心跳检测</button>
|
||||
<button class="btn primary" @click="ctx.checkSources"><Activity :size="16" />立即服务端检测</button>
|
||||
<button class="btn ghost" @click="ctx.toggleAutoRefresh">
|
||||
<component :is="ctx.autoRefreshPaused ? PlayCircle : PauseCircle" :size="16" />
|
||||
{{ ctx.autoRefreshPaused ? "恢复自动刷新" : "暂停自动刷新" }}
|
||||
@@ -30,7 +30,7 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
</div>
|
||||
|
||||
<section v-if="ctx.sourceCheckJobs.length" class="panel">
|
||||
<div class="section-head"><h2>心跳检测任务</h2><span class="badge">{{ ctx.sourceCheckJobs[0].status }}</span></div>
|
||||
<div class="section-head"><h2>服务端检测任务</h2><span class="badge">{{ ctx.sourceCheckJobs[0].status }}</span></div>
|
||||
<table>
|
||||
<thead><tr><th>任务</th><th>进度</th><th>正常</th><th>重定向</th><th>降级</th><th>错误</th><th>开始时间</th></tr></thead>
|
||||
<tbody>
|
||||
@@ -48,14 +48,21 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
</section>
|
||||
|
||||
<div class="chart-grid">
|
||||
<section class="panel chart-panel"><h2>接口心跳延迟</h2><VChart class="chart" :option="ctx.heartbeatOption" autoresize /></section>
|
||||
<section class="panel chart-panel chart-panel-relative">
|
||||
<h2>服务端接口延迟</h2>
|
||||
<VChart class="chart" :option="ctx.heartbeatOption" autoresize />
|
||||
<div v-if="ctx.isHeartbeatChartEmpty" class="chart-empty">
|
||||
<strong>暂无服务端检测记录</strong>
|
||||
<span>点击“立即服务端检测”后会生成延迟曲线。</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel chart-panel"><h2>接口健康分布</h2><VChart class="chart" :option="ctx.healthOption" autoresize /></section>
|
||||
<section class="panel chart-panel"><h2>反馈状态分布</h2><VChart class="chart" :option="ctx.feedbackOption" autoresize /></section>
|
||||
<section class="panel chart-panel"><h2>服务可用率</h2><VChart class="chart" :option="ctx.availabilityOption" autoresize /></section>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-head"><h2>最近接口心跳</h2><span class="badge">{{ ctx.heartbeats.length }} 条</span></div>
|
||||
<div class="section-head"><h2>最近服务端检测</h2><span class="badge">{{ ctx.heartbeats.length }} 条</span></div>
|
||||
<table>
|
||||
<thead><tr><th>接口</th><th>状态</th><th>延迟</th><th>错误</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
@@ -66,7 +73,7 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
<td class="hash">{{ item.error || "-" }}</td>
|
||||
<td>{{ item.checkedAt || "-" }}</td>
|
||||
</tr>
|
||||
<tr v-if="ctx.heartbeats.length === 0"><td colspan="5">暂无心跳记录,点击“立即心跳检测”后会刷新。</td></tr>
|
||||
<tr v-if="ctx.heartbeats.length === 0"><td colspan="5">暂无检测记录,点击“立即服务端检测”后会刷新。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { Pencil, Trash2 } from "lucide-vue-next";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel page-stack">
|
||||
<div class="section-head"><h2>客户端动态接口</h2><span class="badge">{{ ctx.visibleEndpointCount }} 可见 / {{ ctx.healthyEndpointCount }} 健康</span></div>
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>客户端动态接口</h2>
|
||||
<p class="muted">删除接口后会由服务端重新生成兼容媒体源 JSON 和更新 JSON。</p>
|
||||
</div>
|
||||
<span class="badge">{{ ctx.visibleEndpointCount }} 可见 / {{ ctx.healthyEndpointCount }} 健康</span>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>分类</th><th>模式</th><th>健康</th><th>缓存</th><th>URL</th><th></th></tr></thead>
|
||||
<thead><tr><th>ID</th><th>分类</th><th>模式</th><th>健康</th><th>缓存</th><th>URL</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.endpoints" :key="item.id || item.sourceId">
|
||||
<td class="mono">{{ item.id || item.sourceId }}</td>
|
||||
@@ -17,8 +25,13 @@ defineProps<{ ctx: any }>();
|
||||
<span v-if="ctx.endpointStatus(item) === 'redirected' || item.health?.meta?.redirected" class="badge warn">重定向接口</span>
|
||||
</td>
|
||||
<td>{{ item.cacheSeconds || 0 }}s</td>
|
||||
<td class="hash">{{ item.urlTemplate || item.apiUrl }}</td>
|
||||
<td><button class="btn ghost compact" @click="ctx.copyEndpointToSource(item)">编辑</button></td>
|
||||
<td class="hash">{{ item.resolvedUrl || item.urlTemplate || item.apiUrl }}</td>
|
||||
<td>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost compact" @click="ctx.copyEndpointToSource(item)"><Pencil :size="14" />编辑</button>
|
||||
<button class="btn ghost compact danger" @click="ctx.deleteEndpoint(item)"><Trash2 :size="14" />删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="ctx.endpoints.length === 0"><td colspan="7">暂无客户端接口。</td></tr>
|
||||
</tbody>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Save, Search, UploadCloud } from "lucide-vue-next";
|
||||
import { Mail, Save, Search, UploadCloud } from "lucide-vue-next";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
@@ -8,44 +8,107 @@ defineProps<{ ctx: any }>();
|
||||
<section class="split">
|
||||
<section class="panel page-stack">
|
||||
<div class="toolbar">
|
||||
<label class="search-box"><Search :size="16" /><input v-model="ctx.feedbackFilters.q" placeholder="搜索编号、标题、联系方式" @keyup.enter="ctx.loadFeedbacks" /></label>
|
||||
<select v-model="ctx.feedbackFilters.status" @change="ctx.loadFeedbacks"><option value="">全部状态</option><option value="new">new</option><option value="processing">processing</option><option value="closed">closed</option></select>
|
||||
<label class="search-box">
|
||||
<Search :size="16" />
|
||||
<input v-model="ctx.feedbackFilters.q" placeholder="搜索编号、标题、联系方式" @keyup.enter="ctx.loadFeedbacks" />
|
||||
</label>
|
||||
<select v-model="ctx.feedbackFilters.status" @change="ctx.loadFeedbacks">
|
||||
<option value="">全部状态</option>
|
||||
<option value="new">新建</option>
|
||||
<option value="processing">处理中</option>
|
||||
<option value="closed">已关闭</option>
|
||||
</select>
|
||||
<select v-model="ctx.feedbackFilters.priority" @change="ctx.loadFeedbacks">
|
||||
<option value="">全部优先级</option>
|
||||
<option value="low">低</option>
|
||||
<option value="normal">普通</option>
|
||||
<option value="high">高</option>
|
||||
<option value="urgent">紧急</option>
|
||||
</select>
|
||||
<button class="btn ghost" @click="ctx.loadFeedbacks">查询</button>
|
||||
<a class="btn ghost" href="/api/admin/feedbacks/export" target="_blank"><UploadCloud :size="16" />CSV</a>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>编号</th><th>标题</th><th>状态</th><th>优先级</th><th>最近活动</th></tr></thead>
|
||||
<thead><tr><th>编号</th><th>标题</th><th>状态</th><th>优先级</th><th>邮件</th><th>最近活动</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.feedbackPage.items" :key="item.code" class="clickable" :class="{ selected: ctx.selectedFeedback?.code === item.code }" @click="ctx.openFeedback(item)">
|
||||
<td class="mono">{{ item.code }}</td>
|
||||
<td>{{ item.title || item.summaryText || "未命名反馈" }}</td>
|
||||
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ item.status }}</span></td>
|
||||
<td>{{ item.priority || "-" }}</td>
|
||||
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ ctx.labelStatus(item.status) }}</span></td>
|
||||
<td><span :class="['badge', ctx.statusTone(item.priority)]">{{ ctx.labelPriority(item.priority) }}</span></td>
|
||||
<td><span :class="['badge', item.mailSent ? 'good' : 'warn']">{{ item.mailSent ? "已发送" : "未发送" }}</span></td>
|
||||
<td>{{ item.lastActivityAt || item.createdAt }}</td>
|
||||
</tr>
|
||||
<tr v-if="!ctx.feedbackPage.items?.length"><td colspan="5">暂无反馈工单。</td></tr>
|
||||
<tr v-if="!ctx.feedbackPage.items?.length"><td colspan="6">暂无反馈工单。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<aside class="panel detail-panel">
|
||||
<template v-if="ctx.selectedFeedback">
|
||||
<h2>{{ ctx.selectedFeedback.code }}</h2>
|
||||
<div class="section-head">
|
||||
<h2>{{ ctx.selectedFeedback.code }}</h2>
|
||||
<span :class="['badge', ctx.selectedFeedback.mailSent ? 'good' : 'warn']">{{ ctx.selectedFeedback.mailSent ? "邮件已发送" : "邮件未发送" }}</span>
|
||||
</div>
|
||||
<p class="muted">{{ ctx.selectedFeedback.title || ctx.selectedFeedback.body }}</p>
|
||||
<label>状态<select v-model="ctx.feedbackUpdate.status"><option>new</option><option>processing</option><option>closed</option></select></label>
|
||||
<div class="kv-grid">
|
||||
<span>联系方式</span><strong>{{ ctx.selectedFeedback.contact || "-" }}</strong>
|
||||
<span>来源</span><strong>{{ ctx.selectedFeedback.sourceChannel || "-" }}</strong>
|
||||
<span>接收时间</span><strong>{{ ctx.selectedFeedback.createdAt || "-" }}</strong>
|
||||
</div>
|
||||
|
||||
<label>状态
|
||||
<select v-model="ctx.feedbackUpdate.status">
|
||||
<option value="new">新建</option>
|
||||
<option value="processing">处理中</option>
|
||||
<option value="closed">已关闭</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>优先级
|
||||
<select v-model="ctx.feedbackUpdate.priority">
|
||||
<option value="low">低</option>
|
||||
<option value="normal">普通</option>
|
||||
<option value="high">高</option>
|
||||
<option value="urgent">紧急</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>状态说明<input v-model="ctx.feedbackUpdate.statusDetail" /></label>
|
||||
<label>公开回复<textarea v-model="ctx.feedbackUpdate.publicReply" rows="3"></textarea></label>
|
||||
<button class="btn primary" @click="ctx.saveFeedbackUpdate"><Save :size="16" />保存状态</button>
|
||||
<div class="button-row">
|
||||
<button class="btn primary" @click="ctx.saveFeedbackUpdate"><Save :size="16" />保存工单</button>
|
||||
<button class="btn ghost" @click="ctx.retryFeedbackMail"><Mail :size="16" />重试邮件</button>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
<h3>评论</h3>
|
||||
<div class="comment-list">
|
||||
<div v-for="item in ctx.selectedFeedback.comments || []" :key="item.id" class="comment"><strong>{{ item.author }}</strong><p>{{ item.body }}</p></div>
|
||||
<div v-for="item in ctx.selectedFeedback.comments || []" :key="item.id" class="comment">
|
||||
<strong>{{ item.author }}</strong>
|
||||
<p>{{ item.body }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label>新增评论<textarea v-model="ctx.commentDraft.body" rows="3"></textarea></label>
|
||||
<label class="checkbox"><input v-model="ctx.commentDraft.internal" type="checkbox" />内部备注</label>
|
||||
<button class="btn ghost" @click="ctx.addFeedbackComment">添加评论</button>
|
||||
|
||||
<details>
|
||||
<summary>旧反馈事件 / 邮件记录</summary>
|
||||
<pre class="json-preview small">{{ ctx.pretty({ events: ctx.selectedFeedback.legacyEvents, mail: ctx.selectedFeedback.mailRecords }) }}</pre>
|
||||
<summary>邮件记录</summary>
|
||||
<table>
|
||||
<thead><tr><th>状态</th><th>收件人</th><th>主题</th><th>错误</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.selectedFeedback.mailRecords || []" :key="item.id">
|
||||
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ ctx.labelStatus(item.status) }}</span></td>
|
||||
<td>{{ item.toAddress || "-" }}</td>
|
||||
<td>{{ item.subject || "-" }}</td>
|
||||
<td>{{ item.errorMessage || "-" }}</td>
|
||||
</tr>
|
||||
<tr v-if="!(ctx.selectedFeedback.mailRecords || []).length"><td colspan="4">暂无邮件记录。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
<details>
|
||||
<summary>旧反馈事件</summary>
|
||||
<pre class="json-preview small">{{ ctx.pretty({ events: ctx.selectedFeedback.legacyEvents }) }}</pre>
|
||||
</details>
|
||||
</template>
|
||||
<div v-else class="empty-state">选择一条工单查看详情。</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircle2, Plus, Save, Trash2 } from "lucide-vue-next";
|
||||
import { CheckCircle2, Pencil, Plus, Save, Trash2 } from "lucide-vue-next";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
@@ -9,7 +9,7 @@ defineProps<{ ctx: any }>();
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>{{ ctx.activeLegacyLabel }}</h2>
|
||||
<p class="muted">以当前兼容 JSON 为基板,表单保存会合并进原 JSON,未知字段保留。</p>
|
||||
<p class="muted">可视化表单只维护常用字段,保存时会合并回当前 JSON,未识别字段继续保留。</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost" @click="ctx.validateLegacy(ctx.activeLegacyName)"><CheckCircle2 :size="16" />校验</button>
|
||||
@@ -18,73 +18,126 @@ defineProps<{ ctx: any }>();
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'form'">可视化表单</button>
|
||||
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'form'">可视化</button>
|
||||
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'raw' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'raw'">Raw JSON</button>
|
||||
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'preview' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'preview'">预览</button>
|
||||
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'history' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'history'">历史版本</button>
|
||||
</div>
|
||||
|
||||
<p v-if="ctx.activeLegacyName === 'media-types'" class="notice">
|
||||
生产环境不再自动依赖旧项目路径。需要以 server/update/public/media-types.json 为基板时,请切换到 Raw JSON 粘贴完整内容,校验通过后保存发布。
|
||||
</p>
|
||||
<section v-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form' && ctx.activeLegacyName === 'update-info'" class="page-stack">
|
||||
<section class="form-grid">
|
||||
<label>版本号<input v-model="ctx.legacyDrafts['update-info'].form.app_version" /></label>
|
||||
<label>标题<input v-model="ctx.legacyDrafts['update-info'].form.title" /></label>
|
||||
<label class="wide">公告<textarea v-model="ctx.legacyDrafts['update-info'].form.message" rows="3"></textarea></label>
|
||||
<label class="wide">下载地址<input v-model="ctx.legacyDrafts['update-info'].form.download_url" /></label>
|
||||
<label>包 SHA256<input v-model="ctx.legacyDrafts['update-info'].form.package_sha256" /></label>
|
||||
<label>包大小<input v-model="ctx.legacyDrafts['update-info'].form.package_size" type="number" /></label>
|
||||
<label class="wide">发布说明<textarea v-model="ctx.legacyDrafts['update-info'].form.release_notes" rows="4"></textarea></label>
|
||||
<label class="wide">发布说明 Markdown<textarea v-model="ctx.legacyDrafts['update-info'].form.release_notes_md" rows="4"></textarea></label>
|
||||
</section>
|
||||
|
||||
<section v-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form' && ctx.activeLegacyName === 'update-info'" class="form-grid">
|
||||
<label>版本号<input v-model="ctx.legacyDrafts['update-info'].form.app_version" /></label>
|
||||
<label>标题<input v-model="ctx.legacyDrafts['update-info'].form.title" /></label>
|
||||
<label class="wide">公告<textarea v-model="ctx.legacyDrafts['update-info'].form.message" rows="3"></textarea></label>
|
||||
<label class="wide">下载地址<input v-model="ctx.legacyDrafts['update-info'].form.download_url" /></label>
|
||||
<label>包 SHA256<input v-model="ctx.legacyDrafts['update-info'].form.package_sha256" /></label>
|
||||
<label>包大小<input v-model="ctx.legacyDrafts['update-info'].form.package_size" type="number" /></label>
|
||||
<label class="wide">发布说明<textarea v-model="ctx.legacyDrafts['update-info'].form.release_notes" rows="4"></textarea></label>
|
||||
<label class="wide">发布说明 Markdown<textarea v-model="ctx.legacyDrafts['update-info'].form.release_notes_md" rows="5"></textarea></label>
|
||||
<label class="wide">更新说明 JSON<textarea v-model="ctx.legacyDrafts['update-info'].form.update_notes" class="code-editor mini-editor"></textarea></label>
|
||||
<label class="wide">上次更新说明 JSON<textarea v-model="ctx.legacyDrafts['update-info'].form.last_update_notes" class="code-editor mini-editor"></textarea></label>
|
||||
<div class="wide button-row">
|
||||
<button class="btn ghost" @click="ctx.addUpdateMirror"><Plus :size="16" />新增镜像字段到底稿</button>
|
||||
<button class="btn" @click="ctx.updateLegacyRawFromForm('update-info')">生成预览 JSON</button>
|
||||
</div>
|
||||
<section class="nested-card">
|
||||
<div class="section-head">
|
||||
<h3>下载镜像</h3>
|
||||
<button class="btn ghost compact" @click="ctx.openUpdateMirrorModal()"><Plus :size="14" />新增镜像</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>名称</th><th>类型</th><th>状态</th><th>URL</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="(mirror, index) in ctx.legacyDrafts['update-info'].form.download_mirrors || []" :key="mirror.id || index">
|
||||
<td class="mono">{{ mirror.id }}</td>
|
||||
<td>{{ mirror.name }}</td>
|
||||
<td>{{ mirror.type || "direct" }}</td>
|
||||
<td><span :class="['badge', mirror.enabled === false ? 'neutral' : 'good']">{{ mirror.enabled === false ? "停用" : "启用" }}</span></td>
|
||||
<td class="hash">{{ mirror.url }}</td>
|
||||
<td>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost compact" @click="ctx.openUpdateMirrorModal(index)"><Pencil :size="14" />编辑</button>
|
||||
<button class="btn ghost compact danger" @click="ctx.removeItem(ctx.legacyDrafts['update-info'].form.download_mirrors, index)"><Trash2 :size="14" />删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!(ctx.legacyDrafts['update-info'].form.download_mirrors || []).length"><td colspan="6">暂无镜像。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<details>
|
||||
<summary>高级 JSON 字段</summary>
|
||||
<div class="form-grid">
|
||||
<label class="wide">更新说明 JSON<textarea v-model="ctx.legacyDrafts['update-info'].form.update_notes" class="code-editor mini-editor"></textarea></label>
|
||||
<label class="wide">上次更新说明 JSON<textarea v-model="ctx.legacyDrafts['update-info'].form.last_update_notes" class="code-editor mini-editor"></textarea></label>
|
||||
</div>
|
||||
</details>
|
||||
<div class="button-row"><button class="btn" @click="ctx.updateLegacyRawFromForm('update-info')">生成预览 JSON</button></div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form'" class="page-stack">
|
||||
<div class="form-grid">
|
||||
<label>布局版本<input v-model="ctx.legacyDrafts['media-types'].form.layout_version" /></label>
|
||||
<label>更新时间<input v-model="ctx.legacyDrafts['media-types'].form.last_updated" /></label>
|
||||
<label class="wide">UI 配置 JSON<textarea v-model="ctx.legacyDrafts['media-types'].form.ui_config" class="code-editor mini-editor"></textarea></label>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost" @click="ctx.addMediaCategory('media-types')"><Plus :size="16" />新增分类</button>
|
||||
<button class="btn" @click="ctx.updateLegacyRawFromForm('media-types')">生成预览 JSON</button>
|
||||
</div>
|
||||
<section v-for="(cat, cIndex) in ctx.legacyDrafts['media-types'].form.categories" :key="cIndex" class="nested-card">
|
||||
<div class="section-head">
|
||||
<h3>分类 {{ cIndex + 1 }}</h3>
|
||||
<button class="btn ghost compact" @click="ctx.removeItem(ctx.legacyDrafts['media-types'].form.categories, cIndex)"><Trash2 :size="14" />删除</button>
|
||||
</div>
|
||||
<section v-else-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form'" class="split legacy-media-editor">
|
||||
<section class="panel-soft page-stack">
|
||||
<div class="form-grid">
|
||||
<label>ID<input v-model="cat.id" /></label>
|
||||
<label>名称<input v-model="cat.name" /></label>
|
||||
<label class="checkbox"><input v-model="cat.enabled" type="checkbox" />启用分类</label>
|
||||
<label>布局版本<input v-model="ctx.legacyDrafts['media-types'].form.layout_version" /></label>
|
||||
<label>更新时间<input v-model="ctx.legacyDrafts['media-types'].form.last_updated" /></label>
|
||||
<label class="wide">UI 配置 JSON<textarea v-model="ctx.legacyDrafts['media-types'].form.ui_config" class="code-editor mini-editor"></textarea></label>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost compact" @click="ctx.addMediaSubcategory(cat)"><Plus :size="14" />新增子接口</button>
|
||||
<div class="section-head">
|
||||
<h3>分类</h3>
|
||||
<button class="btn ghost compact" @click="ctx.openMediaCategoryModal()"><Plus :size="14" />新增分类</button>
|
||||
</div>
|
||||
<section v-for="(sub, sIndex) in cat.subcategories" :key="sIndex" class="nested-card inner">
|
||||
<div class="section-head">
|
||||
<h3>{{ sub.name || "子接口" }}</h3>
|
||||
<button class="btn ghost compact" @click="ctx.removeItem(cat.subcategories, sIndex)"><Trash2 :size="14" />删除</button>
|
||||
<div class="category-list" v-if="ctx.legacyDrafts['media-types'].form.categories.length">
|
||||
<button
|
||||
v-for="(cat, cIndex) in ctx.legacyDrafts['media-types'].form.categories"
|
||||
:key="cat.id || cIndex"
|
||||
type="button"
|
||||
:class="{ active: ctx.activeMediaCategoryIndex === cIndex }"
|
||||
@click="ctx.selectMediaCategory(cIndex)"
|
||||
>
|
||||
<span><strong>{{ cat.name || cat.id || `分类 ${cIndex + 1}` }}</strong><small class="mono">{{ cat.id || "-" }}</small></span>
|
||||
<span class="badge">{{ cat.subcategories?.length || 0 }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="empty-state compact">暂无分类。</div>
|
||||
</section>
|
||||
|
||||
<aside class="panel-soft page-stack media-subcategory-panel">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h3>{{ ctx.activeMediaCategory?.name || ctx.activeMediaCategory?.id || "子接口" }}</h3>
|
||||
<p class="muted">右侧仅显示当前选中分类下的子接口。</p>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label>ID<input v-model="sub.id" /></label>
|
||||
<label>名称<input v-model="sub.name" /></label>
|
||||
<label class="wide">接口 URL<input v-model="sub.api_url" /></label>
|
||||
<label>缩略图<input v-model="sub.thumbnail_url" /></label>
|
||||
<label>刷新间隔<input v-model.number="sub.refresh_interval" type="number" /></label>
|
||||
<label>格式<input v-model="sub.supported_formats" placeholder="json, xml" /></label>
|
||||
<label class="checkbox"><input v-model="sub.downloadable" type="checkbox" />可下载</label>
|
||||
<label class="wide">描述<textarea v-model="sub.description" rows="2"></textarea></label>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost compact" :disabled="!ctx.activeMediaCategory" @click="ctx.openMediaCategoryModal(ctx.activeMediaCategoryIndex)"><Pencil :size="14" />编辑分类</button>
|
||||
<button class="btn ghost compact" :disabled="!ctx.activeMediaCategory" @click="ctx.openMediaSubcategoryModal(ctx.activeMediaCategoryIndex)"><Plus :size="14" />新增子接口</button>
|
||||
<button class="btn" @click="ctx.updateLegacyRawFromForm('media-types')">生成预览 JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
<section v-if="ctx.activeMediaCategory" class="source-group">
|
||||
<div class="button-row">
|
||||
<span :class="['badge', ctx.activeMediaCategory.enabled === false ? 'neutral' : 'good']">{{ ctx.activeMediaCategory.enabled === false ? "停用" : "启用" }}</span>
|
||||
<button class="btn ghost compact danger" @click="ctx.removeItem(ctx.legacyDrafts['media-types'].form.categories, ctx.activeMediaCategoryIndex)"><Trash2 :size="14" />删除分类</button>
|
||||
</div>
|
||||
<div class="table-scroll media-subcategory-table">
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>名称</th><th>刷新</th><th>URL</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="(sub, sIndex) in ctx.activeMediaCategory.subcategories || []" :key="sub.id || sIndex">
|
||||
<td class="mono">{{ sub.id }}</td>
|
||||
<td>{{ sub.name }}</td>
|
||||
<td>{{ sub.refresh_interval || 300 }}s</td>
|
||||
<td class="hash url-cell" :title="sub.api_url">{{ sub.api_url }}</td>
|
||||
<td>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost compact" @click="ctx.openMediaSubcategoryModal(ctx.activeMediaCategoryIndex, sIndex)"><Pencil :size="14" />编辑</button>
|
||||
<button class="btn ghost compact danger" @click="ctx.removeItem(ctx.activeMediaCategory.subcategories, sIndex)"><Trash2 :size="14" />删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!(ctx.activeMediaCategory.subcategories || []).length"><td colspan="5">当前分类暂无子接口。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
<div v-else class="empty-state compact">请选择或新增一个分类。</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section v-else-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'raw'" class="page-stack">
|
||||
@@ -102,5 +155,46 @@ defineProps<{ ctx: any }>();
|
||||
</button>
|
||||
<div v-if="(ctx.legacyDocuments[ctx.activeLegacyName]?.revisions || []).length === 0" class="empty-state compact">暂无历史版本。</div>
|
||||
</section>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="ctx.legacyModal.open" class="modal-backdrop" @click.self="ctx.closeLegacyModal">
|
||||
<section class="modal-panel">
|
||||
<div class="section-head">
|
||||
<h2>{{ ctx.legacyModal.type === 'media-category' ? '分类' : ctx.legacyModal.type === 'media-subcategory' ? '子接口' : '下载镜像' }}</h2>
|
||||
<button class="btn ghost compact" @click="ctx.closeLegacyModal">关闭</button>
|
||||
</div>
|
||||
|
||||
<div v-if="ctx.legacyModal.type === 'media-category'" class="form-grid">
|
||||
<label>ID<input v-model="ctx.legacyModal.draft.id" /></label>
|
||||
<label>名称<input v-model="ctx.legacyModal.draft.name" /></label>
|
||||
<label class="checkbox wide"><input v-model="ctx.legacyModal.draft.enabled" type="checkbox" />启用分类</label>
|
||||
</div>
|
||||
|
||||
<div v-else-if="ctx.legacyModal.type === 'media-subcategory'" class="form-grid">
|
||||
<label>ID<input v-model="ctx.legacyModal.draft.id" /></label>
|
||||
<label>名称<input v-model="ctx.legacyModal.draft.name" /></label>
|
||||
<label class="wide">接口 URL<input v-model="ctx.legacyModal.draft.api_url" /></label>
|
||||
<label>缩略图<input v-model="ctx.legacyModal.draft.thumbnail_url" /></label>
|
||||
<label>刷新间隔<input v-model.number="ctx.legacyModal.draft.refresh_interval" type="number" /></label>
|
||||
<label>格式<input v-model="ctx.legacyModal.draft.supported_formats" placeholder="json, mp4, webp" /></label>
|
||||
<label class="checkbox"><input v-model="ctx.legacyModal.draft.downloadable" type="checkbox" />可下载</label>
|
||||
<label class="wide">描述<textarea v-model="ctx.legacyModal.draft.description" rows="2"></textarea></label>
|
||||
</div>
|
||||
|
||||
<div v-else class="form-grid">
|
||||
<label>ID<input v-model="ctx.legacyModal.draft.id" /></label>
|
||||
<label>名称<input v-model="ctx.legacyModal.draft.name" /></label>
|
||||
<label>类型<input v-model="ctx.legacyModal.draft.type" /></label>
|
||||
<label class="wide">URL<input v-model="ctx.legacyModal.draft.url" /></label>
|
||||
<label>SHA256<input v-model="ctx.legacyModal.draft.sha256" /></label>
|
||||
<label class="checkbox"><input v-model="ctx.legacyModal.draft.enabled" type="checkbox" />启用</label>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="btn primary" @click="ctx.applyLegacyModal">保存</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -56,13 +56,18 @@ defineProps<{ ctx: any }>();
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<aside class="panel editor-panel">
|
||||
<div class="section-head"><h2>版本日志</h2><button class="btn ghost" @click="ctx.importNotices">导入目录</button></div>
|
||||
<aside class="panel editor-panel compact-side">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>版本日志</h2>
|
||||
<p class="muted">以 update-info.json 模板为基础动态生成更新信息。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="revision-list">
|
||||
<button v-for="item in ctx.releaseNotices" :key="item.version" :class="{ active: ctx.noticeDraft.version === item.version }" @click="ctx.openNotice(item.version)">
|
||||
<strong>{{ item.version }}</strong><small>{{ item.title || item.updatedAt }}</small>
|
||||
</button>
|
||||
<div v-if="ctx.releaseNotices.length === 0" class="empty-state compact">暂无版本日志,可先执行导入。</div>
|
||||
<div v-if="ctx.releaseNotices.length === 0" class="empty-state compact">暂无版本日志,可直接填写版本和 Raw JSON 后保存。</div>
|
||||
</div>
|
||||
<label>版本<input v-model="ctx.noticeDraft.version" placeholder="1.0.0" /></label>
|
||||
<label>Raw JSON<textarea v-model="ctx.noticeDraft.raw" class="code-editor compact-editor"></textarea></label>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { Activity, AlertTriangle, ArrowDownUp, Clock3, Database, KeyRound, ListChecks, RefreshCw, ShieldCheck } from "lucide-vue-next";
|
||||
import { Activity, AlertTriangle, ArrowDownUp, Clock3, Database, HardDrive, KeyRound, ListChecks, Mail, RefreshCw, Save, ShieldCheck, UserRound } from "lucide-vue-next";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
|
||||
const tabs = [
|
||||
{ id: "database", label: "数据库", icon: Database },
|
||||
{ id: "migration", label: "迁移状态", icon: HardDrive },
|
||||
{ id: "sync", label: "旧项目同步", icon: RefreshCw },
|
||||
{ id: "security", label: "安全设置", icon: ShieldCheck },
|
||||
{ id: "security", label: "安全与邮件", icon: ShieldCheck },
|
||||
{ id: "health", label: "健康快照", icon: Activity },
|
||||
{ id: "audit", label: "审计日志", icon: ListChecks },
|
||||
];
|
||||
@@ -15,13 +16,7 @@ const tabs = [
|
||||
<template>
|
||||
<section class="page-stack">
|
||||
<nav class="tabs" aria-label="系统运维标签">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
:class="{ active: ctx.systemTab === tab.id }"
|
||||
@click="ctx.setSystemTab(tab.id)"
|
||||
>
|
||||
<button v-for="tab in tabs" :key="tab.id" type="button" :class="{ active: ctx.systemTab === tab.id }" @click="ctx.setSystemTab(tab.id)">
|
||||
<component :is="tab.icon" :size="15" />
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
@@ -43,35 +38,86 @@ const tabs = [
|
||||
</div>
|
||||
|
||||
<div class="sync-summary">
|
||||
<div>
|
||||
<span><ArrowDownUp :size="15" />最近同步方向</span>
|
||||
<strong>{{ ctx.databaseSyncDirectionLabel(ctx.databaseLastSync?.direction) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span><ListChecks :size="15" />影响记录</span>
|
||||
<strong>{{ ctx.databaseSyncTableCount(ctx.databaseLastSync) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span><Clock3 :size="15" />完成时间</span>
|
||||
<strong>{{ ctx.databaseLastSync?.finishedAt || ctx.database?.lastSyncAt || "-" }}</strong>
|
||||
</div>
|
||||
<div><span><ArrowDownUp :size="15" />最近方向</span><strong>{{ ctx.databaseSyncDirectionLabel(ctx.databaseLastSync?.direction) }}</strong></div>
|
||||
<div><span><ListChecks :size="15" />最近状态</span><strong>{{ ctx.databaseSyncStatusLabel(ctx.databaseLastSync?.status) }}</strong></div>
|
||||
<div><span><Clock3 :size="15" />影响记录</span><strong>{{ ctx.databaseSyncTableCount(ctx.databaseLastSync) }}</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="ops-note">
|
||||
<div v-if="ctx.databaseLastSync?.warnings?.length" class="ops-note">
|
||||
<AlertTriangle :size="16" />
|
||||
<span>数据库同步是覆盖式全表 upsert。执行前确认方向:SQLite 导入远端会以本地库为源,远端同步回本地会以 MySQL 为源。</span>
|
||||
<span>{{ ctx.databaseLastSync.warnings.join(";") }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="panel editor-panel">
|
||||
<h2>连接与同步</h2>
|
||||
<label>Provider<select v-model="ctx.databaseForm.provider"><option>sqlite</option><option>mysql</option></select></label>
|
||||
<label>SQLite 路径<input v-model="ctx.databaseForm.sqlitePath" placeholder="留空使用当前配置" /></label>
|
||||
<label>MySQL DSN<input v-model="ctx.databaseForm.mysqlDsn" placeholder="user:pass@tcp(host:3306)/db?parseTime=true" /></label>
|
||||
<div class="section-head">
|
||||
<h2>连接与同步</h2>
|
||||
<div class="button-row compact-row">
|
||||
<span v-if="ctx.databaseFormEditing" class="badge warn">编辑中,自动刷新不会覆盖表单</span>
|
||||
<button v-if="ctx.databaseFormEditing" class="btn ghost compact" type="button" @click="ctx.reloadDatabaseConfig">重新读取配置</button>
|
||||
<button v-if="ctx.databaseConfigCollapsed" class="btn ghost compact" type="button" @click="ctx.editDatabaseConfig">修改配置</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="ctx.databaseConfigCollapsed" class="kv-grid">
|
||||
<span>当前配置</span><strong>{{ ctx.databaseConfigSummary() }}</strong>
|
||||
<span>密码状态</span><strong>{{ ctx.databaseConfig?.hasPassword ? "已保存,前端不回显" : "未保存" }}</strong>
|
||||
</div>
|
||||
|
||||
<div v-else class="form-stack" @input="ctx.markDatabaseFormEditing" @change="ctx.markDatabaseFormEditing">
|
||||
<label>Provider
|
||||
<select v-model="ctx.databaseForm.provider">
|
||||
<option value="sqlite">SQLite</option>
|
||||
<option value="mysql">MySQL</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="ctx.databaseForm.provider === 'sqlite'">SQLite 路径
|
||||
<input v-model="ctx.databaseForm.sqlitePath" placeholder="留空使用当前配置" />
|
||||
</label>
|
||||
<div v-else class="form-grid">
|
||||
<label>主机<input v-model="ctx.databaseForm.mysqlHost" placeholder="127.0.0.1" /></label>
|
||||
<label>端口<input v-model.number="ctx.databaseForm.mysqlPort" type="number" min="1" placeholder="3306" /></label>
|
||||
<label>数据库名<input v-model="ctx.databaseForm.mysqlDatabase" placeholder="ymhut_unified" /></label>
|
||||
<label>数据库用户<input v-model="ctx.databaseForm.mysqlUser" autocomplete="username" /></label>
|
||||
<label class="wide">数据库密码
|
||||
<input v-model="ctx.databaseForm.mysqlPassword" type="password" autocomplete="new-password" :placeholder="ctx.databaseConfig?.hasPassword ? '留空沿用已保存密码' : '请输入数据库密码'" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="btn ghost" @click="ctx.testDatabase">测试连接</button>
|
||||
<button class="btn primary" @click="ctx.syncDatabase('import')">SQLite 导入远端</button>
|
||||
<button class="btn ghost" @click="ctx.syncDatabase('sync')">远端同步回本地</button>
|
||||
<button class="btn primary" @click="ctx.saveDatabase"><Save :size="16" />测试并保存</button>
|
||||
<button class="btn ghost" @click="ctx.syncDatabase('import')">SQLite -> MySQL</button>
|
||||
<button class="btn ghost" @click="ctx.syncDatabase('sync')">MySQL -> SQLite</button>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section v-else-if="ctx.systemTab === 'migration'" class="split">
|
||||
<section class="panel page-stack">
|
||||
<div class="section-head"><h2>数据库优先迁移</h2><button class="btn ghost" @click="ctx.loadMigrationStatus"><RefreshCw :size="16" />刷新</button></div>
|
||||
<div class="kv-grid">
|
||||
<span>策略</span><strong>{{ ctx.migrationStatus?.strategy || "-" }}</strong>
|
||||
<span>SQLite 文件</span><strong>{{ ctx.migrationStatus?.sqlitePath || "-" }}</strong>
|
||||
<span>活动数据库</span><strong>{{ ctx.migrationStatus?.activeProvider || "-" }}</strong>
|
||||
<span>最后同步</span><strong>{{ ctx.migrationStatus?.lastSyncAt || "-" }}</strong>
|
||||
<span>同步错误</span><strong>{{ ctx.migrationStatus?.lastSyncError || "-" }}</strong>
|
||||
</div>
|
||||
<div class="ops-note">
|
||||
<AlertTriangle :size="16" />
|
||||
<span>数据库保存站点结构化状态;发布包、下载文件和反馈附件仍属于文件资产,迁移时需要连同数据库一起备份。</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="panel page-stack">
|
||||
<h2>数据库覆盖范围</h2>
|
||||
<ul class="plain-list">
|
||||
<li v-for="item in ctx.migrationStatus?.databaseCovers || []" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
<h2>文件资产目录</h2>
|
||||
<div v-for="asset in ctx.migrationStatus?.fileAssets || []" :key="asset.name" class="asset-row">
|
||||
<strong>{{ asset.name }}</strong>
|
||||
<span>{{ asset.description }}</span>
|
||||
<code>{{ asset.path }}</code>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
@@ -80,37 +126,23 @@ const tabs = [
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>旧项目同步</h2>
|
||||
<p class="muted">预览只检查旧目录和影响范围;执行会先备份当前发布目录,再复制旧项目数据并导入反馈记录。</p>
|
||||
<p class="muted">预览只检查旧目录和影响范围;执行前会备份当前兼容输出,再复制旧项目数据并导入记录。</p>
|
||||
</div>
|
||||
<button class="btn ghost" @click="ctx.previewLegacySync"><RefreshCw :size="16" />刷新预览</button>
|
||||
</div>
|
||||
|
||||
<div class="sync-summary">
|
||||
<div>
|
||||
<span>当前模式</span>
|
||||
<strong>{{ ctx.legacySyncMode === "run" || ctx.legacySync?.dryRun === false ? "执行结果" : "Dry-run 预览" }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>状态</span>
|
||||
<strong>{{ ctx.legacySync?.ok === false ? "存在错误" : "可继续" }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>完成时间</span>
|
||||
<strong>{{ ctx.legacySync?.finishedAt || "-" }}</strong>
|
||||
</div>
|
||||
<div><span>当前模式</span><strong>{{ ctx.legacySyncMode === "run" || ctx.legacySync?.dryRun === false ? "执行结果" : "Dry-run 预览" }}</strong></div>
|
||||
<div><span>状态</span><strong>{{ ctx.legacySync?.ok === false ? "存在错误" : "可继续" }}</strong></div>
|
||||
<div><span>完成时间</span><strong>{{ ctx.legacySync?.finishedAt || "-" }}</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="kv-grid">
|
||||
<span>复制文件</span><strong>{{ ctx.legacySync?.stats?.copiedFiles || 0 }}</strong>
|
||||
<span>复制目录</span><strong>{{ ctx.legacySync?.stats?.copiedDirectories || 0 }}</strong>
|
||||
<span>导入记录</span><strong>{{ ctx.legacySync?.stats?.importedRows || 0 }}</strong>
|
||||
<span>缺失路径</span><strong>{{ ctx.legacySync?.stats?.missingPaths || 0 }}</strong>
|
||||
</div>
|
||||
|
||||
<pre class="json-preview tall">{{ ctx.pretty(ctx.legacySync) }}</pre>
|
||||
<div class="button-row">
|
||||
<button class="btn primary" @click="ctx.runLegacySync">执行旧项目同步</button>
|
||||
</div>
|
||||
<div class="button-row"><button class="btn primary" @click="ctx.runLegacySync">执行旧项目同步</button></div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="ctx.systemTab === 'security'" class="split">
|
||||
@@ -121,15 +153,52 @@ const tabs = [
|
||||
<button class="btn primary" @click="ctx.changePassword"><KeyRound :size="16" />保存密码</button>
|
||||
</section>
|
||||
|
||||
<section class="panel page-stack">
|
||||
<div class="section-head"><h2>当前安全策略</h2><span class="badge good">已启用</span></div>
|
||||
<div class="kv-grid">
|
||||
<span>登录保护</span><strong>验证码 + 连续失败限流</strong>
|
||||
<span>写操作保护</span><strong>HttpOnly Session + CSRF Token</strong>
|
||||
<span>Cookie</span><strong>HTTPS 或 X-Forwarded-Proto=https 时自动 Secure</strong>
|
||||
<span>会话范围</span><strong>后台 API 与 SSE 事件流均要求登录</strong>
|
||||
<span>密码规则</span><strong>至少 8 位,不能为 admin,不能与当前密码相同</strong>
|
||||
<span>兼容哈希</span><strong>保留 SHA-256 登录兼容,后续可平滑迁移到更强算法</strong>
|
||||
<section class="panel editor-panel">
|
||||
<div class="section-head">
|
||||
<h2>站点品牌</h2>
|
||||
<span class="badge neutral">{{ ctx.branding.developerName || "YMhut" }}</span>
|
||||
</div>
|
||||
<div class="brand-preview">
|
||||
<img :src="ctx.branding.siteIconUrl" alt="站点图标" />
|
||||
<img :src="ctx.branding.developerAvatarUrl" alt="开发者头像" />
|
||||
<strong>{{ ctx.branding.developerName }}</strong>
|
||||
</div>
|
||||
<label>站点图标 URL<input v-model="ctx.branding.siteIconUrl" /></label>
|
||||
<label>开发者头像 URL<input v-model="ctx.branding.developerAvatarUrl" /></label>
|
||||
<label>开发者名称<input v-model="ctx.branding.developerName" /></label>
|
||||
<label>反馈邮箱<input v-model="ctx.branding.feedbackEmail" /></label>
|
||||
<button class="btn primary" @click="ctx.saveBranding"><UserRound :size="16" />保存品牌</button>
|
||||
</section>
|
||||
|
||||
<section class="panel editor-panel">
|
||||
<div class="section-head">
|
||||
<h2>反馈邮件通知</h2>
|
||||
<div class="button-row compact-row">
|
||||
<span v-if="ctx.mailConfigEditing" class="badge warn">编辑中,自动刷新不会覆盖表单</span>
|
||||
<button v-if="ctx.mailConfigEditing" class="btn ghost compact" type="button" @click="ctx.reloadMailConfig">重新读取配置</button>
|
||||
<span :class="['badge', ctx.mailConfig.configured ? 'good' : 'warn']">{{ ctx.mailConfig.configured ? "已配置" : "未完成" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid" @input="ctx.markMailConfigEditing" @change="ctx.markMailConfigEditing">
|
||||
<label>SMTP 主机<input v-model="ctx.mailConfig.host" placeholder="smtp.example.com" /></label>
|
||||
<label>端口<input v-model.number="ctx.mailConfig.port" type="number" min="1" /></label>
|
||||
<label>加密方式
|
||||
<select v-model="ctx.mailConfig.secure">
|
||||
<option value="ssl">SSL/TLS</option>
|
||||
<option value="starttls">STARTTLS</option>
|
||||
<option value="none">不加密</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>账号<input v-model="ctx.mailConfig.username" autocomplete="username" /></label>
|
||||
<label>密码<input v-model="ctx.mailConfig.password" type="password" autocomplete="new-password" :placeholder="ctx.mailConfig.hasPassword ? '留空沿用已保存密码' : '请输入 SMTP 密码'" /></label>
|
||||
<label>超时秒数<input v-model.number="ctx.mailConfig.timeoutSeconds" type="number" min="3" /></label>
|
||||
<label>发件地址<input v-model="ctx.mailConfig.fromAddress" /></label>
|
||||
<label>发件名称<input v-model="ctx.mailConfig.fromName" /></label>
|
||||
<label class="wide">开发者收件地址<input v-model="ctx.mailConfig.developerAddress" :placeholder="ctx.branding.feedbackEmail" /></label>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="btn primary" @click="ctx.saveMailConfig"><Mail :size="16" />保存邮件配置</button>
|
||||
<button class="btn ghost" @click="ctx.testMail">发送测试邮件</button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
@@ -140,20 +209,46 @@ const tabs = [
|
||||
</section>
|
||||
|
||||
<section v-else class="panel page-stack">
|
||||
<div class="section-head"><h2>审计日志</h2><button class="btn ghost" @click="ctx.loadAudit">刷新</button></div>
|
||||
<div class="section-head">
|
||||
<h2>审计日志</h2>
|
||||
<button class="btn ghost" @click="ctx.loadAudit">刷新</button>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<input v-model="ctx.auditPage.q" placeholder="搜索操作、目标、信息或 IP" @keyup.enter="ctx.loadAudit" />
|
||||
<input v-model="ctx.auditPage.type" placeholder="类型,例如 source.deleted" @keyup.enter="ctx.loadAudit" />
|
||||
<input v-model="ctx.auditPage.target" placeholder="目标" @keyup.enter="ctx.loadAudit" />
|
||||
<button class="btn ghost" @click="ctx.auditPage.page = 1; ctx.loadAudit()">筛选</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>类型</th><th>目标</th><th>信息</th><th>IP</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.auditLogs" :key="item.id">
|
||||
<tr v-for="item in ctx.auditPage.items" :key="item.id" class="clickable" @click="ctx.selectAuditLog(item)">
|
||||
<td><span class="badge neutral">{{ ctx.auditTypeLabel(item.type) }}</span></td>
|
||||
<td>{{ item.target }}</td>
|
||||
<td>{{ ctx.auditMessage(item) }}</td>
|
||||
<td>{{ item.ip || "-" }}</td>
|
||||
<td>{{ item.createdAt }}</td>
|
||||
</tr>
|
||||
<tr v-if="ctx.auditLogs.length === 0"><td colspan="5">暂无审计日志。</td></tr>
|
||||
<tr v-if="ctx.auditPage.items.length === 0"><td colspan="5">暂无审计日志。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pager">
|
||||
<button class="btn ghost compact" :disabled="ctx.auditPage.page <= 1" @click="ctx.setAuditPage(ctx.auditPage.page - 1)">上一页</button>
|
||||
<span>第 {{ ctx.auditPage.page }} 页 / 共 {{ Math.max(1, Math.ceil(ctx.auditPage.total / ctx.auditPage.perPage)) }} 页,{{ ctx.auditPage.total }} 条</span>
|
||||
<button class="btn ghost compact" :disabled="ctx.auditPage.page >= Math.ceil(ctx.auditPage.total / ctx.auditPage.perPage)" @click="ctx.setAuditPage(ctx.auditPage.page + 1)">下一页</button>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="ctx.auditPage.selected" class="modal-backdrop" @click.self="ctx.auditPage.selected = null">
|
||||
<section class="modal-panel">
|
||||
<div class="section-head">
|
||||
<h2>审计详情</h2>
|
||||
<button class="btn ghost compact" @click="ctx.auditPage.selected = null">关闭</button>
|
||||
</div>
|
||||
<pre class="json-preview tall">{{ ctx.pretty(ctx.auditPage.selected) }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user