更新后端服务UI
客户端重构部分界面UI
This commit is contained in:
@@ -20,6 +20,7 @@ import LegacyJsonView from "./views/LegacyJsonView.vue";
|
||||
import ReleasesView from "./views/ReleasesView.vue";
|
||||
import SourcesView from "./views/SourcesView.vue";
|
||||
import SystemView from "./views/SystemView.vue";
|
||||
import AuditLogView from "./views/AuditLogView.vue";
|
||||
import { adminFetch, toChineseError, uploadAdminFile } from "./api/admin";
|
||||
import { createAuthStore } from "./stores/auth";
|
||||
import { createDashboardStore } from "./stores/dashboard";
|
||||
@@ -78,7 +79,7 @@ const systemStore = createSystemStore();
|
||||
|
||||
const { csrf, captcha, bootstrap: authBootstrap, loginForm, passwordForm } = authStore;
|
||||
const { dashboard, sourceCheckJobs } = dashboardStore;
|
||||
const { page: feedbackPage, selected: selectedFeedback, filters: feedbackFilters, update: feedbackUpdate, commentDraft } = feedbackStore;
|
||||
const { page: feedbackPage, selected: selectedFeedback, filters: feedbackFilters, update: feedbackUpdate, commentDraft, selectedCodes: feedbackSelectedCodes, detailTab: feedbackDetailTab, tagInput: feedbackTagInput } = feedbackStore;
|
||||
const { releases, notices: releaseNotices, selectedNotice, noticeDraft, uploadDraft } = releaseStore;
|
||||
const { sync: legacySync, documents: legacyDocuments, drafts: legacyDrafts, modal: legacyModal, activeMediaCategoryIndex } = legacyStore;
|
||||
const { sources, endpoints, draft: sourceDraft } = sourceStore;
|
||||
@@ -92,6 +93,7 @@ const routes: RouteItem[] = [
|
||||
{ path: "/admin/legacy/media-types", label: "媒体源 JSON", description: "维护旧客户端媒体源结构", icon: ClipboardList },
|
||||
{ path: "/admin/sources", label: "来源目录", description: "媒体/数据源目录和健康检测", icon: Network },
|
||||
{ path: "/admin/endpoints", label: "客户端接口", description: "新版客户端动态接口配置", icon: Code2 },
|
||||
{ path: "/admin/audit", label: "审计日志", description: "操作审计、登录记录与安全事件", icon: ShieldCheck },
|
||||
{ path: "/admin/system", label: "系统运维", description: "数据库、旧项目同步、安全、健康与审计", icon: Database },
|
||||
];
|
||||
|
||||
@@ -100,7 +102,7 @@ const navGroups = [
|
||||
{ label: "反馈", items: routes.filter((item) => ["/admin/feedbacks"].includes(item.path)) },
|
||||
{ label: "发布与兼容", items: routes.filter((item) => ["/admin/releases", "/admin/legacy/update-info", "/admin/legacy/media-types"].includes(item.path)) },
|
||||
{ label: "客户端接口", items: routes.filter((item) => ["/admin/sources", "/admin/endpoints"].includes(item.path)) },
|
||||
{ label: "系统运维", items: routes.filter((item) => ["/admin/system"].includes(item.path)) },
|
||||
{ label: "系统运维", items: routes.filter((item) => ["/admin/audit", "/admin/system"].includes(item.path)) },
|
||||
];
|
||||
|
||||
const pageMeta = computed(() => routes.find((item) => item.path === currentPath.value) || routes[0]);
|
||||
@@ -270,6 +272,16 @@ const viewContext = computed(() => ({
|
||||
feedbackOption: feedbackOption.value,
|
||||
feedbackPage: feedbackPage.value,
|
||||
feedbackUpdate,
|
||||
feedbackSelectedCodes: feedbackSelectedCodes.value,
|
||||
feedbackDetailTab: feedbackDetailTab.value,
|
||||
feedbackTagInput: feedbackTagInput.value,
|
||||
setFeedbackDetailTab: (tab: string) => { feedbackDetailTab.value = tab as any; },
|
||||
setFeedbackTagInput: (val: string) => { feedbackTagInput.value = val; },
|
||||
addFeedbackTag,
|
||||
removeFeedbackTag,
|
||||
toggleFeedbackCode,
|
||||
toggleAllFeedbackCodes,
|
||||
bulkUpdateFeedbacks,
|
||||
formatBytes,
|
||||
formatHealthOutput,
|
||||
healthOption: healthOption.value,
|
||||
@@ -476,6 +488,7 @@ async function load() {
|
||||
if (currentPath.value === "/admin/releases") await loadReleases();
|
||||
if (currentPath.value === "/admin/sources") await loadSources();
|
||||
if (currentPath.value === "/admin/endpoints") await loadEndpoints();
|
||||
if (currentPath.value === "/admin/audit") await loadAudit();
|
||||
if (currentPath.value === "/admin/system") await loadSystem({ preserveForms: true });
|
||||
const legacyName = activeLegacyName.value;
|
||||
if (legacyName) await loadLegacy(legacyName);
|
||||
@@ -500,13 +513,17 @@ async function loadSystem(options: LoadSystemOptions = {}) {
|
||||
]);
|
||||
}
|
||||
|
||||
async function loadFeedbacks() {
|
||||
async function loadFeedbacks(page?: number) {
|
||||
if (page !== undefined) feedbackFilters.page = page;
|
||||
const params = new URLSearchParams({ page: String(feedbackFilters.page), perPage: String(feedbackFilters.perPage) });
|
||||
if (feedbackFilters.q) params.set("q", feedbackFilters.q);
|
||||
if (feedbackFilters.status) params.set("status", feedbackFilters.status);
|
||||
if (feedbackFilters.priority) params.set("priority", feedbackFilters.priority);
|
||||
if ((feedbackFilters as any).category) params.set("category", (feedbackFilters as any).category);
|
||||
if ((feedbackFilters as any).assignee) params.set("assignee", (feedbackFilters as any).assignee);
|
||||
const data = await api<{ page: any }>(`/api/admin/feedbacks?${params}`);
|
||||
feedbackPage.value = data.page || { items: [], total: 0, page: 1, perPage: 20 };
|
||||
feedbackSelectedCodes.value = [];
|
||||
}
|
||||
|
||||
async function openFeedback(item: any) {
|
||||
@@ -516,6 +533,48 @@ async function openFeedback(item: any) {
|
||||
feedbackUpdate.priority = data.feedback.priority || "normal";
|
||||
feedbackUpdate.statusDetail = data.feedback.statusDetail || "";
|
||||
feedbackUpdate.publicReply = data.feedback.publicReply || "";
|
||||
feedbackUpdate.assignee = data.feedback.assignee || "";
|
||||
feedbackUpdate.tags = data.feedback.tags ? [...data.feedback.tags] : [];
|
||||
feedbackDetailTab.value = "info";
|
||||
}
|
||||
|
||||
function toggleFeedbackCode(code: string) {
|
||||
const idx = feedbackSelectedCodes.value.indexOf(code);
|
||||
if (idx >= 0) feedbackSelectedCodes.value.splice(idx, 1);
|
||||
else feedbackSelectedCodes.value.push(code);
|
||||
}
|
||||
|
||||
function toggleAllFeedbackCodes() {
|
||||
const items: any[] = feedbackPage.value?.items || [];
|
||||
if (feedbackSelectedCodes.value.length === items.length) {
|
||||
feedbackSelectedCodes.value = [];
|
||||
} else {
|
||||
feedbackSelectedCodes.value = items.map((item: any) => item.code);
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkUpdateFeedbacks(payload: { status?: string; assignee?: string; tags?: string[] }) {
|
||||
if (!feedbackSelectedCodes.value.length) return;
|
||||
await guarded(async () => {
|
||||
await api("/api/admin/feedbacks/bulk", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ codes: feedbackSelectedCodes.value, ...payload }),
|
||||
});
|
||||
setToast(`已批量更新 ${feedbackSelectedCodes.value.length} 条工单`);
|
||||
feedbackSelectedCodes.value = [];
|
||||
await loadFeedbacks();
|
||||
});
|
||||
}
|
||||
|
||||
function addFeedbackTag() {
|
||||
const tag = feedbackTagInput.value.trim();
|
||||
if (!tag || feedbackUpdate.tags.includes(tag)) { feedbackTagInput.value = ""; return; }
|
||||
feedbackUpdate.tags = [...feedbackUpdate.tags, tag];
|
||||
feedbackTagInput.value = "";
|
||||
}
|
||||
|
||||
function removeFeedbackTag(tag: string) {
|
||||
feedbackUpdate.tags = feedbackUpdate.tags.filter((t) => t !== tag);
|
||||
}
|
||||
|
||||
async function saveFeedbackUpdate() {
|
||||
@@ -1597,6 +1656,7 @@ function connectAdminEvents() {
|
||||
<LegacyJsonView v-else-if="activeLegacyName" :ctx="viewContext" />
|
||||
<SourcesView v-else-if="currentPath === '/admin/sources'" :ctx="viewContext" />
|
||||
<EndpointsView v-else-if="currentPath === '/admin/endpoints'" :ctx="viewContext" />
|
||||
<AuditLogView v-else-if="currentPath === '/admin/audit'" :ctx="viewContext" />
|
||||
<SystemView v-else-if="currentPath === '/admin/system'" :ctx="viewContext" />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -3,9 +3,19 @@ import { reactive, ref } from "vue";
|
||||
export function createFeedbackStore() {
|
||||
const page = ref<any>({ items: [], total: 0, page: 1, perPage: 20 });
|
||||
const selected = ref<any | null>(null);
|
||||
const filters = reactive({ q: "", status: "", priority: "", page: 1, perPage: 20 });
|
||||
const update = reactive({ status: "", priority: "", statusDetail: "", publicReply: "" });
|
||||
const filters = reactive({ q: "", status: "", priority: "", category: "", assignee: "", page: 1, perPage: 20 });
|
||||
const update = reactive<{ status: string; priority: string; statusDetail: string; publicReply: string; assignee: string; tags: string[] }>({
|
||||
status: "",
|
||||
priority: "",
|
||||
statusDetail: "",
|
||||
publicReply: "",
|
||||
assignee: "",
|
||||
tags: [],
|
||||
});
|
||||
const commentDraft = reactive({ body: "", internal: true });
|
||||
const selectedCodes = ref<string[]>([]);
|
||||
const detailTab = ref<"info" | "comments" | "activity">("info");
|
||||
const tagInput = ref("");
|
||||
|
||||
return { page, selected, filters, update, commentDraft };
|
||||
return { page, selected, filters, update, commentDraft, selectedCodes, detailTab, tagInput };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { Search, ChevronLeft, ChevronRight } from "lucide-vue-next";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-stack">
|
||||
<div class="toolbar">
|
||||
<label class="search-box">
|
||||
<Search :size="16" />
|
||||
<input v-model="ctx.auditPage.q" placeholder="搜索消息内容" @keyup.enter="ctx.loadAudit" />
|
||||
</label>
|
||||
<select v-model="ctx.auditPage.type" style="width:140px" @change="ctx.loadAudit">
|
||||
<option value="">全部类型</option>
|
||||
<option value="auth.login">管理员登录</option>
|
||||
<option value="auth.password_changed">修改密码</option>
|
||||
<option value="feedback.created">提交反馈</option>
|
||||
<option value="feedback.updated">更新工单</option>
|
||||
<option value="legacy_json.saved">保存兼容 JSON</option>
|
||||
<option value="release_notice.saved">保存版本日志</option>
|
||||
<option value="release.package_uploaded">上传发布包</option>
|
||||
<option value="legacy.sync">旧项目同步</option>
|
||||
</select>
|
||||
<input v-model="ctx.auditPage.target" placeholder="目标对象" style="width:100px" @keyup.enter="ctx.loadAudit" />
|
||||
<button class="btn ghost" @click="ctx.loadAudit">查询</button>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-head">
|
||||
<h2>审计日志</h2>
|
||||
<span class="badge">共 {{ ctx.auditPage.total || 0 }} 条</span>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>操作者</th>
|
||||
<th>操作类型</th>
|
||||
<th>目标</th>
|
||||
<th>消息</th>
|
||||
<th>IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in ctx.auditPage.items || []"
|
||||
:key="item.id"
|
||||
:class="{ selected: ctx.auditPage.selected?.id === item.id }"
|
||||
style="cursor:pointer"
|
||||
@click="ctx.selectAuditLog(item)"
|
||||
>
|
||||
<td class="mono">{{ item.createdAt }}</td>
|
||||
<td>{{ item.actor || "-" }}</td>
|
||||
<td><span class="badge neutral">{{ ctx.auditTypeLabel(item.type) }}</span></td>
|
||||
<td class="mono">{{ item.target || "-" }}</td>
|
||||
<td>{{ ctx.auditMessage(item) }}</td>
|
||||
<td class="hash">{{ item.ip || "-" }}</td>
|
||||
</tr>
|
||||
<tr v-if="!(ctx.auditPage.items || []).length">
|
||||
<td colspan="6">暂无审计日志。</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination" v-if="ctx.auditPage.total > 0">
|
||||
<button class="btn ghost small" :disabled="ctx.auditPage.page <= 1" @click="ctx.setAuditPage(ctx.auditPage.page - 1)">
|
||||
<ChevronLeft :size="14" />
|
||||
</button>
|
||||
<span class="muted">第 {{ ctx.auditPage.page }} / {{ Math.ceil(ctx.auditPage.total / ctx.auditPage.perPage) }} 页 · {{ ctx.auditPage.total }} 条</span>
|
||||
<button class="btn ghost small" :disabled="ctx.auditPage.page >= Math.ceil(ctx.auditPage.total / ctx.auditPage.perPage)" @click="ctx.setAuditPage(ctx.auditPage.page + 1)">
|
||||
<ChevronRight :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Detail panel for selected log -->
|
||||
<section v-if="ctx.auditPage.selected" class="panel">
|
||||
<div class="section-head">
|
||||
<h2>审计详情</h2>
|
||||
<span class="badge neutral">ID {{ ctx.auditPage.selected.id }}</span>
|
||||
</div>
|
||||
<div class="kv-grid">
|
||||
<span>时间</span><strong>{{ ctx.auditPage.selected.createdAt }}</strong>
|
||||
<span>操作者</span><strong>{{ ctx.auditPage.selected.actor || "-" }}</strong>
|
||||
<span>类型</span><strong>{{ ctx.auditTypeLabel(ctx.auditPage.selected.type) }}</strong>
|
||||
<span>目标</span><strong class="mono">{{ ctx.auditPage.selected.target || "-" }}</strong>
|
||||
<span>消息</span><strong>{{ ctx.auditMessage(ctx.auditPage.selected) }}</strong>
|
||||
<span>IP</span><strong class="mono">{{ ctx.auditPage.selected.ip || "-" }}</strong>
|
||||
<span>User-Agent</span><strong class="hash" style="word-break:break-all">{{ ctx.auditPage.selected.userAgent || "-" }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0 0;
|
||||
}
|
||||
.pagination .btn.small {
|
||||
padding: 3px 7px;
|
||||
}
|
||||
</style>
|
||||
@@ -16,7 +16,7 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
<div class="metric-grid">
|
||||
<article class="metric"><span>反馈总数</span><strong>{{ ctx.kpis.feedbackTotal || 0 }}</strong><small>今日新增 {{ ctx.kpis.feedbackToday || 0 }}</small></article>
|
||||
<article class="metric"><span>可见接口</span><strong>{{ ctx.kpis.sourceVisible || 0 }}</strong><small>接口总数 {{ ctx.kpis.sourceTotal || 0 }}</small></article>
|
||||
<article class="metric"><span>版本日志</span><strong>{{ ctx.kpis.releaseNotices || 0 }}</strong><small>{{ ctx.latestNotice?.version || "暂无最新版本" }}</small></article>
|
||||
<article class="metric"><span>版本日志</span><strong>{{ ctx.kpis.releaseNotices || 0 }}</strong><small>{{ ctx.latestNotice && ctx.latestNotice.version ? ctx.latestNotice.version : "暂无最新版本" }}</small></article>
|
||||
<article class="metric"><span>邮件失败</span><strong>{{ ctx.kpis.mailFailed || 0 }}</strong><small>旧反馈兼容记录</small></article>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,8 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
<component :is="ctx.autoRefreshPaused ? PlayCircle : PauseCircle" :size="16" />
|
||||
{{ ctx.autoRefreshPaused ? "恢复自动刷新" : "暂停自动刷新" }}
|
||||
</button>
|
||||
<span class="muted">每 15 秒自动刷新仪表盘数据。</span>
|
||||
<span class="muted">每 15 秒自动刷新。</span>
|
||||
<span v-if="ctx.lastRefreshedAt" class="muted">上次刷新:{{ ctx.lastRefreshedAt }}</span>
|
||||
</div>
|
||||
|
||||
<section v-if="ctx.sourceCheckJobs.length" class="panel">
|
||||
@@ -37,10 +38,10 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
<tr v-for="job in ctx.sourceCheckJobs.slice(0, 5)" :key="job.id">
|
||||
<td class="mono">{{ job.id }}</td>
|
||||
<td>{{ job.checked || 0 }} / {{ job.total || 0 }}</td>
|
||||
<td>{{ job.stats?.ok || 0 }}</td>
|
||||
<td>{{ job.stats?.redirected || 0 }}</td>
|
||||
<td>{{ job.stats?.degraded || 0 }}</td>
|
||||
<td>{{ job.stats?.error || 0 }}</td>
|
||||
<td>{{ (job.stats && job.stats.ok) || 0 }}</td>
|
||||
<td>{{ (job.stats && job.stats.redirected) || 0 }}</td>
|
||||
<td>{{ (job.stats && job.stats.degraded) || 0 }}</td>
|
||||
<td>{{ (job.stats && job.stats.error) || 0 }}</td>
|
||||
<td>{{ job.startedAt || "-" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -53,7 +54,7 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
<VChart class="chart" :option="ctx.heartbeatOption" autoresize />
|
||||
<div v-if="ctx.isHeartbeatChartEmpty" class="chart-empty">
|
||||
<strong>暂无服务端检测记录</strong>
|
||||
<span>点击“立即服务端检测”后会生成延迟曲线。</span>
|
||||
<span>点击立即服务端检测后会生成延迟曲线。</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel chart-panel"><h2>接口健康分布</h2><VChart class="chart" :option="ctx.healthOption" autoresize /></section>
|
||||
@@ -68,12 +69,12 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.heartbeats.slice(0, 10)" :key="item.id">
|
||||
<td>{{ item.name || item.sourceId }}</td>
|
||||
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ ctx.labelStatus(item.status) }}</span></td>
|
||||
<td><span :class='["badge", ctx.statusTone(item.status)]'>{{ ctx.labelStatus(item.status) }}</span></td>
|
||||
<td>{{ item.latencyMs || 0 }}ms</td>
|
||||
<td class="hash">{{ ctx.formatHealthOutput(item) }}</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>
|
||||
@@ -85,7 +86,7 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.clientCalls.slice(0, 8)" :key="item.id">
|
||||
<td>{{ item.sourceId }}</td>
|
||||
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ ctx.labelStatus(item.status) }}</span></td>
|
||||
<td><span :class='["badge", ctx.statusTone(item.status)]'>{{ ctx.labelStatus(item.status) }}</span></td>
|
||||
<td>{{ item.latencyMs || 0 }}ms</td>
|
||||
<td class="hash">{{ item.client || "-" }}</td>
|
||||
<td>{{ item.createdAt || "-" }}</td>
|
||||
@@ -94,5 +95,37 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-head">
|
||||
<h2>系统日志</h2>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<select v-model="ctx.systemLogPage.category" style="width:100px" @change="ctx.loadSystemLogs">
|
||||
<option value="">全部分类</option>
|
||||
<option value="feedback">反馈</option>
|
||||
<option value="release">发布</option>
|
||||
<option value="auth">认证</option>
|
||||
<option value="legacy">兼容</option>
|
||||
<option value="source">接口</option>
|
||||
<option value="database">数据库</option>
|
||||
<option value="risk">风险</option>
|
||||
</select>
|
||||
<button class="btn ghost" style="padding:3px 10px;font-size:12px" @click="ctx.loadSystemLogs">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>分类</th><th>类型</th><th>状态</th><th>消息</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in (ctx.systemLogPage.items || []).slice(0, 20)" :key="item.id">
|
||||
<td><span class="badge neutral">{{ item.category || "-" }}</span></td>
|
||||
<td class="mono">{{ item.type || "-" }}</td>
|
||||
<td><span :class='["badge", ctx.statusTone(item.status)]'>{{ ctx.labelStatus(item.status) }}</span></td>
|
||||
<td>{{ item.message || "-" }}</td>
|
||||
<td>{{ item.createdAt || "-" }}</td>
|
||||
</tr>
|
||||
<tr v-if="!(ctx.systemLogPage.items || []).length"><td colspan="5">暂无系统日志。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { Mail, Save, Search, UploadCloud } from "lucide-vue-next";
|
||||
import { Mail, Save, Search, UploadCloud, Tag, X, ChevronLeft, ChevronRight } from "lucide-vue-next";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="split">
|
||||
<!-- LEFT: list panel -->
|
||||
<section class="panel page-stack">
|
||||
<div class="toolbar">
|
||||
<label class="search-box">
|
||||
@@ -25,93 +26,340 @@ defineProps<{ ctx: any }>();
|
||||
<option value="high">高</option>
|
||||
<option value="urgent">紧急</option>
|
||||
</select>
|
||||
<select v-model="ctx.feedbackFilters.category" @change="ctx.loadFeedbacks">
|
||||
<option value="">全部分类</option>
|
||||
<option value="issue">问题</option>
|
||||
<option value="suggestion">建议</option>
|
||||
<option value="ui">界面反馈</option>
|
||||
<option value="other">其他</option>
|
||||
</select>
|
||||
<input v-model="ctx.feedbackFilters.assignee" placeholder="受理人" style="width:90px" @keyup.enter="ctx.loadFeedbacks" />
|
||||
<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>
|
||||
|
||||
<!-- bulk action bar -->
|
||||
<div v-if="ctx.feedbackSelectedCodes.length" class="toolbar bulk-bar">
|
||||
<span class="muted">已选 {{ ctx.feedbackSelectedCodes.length }} 条</span>
|
||||
<button class="btn ghost" @click="ctx.bulkUpdateFeedbacks({ status: 'closed' })">批量关闭</button>
|
||||
<button class="btn ghost" @click="ctx.bulkUpdateFeedbacks({ status: 'processing' })">批量处理中</button>
|
||||
<button class="btn ghost" @click="ctx.feedbackSelectedCodes.splice(0)">取消选择</button>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead><tr><th>编号</th><th>标题</th><th>状态</th><th>优先级</th><th>邮件</th><th>最近活动</th></tr></thead>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:34px">
|
||||
<input type="checkbox"
|
||||
:checked="ctx.feedbackSelectedCodes.length === (ctx.feedbackPage.items?.length || 0) && ctx.feedbackPage.items?.length > 0"
|
||||
@change="ctx.toggleAllFeedbackCodes" />
|
||||
</th>
|
||||
<th>编号</th>
|
||||
<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)">
|
||||
<tr
|
||||
v-for="item in ctx.feedbackPage.items"
|
||||
:key="item.code"
|
||||
:class="{ selected: ctx.selectedFeedback?.code === item.code }"
|
||||
@click="ctx.openFeedback(item)"
|
||||
style="cursor:pointer"
|
||||
>
|
||||
<td @click.stop>
|
||||
<input type="checkbox"
|
||||
:checked="ctx.feedbackSelectedCodes.includes(item.code)"
|
||||
@change="ctx.toggleFeedbackCode(item.code)" />
|
||||
</td>
|
||||
<td class="mono">{{ item.code }}</td>
|
||||
<td>{{ item.title || item.summaryText || "未命名反馈" }}</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.category || "-" }}</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="6">暂无反馈工单。</td></tr>
|
||||
<tr v-if="!ctx.feedbackPage.items?.length">
|
||||
<td colspan="8">暂无反馈工单。</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- pagination -->
|
||||
<div class="pagination" v-if="ctx.feedbackPage.total > 0">
|
||||
<button class="btn ghost small" :disabled="ctx.feedbackPage.page <= 1" @click="ctx.loadFeedbacks(ctx.feedbackPage.page - 1)">
|
||||
<ChevronLeft :size="14" />
|
||||
</button>
|
||||
<span class="muted">第 {{ ctx.feedbackPage.page }} / {{ Math.ceil(ctx.feedbackPage.total / ctx.feedbackPage.perPage) }} 页 · {{ ctx.feedbackPage.total }} 条</span>
|
||||
<button class="btn ghost small" :disabled="ctx.feedbackPage.page >= Math.ceil(ctx.feedbackPage.total / ctx.feedbackPage.perPage)" @click="ctx.loadFeedbacks(ctx.feedbackPage.page + 1)">
|
||||
<ChevronRight :size="14" />
|
||||
</button>
|
||||
<select v-model="ctx.feedbackFilters.perPage" style="width:72px" @change="ctx.loadFeedbacks(1)">
|
||||
<option :value="10">10条</option>
|
||||
<option :value="20">20条</option>
|
||||
<option :value="50">50条</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- RIGHT: detail panel -->
|
||||
<aside class="panel detail-panel">
|
||||
<template v-if="ctx.selectedFeedback">
|
||||
<div class="section-head">
|
||||
<h2>{{ ctx.selectedFeedback.code }}</h2>
|
||||
<span :class="['badge', ctx.selectedFeedback.mailSent ? 'good' : 'warn']">{{ ctx.selectedFeedback.mailSent ? "邮件已发送" : "邮件未发送" }}</span>
|
||||
<span :class="['badge', ctx.selectedFeedback.mailSent ? 'good' : 'warn']">
|
||||
{{ ctx.selectedFeedback.mailSent ? "邮件已发" : "邮件未发" }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="muted">{{ ctx.selectedFeedback.title || ctx.selectedFeedback.body }}</p>
|
||||
<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>
|
||||
<p class="muted" style="margin-bottom:10px">{{ ctx.selectedFeedback.title || ctx.selectedFeedback.body }}</p>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tab-bar">
|
||||
<button :class="['tab-btn', { active: ctx.feedbackDetailTab === 'info' }]" @click="ctx.setFeedbackDetailTab('info')">基本信息</button>
|
||||
<button :class="['tab-btn', { active: ctx.feedbackDetailTab === 'comments' }]" @click="ctx.setFeedbackDetailTab('comments')">评论 {{ (ctx.selectedFeedback.comments || []).length }}</button>
|
||||
<button :class="['tab-btn', { active: ctx.feedbackDetailTab === 'activity' }]" @click="ctx.setFeedbackDetailTab('activity')">活动日志</button>
|
||||
</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>
|
||||
<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>
|
||||
<!-- Tab: info -->
|
||||
<div v-if="ctx.feedbackDetailTab === 'info'" class="tab-content page-stack">
|
||||
<div class="kv-grid">
|
||||
<span>联系方式</span><strong>{{ ctx.selectedFeedback.contact || "-" }}</strong>
|
||||
<span>来源</span><strong>{{ ctx.selectedFeedback.sourceChannel || "-" }}</strong>
|
||||
<span>分类</span><strong>{{ ctx.selectedFeedback.category || "-" }}</strong>
|
||||
<span>受理人</span><strong>{{ ctx.selectedFeedback.assignee || "-" }}</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.assignee" placeholder="分配受理人" /></label>
|
||||
<label>状态说明<input v-model="ctx.feedbackUpdate.statusDetail" /></label>
|
||||
<label>公开回复<textarea v-model="ctx.feedbackUpdate.publicReply" rows="3"></textarea></label>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="form-group">
|
||||
<label style="display:block;margin-bottom:4px">标签</label>
|
||||
<div class="tag-row">
|
||||
<span v-for="tag in ctx.feedbackUpdate.tags" :key="tag" class="tag-chip">
|
||||
<Tag :size="11" />{{ tag }}
|
||||
<button class="tag-remove" @click="ctx.removeFeedbackTag(tag)"><X :size="11" /></button>
|
||||
</span>
|
||||
<input
|
||||
:value="ctx.feedbackTagInput"
|
||||
@input="ctx.setFeedbackTagInput($event.target.value)"
|
||||
@keyup.enter="ctx.addFeedbackTag"
|
||||
placeholder="输入后回车添加"
|
||||
style="border:none;outline:none;flex:1;min-width:80px;font-size:13px;background:transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- mail records -->
|
||||
<details style="margin-top:8px">
|
||||
<summary>邮件记录 ({{ (ctx.selectedFeedback.mailRecords || []).length }})</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>
|
||||
</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>
|
||||
<!-- Tab: comments -->
|
||||
<div v-else-if="ctx.feedbackDetailTab === 'comments'" class="tab-content page-stack">
|
||||
<div class="comment-list">
|
||||
<div
|
||||
v-for="item in ctx.selectedFeedback.comments || []"
|
||||
:key="item.id"
|
||||
:class="['comment', item.internal ? 'internal' : '']"
|
||||
>
|
||||
<div class="comment-meta">
|
||||
<strong>{{ item.author }}</strong>
|
||||
<span class="muted">{{ item.createdAt }}</span>
|
||||
<span v-if="item.internal" class="badge warn">内部备注</span>
|
||||
</div>
|
||||
<p>{{ item.body }}</p>
|
||||
</div>
|
||||
<div v-if="!(ctx.selectedFeedback.comments || []).length" class="muted" style="padding:12px 0">暂无评论。</div>
|
||||
</div>
|
||||
<hr />
|
||||
<label>新增评论<textarea v-model="ctx.commentDraft.body" rows="3" placeholder="输入评论内容..."></textarea></label>
|
||||
<label class="checkbox"><input v-model="ctx.commentDraft.internal" type="checkbox" />内部备注(不对外公示)</label>
|
||||
<button class="btn primary" @click="ctx.addFeedbackComment">提交评论</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab: activity -->
|
||||
<div v-else-if="ctx.feedbackDetailTab === 'activity'" class="tab-content">
|
||||
<div class="timeline">
|
||||
<div
|
||||
v-for="evt in [...(ctx.selectedFeedback.events || []), ...(ctx.selectedFeedback.legacyEvents || [])].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())"
|
||||
:key="evt.id"
|
||||
class="timeline-item"
|
||||
>
|
||||
<div class="timeline-dot"></div>
|
||||
<div class="timeline-body">
|
||||
<div class="timeline-meta">
|
||||
<strong>{{ evt.actor || evt.type || "系统" }}</strong>
|
||||
<span class="muted">{{ evt.createdAt }}</span>
|
||||
</div>
|
||||
<p>{{ evt.message || evt.eventType || evt.type }}</p>
|
||||
<p v-if="evt.fromValue || evt.toValue" class="muted" style="font-size:12px">
|
||||
{{ evt.fromValue }} → {{ evt.toValue }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!(ctx.selectedFeedback.events?.length || ctx.selectedFeedback.legacyEvents?.length)" class="muted" style="padding:12px 0">暂无活动日志。</div>
|
||||
</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>
|
||||
<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>
|
||||
</aside>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bulk-bar {
|
||||
background: var(--color-accent-soft, #eaf4ff);
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0 0;
|
||||
}
|
||||
.pagination .btn.small {
|
||||
padding: 3px 7px;
|
||||
}
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
border-bottom: 1px solid var(--color-stroke, #e5e5e5);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.tab-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 7px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary, #5e5e5e);
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tab-btn.active {
|
||||
color: var(--color-accent, #0067c0);
|
||||
border-bottom-color: var(--color-accent, #0067c0);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tab-content {
|
||||
min-height: 180px;
|
||||
}
|
||||
.tag-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
border: 1px solid var(--color-stroke, #e5e5e5);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
min-height: 36px;
|
||||
}
|
||||
.tag-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--color-accent-soft, #eaf4ff);
|
||||
color: var(--color-accent, #0067c0);
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px 2px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.tag-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: inherit;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.tag-remove:hover { opacity: 1; }
|
||||
.comment {
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--color-surface-alt, #f3f3f3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.comment.internal {
|
||||
border-left: 3px solid var(--color-warn, #d97706);
|
||||
}
|
||||
.comment-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding-left: 16px;
|
||||
border-left: 2px solid var(--color-stroke, #e5e5e5);
|
||||
}
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
padding: 8px 0 8px 16px;
|
||||
}
|
||||
.timeline-dot {
|
||||
position: absolute;
|
||||
left: -7px;
|
||||
top: 14px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent, #0067c0);
|
||||
border: 2px solid white;
|
||||
}
|
||||
.timeline-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.timeline-body p { margin: 2px 0; font-size: 13px; }
|
||||
</style>
|
||||
|
||||
@@ -10,15 +10,15 @@
|
||||
"fullInstaller": {
|
||||
"fileName": "YMhut_Box_WinUI_Setup_2.0.7.6.exe",
|
||||
"url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.7.6.exe",
|
||||
"sha256": "972478ab77d016165e2f35171571b1f59ba7dfa97d7c86ed9fb44b0b82ad4d93",
|
||||
"size": 113486448,
|
||||
"sha256": "38990585884af04fe4b86344418e9021dcf319bce351cd46bbe18762bd18bd1d",
|
||||
"size": 113488208,
|
||||
"version": "2.0.7.6"
|
||||
},
|
||||
"msix": {
|
||||
"fileName": "YMhutBox_2.0.7.6_x64.msix",
|
||||
"url": "https://update.ymhut.cn/downloads/YMhutBox_2.0.7.6_x64.msix",
|
||||
"sha256": "a05b02ebf5f2d1b71e051e906bf9bdd71bd018893badf6f462e3bd61e3f232c0",
|
||||
"size": 259970957,
|
||||
"sha256": "015820df9a5076d3759619fc90cb23b0250d6828adb6713ac48d0587162e4070",
|
||||
"size": 259972073,
|
||||
"version": "2.0.7.6"
|
||||
},
|
||||
"appInstaller": {
|
||||
@@ -32,15 +32,15 @@
|
||||
"fullInstaller": {
|
||||
"fileName": "YMhut_Box_WinUI_Setup_2.0.7.6.exe",
|
||||
"url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.7.6.exe",
|
||||
"sha256": "972478ab77d016165e2f35171571b1f59ba7dfa97d7c86ed9fb44b0b82ad4d93",
|
||||
"size": 113486448,
|
||||
"sha256": "38990585884af04fe4b86344418e9021dcf319bce351cd46bbe18762bd18bd1d",
|
||||
"size": 113488208,
|
||||
"version": "2.0.7.6"
|
||||
},
|
||||
"msix": {
|
||||
"fileName": "YMhutBox_2.0.7.6_x64.msix",
|
||||
"url": "https://update.ymhut.cn/downloads/YMhutBox_2.0.7.6_x64.msix",
|
||||
"sha256": "a05b02ebf5f2d1b71e051e906bf9bdd71bd018893badf6f462e3bd61e3f232c0",
|
||||
"size": 259970957,
|
||||
"sha256": "015820df9a5076d3759619fc90cb23b0250d6828adb6713ac48d0587162e4070",
|
||||
"size": 259972073,
|
||||
"version": "2.0.7.6"
|
||||
},
|
||||
"appInstaller": {
|
||||
@@ -56,5 +56,5 @@
|
||||
"updateInfo": "The official update-info catalog only describes the full offline installer, MSIX, and appinstaller artifacts.",
|
||||
"distribution": "The update channel publishes the full offline installer, MSIX, and appinstaller artifacts."
|
||||
},
|
||||
"createdAt": "2026-06-29T06:32:36.4668331Z"
|
||||
"createdAt": "2026-06-30T02:26:21.1755219Z"
|
||||
}
|
||||
Reference in New Issue
Block a user