更新后端服务UI
客户端重构部分界面UI
This commit is contained in:
@@ -20,6 +20,7 @@ import LegacyJsonView from "./views/LegacyJsonView.vue";
|
|||||||
import ReleasesView from "./views/ReleasesView.vue";
|
import ReleasesView from "./views/ReleasesView.vue";
|
||||||
import SourcesView from "./views/SourcesView.vue";
|
import SourcesView from "./views/SourcesView.vue";
|
||||||
import SystemView from "./views/SystemView.vue";
|
import SystemView from "./views/SystemView.vue";
|
||||||
|
import AuditLogView from "./views/AuditLogView.vue";
|
||||||
import { adminFetch, toChineseError, uploadAdminFile } from "./api/admin";
|
import { adminFetch, toChineseError, uploadAdminFile } from "./api/admin";
|
||||||
import { createAuthStore } from "./stores/auth";
|
import { createAuthStore } from "./stores/auth";
|
||||||
import { createDashboardStore } from "./stores/dashboard";
|
import { createDashboardStore } from "./stores/dashboard";
|
||||||
@@ -78,7 +79,7 @@ const systemStore = createSystemStore();
|
|||||||
|
|
||||||
const { csrf, captcha, bootstrap: authBootstrap, loginForm, passwordForm } = authStore;
|
const { csrf, captcha, bootstrap: authBootstrap, loginForm, passwordForm } = authStore;
|
||||||
const { dashboard, sourceCheckJobs } = dashboardStore;
|
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 { releases, notices: releaseNotices, selectedNotice, noticeDraft, uploadDraft } = releaseStore;
|
||||||
const { sync: legacySync, documents: legacyDocuments, drafts: legacyDrafts, modal: legacyModal, activeMediaCategoryIndex } = legacyStore;
|
const { sync: legacySync, documents: legacyDocuments, drafts: legacyDrafts, modal: legacyModal, activeMediaCategoryIndex } = legacyStore;
|
||||||
const { sources, endpoints, draft: sourceDraft } = sourceStore;
|
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/legacy/media-types", label: "媒体源 JSON", description: "维护旧客户端媒体源结构", icon: ClipboardList },
|
||||||
{ path: "/admin/sources", label: "来源目录", description: "媒体/数据源目录和健康检测", icon: Network },
|
{ path: "/admin/sources", label: "来源目录", description: "媒体/数据源目录和健康检测", icon: Network },
|
||||||
{ path: "/admin/endpoints", label: "客户端接口", description: "新版客户端动态接口配置", icon: Code2 },
|
{ path: "/admin/endpoints", label: "客户端接口", description: "新版客户端动态接口配置", icon: Code2 },
|
||||||
|
{ path: "/admin/audit", label: "审计日志", description: "操作审计、登录记录与安全事件", icon: ShieldCheck },
|
||||||
{ path: "/admin/system", label: "系统运维", description: "数据库、旧项目同步、安全、健康与审计", icon: Database },
|
{ 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/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/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/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]);
|
const pageMeta = computed(() => routes.find((item) => item.path === currentPath.value) || routes[0]);
|
||||||
@@ -270,6 +272,16 @@ const viewContext = computed(() => ({
|
|||||||
feedbackOption: feedbackOption.value,
|
feedbackOption: feedbackOption.value,
|
||||||
feedbackPage: feedbackPage.value,
|
feedbackPage: feedbackPage.value,
|
||||||
feedbackUpdate,
|
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,
|
formatBytes,
|
||||||
formatHealthOutput,
|
formatHealthOutput,
|
||||||
healthOption: healthOption.value,
|
healthOption: healthOption.value,
|
||||||
@@ -476,6 +488,7 @@ async function load() {
|
|||||||
if (currentPath.value === "/admin/releases") await loadReleases();
|
if (currentPath.value === "/admin/releases") await loadReleases();
|
||||||
if (currentPath.value === "/admin/sources") await loadSources();
|
if (currentPath.value === "/admin/sources") await loadSources();
|
||||||
if (currentPath.value === "/admin/endpoints") await loadEndpoints();
|
if (currentPath.value === "/admin/endpoints") await loadEndpoints();
|
||||||
|
if (currentPath.value === "/admin/audit") await loadAudit();
|
||||||
if (currentPath.value === "/admin/system") await loadSystem({ preserveForms: true });
|
if (currentPath.value === "/admin/system") await loadSystem({ preserveForms: true });
|
||||||
const legacyName = activeLegacyName.value;
|
const legacyName = activeLegacyName.value;
|
||||||
if (legacyName) await loadLegacy(legacyName);
|
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) });
|
const params = new URLSearchParams({ page: String(feedbackFilters.page), perPage: String(feedbackFilters.perPage) });
|
||||||
if (feedbackFilters.q) params.set("q", feedbackFilters.q);
|
if (feedbackFilters.q) params.set("q", feedbackFilters.q);
|
||||||
if (feedbackFilters.status) params.set("status", feedbackFilters.status);
|
if (feedbackFilters.status) params.set("status", feedbackFilters.status);
|
||||||
if (feedbackFilters.priority) params.set("priority", feedbackFilters.priority);
|
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}`);
|
const data = await api<{ page: any }>(`/api/admin/feedbacks?${params}`);
|
||||||
feedbackPage.value = data.page || { items: [], total: 0, page: 1, perPage: 20 };
|
feedbackPage.value = data.page || { items: [], total: 0, page: 1, perPage: 20 };
|
||||||
|
feedbackSelectedCodes.value = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openFeedback(item: any) {
|
async function openFeedback(item: any) {
|
||||||
@@ -516,6 +533,48 @@ async function openFeedback(item: any) {
|
|||||||
feedbackUpdate.priority = data.feedback.priority || "normal";
|
feedbackUpdate.priority = data.feedback.priority || "normal";
|
||||||
feedbackUpdate.statusDetail = data.feedback.statusDetail || "";
|
feedbackUpdate.statusDetail = data.feedback.statusDetail || "";
|
||||||
feedbackUpdate.publicReply = data.feedback.publicReply || "";
|
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() {
|
async function saveFeedbackUpdate() {
|
||||||
@@ -1597,6 +1656,7 @@ function connectAdminEvents() {
|
|||||||
<LegacyJsonView v-else-if="activeLegacyName" :ctx="viewContext" />
|
<LegacyJsonView v-else-if="activeLegacyName" :ctx="viewContext" />
|
||||||
<SourcesView v-else-if="currentPath === '/admin/sources'" :ctx="viewContext" />
|
<SourcesView v-else-if="currentPath === '/admin/sources'" :ctx="viewContext" />
|
||||||
<EndpointsView v-else-if="currentPath === '/admin/endpoints'" :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" />
|
<SystemView v-else-if="currentPath === '/admin/system'" :ctx="viewContext" />
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -3,9 +3,19 @@ import { reactive, ref } from "vue";
|
|||||||
export function createFeedbackStore() {
|
export function createFeedbackStore() {
|
||||||
const page = ref<any>({ items: [], total: 0, page: 1, perPage: 20 });
|
const page = ref<any>({ items: [], total: 0, page: 1, perPage: 20 });
|
||||||
const selected = ref<any | null>(null);
|
const selected = ref<any | null>(null);
|
||||||
const filters = reactive({ q: "", status: "", priority: "", page: 1, perPage: 20 });
|
const filters = reactive({ q: "", status: "", priority: "", category: "", assignee: "", page: 1, perPage: 20 });
|
||||||
const update = reactive({ status: "", priority: "", statusDetail: "", publicReply: "" });
|
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 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">
|
<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.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.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>
|
<article class="metric"><span>邮件失败</span><strong>{{ ctx.kpis.mailFailed || 0 }}</strong><small>旧反馈兼容记录</small></article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -26,7 +26,8 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
|||||||
<component :is="ctx.autoRefreshPaused ? PlayCircle : PauseCircle" :size="16" />
|
<component :is="ctx.autoRefreshPaused ? PlayCircle : PauseCircle" :size="16" />
|
||||||
{{ ctx.autoRefreshPaused ? "恢复自动刷新" : "暂停自动刷新" }}
|
{{ ctx.autoRefreshPaused ? "恢复自动刷新" : "暂停自动刷新" }}
|
||||||
</button>
|
</button>
|
||||||
<span class="muted">每 15 秒自动刷新仪表盘数据。</span>
|
<span class="muted">每 15 秒自动刷新。</span>
|
||||||
|
<span v-if="ctx.lastRefreshedAt" class="muted">上次刷新:{{ ctx.lastRefreshedAt }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section v-if="ctx.sourceCheckJobs.length" class="panel">
|
<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">
|
<tr v-for="job in ctx.sourceCheckJobs.slice(0, 5)" :key="job.id">
|
||||||
<td class="mono">{{ job.id }}</td>
|
<td class="mono">{{ job.id }}</td>
|
||||||
<td>{{ job.checked || 0 }} / {{ job.total || 0 }}</td>
|
<td>{{ job.checked || 0 }} / {{ job.total || 0 }}</td>
|
||||||
<td>{{ job.stats?.ok || 0 }}</td>
|
<td>{{ (job.stats && job.stats.ok) || 0 }}</td>
|
||||||
<td>{{ job.stats?.redirected || 0 }}</td>
|
<td>{{ (job.stats && job.stats.redirected) || 0 }}</td>
|
||||||
<td>{{ job.stats?.degraded || 0 }}</td>
|
<td>{{ (job.stats && job.stats.degraded) || 0 }}</td>
|
||||||
<td>{{ job.stats?.error || 0 }}</td>
|
<td>{{ (job.stats && job.stats.error) || 0 }}</td>
|
||||||
<td>{{ job.startedAt || "-" }}</td>
|
<td>{{ job.startedAt || "-" }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -53,7 +54,7 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
|||||||
<VChart class="chart" :option="ctx.heartbeatOption" autoresize />
|
<VChart class="chart" :option="ctx.heartbeatOption" autoresize />
|
||||||
<div v-if="ctx.isHeartbeatChartEmpty" class="chart-empty">
|
<div v-if="ctx.isHeartbeatChartEmpty" class="chart-empty">
|
||||||
<strong>暂无服务端检测记录</strong>
|
<strong>暂无服务端检测记录</strong>
|
||||||
<span>点击“立即服务端检测”后会生成延迟曲线。</span>
|
<span>点击立即服务端检测后会生成延迟曲线。</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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.healthOption" autoresize /></section>
|
||||||
@@ -68,12 +69,12 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="item in ctx.heartbeats.slice(0, 10)" :key="item.id">
|
<tr v-for="item in ctx.heartbeats.slice(0, 10)" :key="item.id">
|
||||||
<td>{{ item.name || item.sourceId }}</td>
|
<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>{{ item.latencyMs || 0 }}ms</td>
|
||||||
<td class="hash">{{ ctx.formatHealthOutput(item) }}</td>
|
<td class="hash">{{ ctx.formatHealthOutput(item) }}</td>
|
||||||
<td>{{ item.checkedAt || "-" }}</td>
|
<td>{{ item.checkedAt || "-" }}</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
@@ -85,7 +86,7 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="item in ctx.clientCalls.slice(0, 8)" :key="item.id">
|
<tr v-for="item in ctx.clientCalls.slice(0, 8)" :key="item.id">
|
||||||
<td>{{ item.sourceId }}</td>
|
<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>{{ item.latencyMs || 0 }}ms</td>
|
||||||
<td class="hash">{{ item.client || "-" }}</td>
|
<td class="hash">{{ item.client || "-" }}</td>
|
||||||
<td>{{ item.createdAt || "-" }}</td>
|
<td>{{ item.createdAt || "-" }}</td>
|
||||||
@@ -94,5 +95,37 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<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 }>();
|
defineProps<{ ctx: any }>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="split">
|
<section class="split">
|
||||||
|
<!-- LEFT: list panel -->
|
||||||
<section class="panel page-stack">
|
<section class="panel page-stack">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<label class="search-box">
|
<label class="search-box">
|
||||||
@@ -25,35 +26,112 @@ defineProps<{ ctx: any }>();
|
|||||||
<option value="high">高</option>
|
<option value="high">高</option>
|
||||||
<option value="urgent">紧急</option>
|
<option value="urgent">紧急</option>
|
||||||
</select>
|
</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>
|
<button class="btn ghost" @click="ctx.loadFeedbacks">查询</button>
|
||||||
<a class="btn ghost" href="/api/admin/feedbacks/export" target="_blank"><UploadCloud :size="16" />CSV</a>
|
<a class="btn ghost" href="/api/admin/feedbacks/export" target="_blank"><UploadCloud :size="16" />CSV</a>
|
||||||
</div>
|
</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>
|
<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>
|
<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 class="mono">{{ item.code }}</td>
|
||||||
<td>{{ item.title || item.summaryText || "未命名反馈" }}</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.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', 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>
|
<td>{{ item.lastActivityAt || item.createdAt }}</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</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>
|
</section>
|
||||||
|
|
||||||
|
<!-- RIGHT: detail panel -->
|
||||||
<aside class="panel detail-panel">
|
<aside class="panel detail-panel">
|
||||||
<template v-if="ctx.selectedFeedback">
|
<template v-if="ctx.selectedFeedback">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>{{ ctx.selectedFeedback.code }}</h2>
|
<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>
|
</div>
|
||||||
<p class="muted">{{ ctx.selectedFeedback.title || ctx.selectedFeedback.body }}</p>
|
<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>
|
||||||
|
|
||||||
|
<!-- Tab: info -->
|
||||||
|
<div v-if="ctx.feedbackDetailTab === 'info'" class="tab-content page-stack">
|
||||||
<div class="kv-grid">
|
<div class="kv-grid">
|
||||||
<span>联系方式</span><strong>{{ ctx.selectedFeedback.contact || "-" }}</strong>
|
<span>联系方式</span><strong>{{ ctx.selectedFeedback.contact || "-" }}</strong>
|
||||||
<span>来源</span><strong>{{ ctx.selectedFeedback.sourceChannel || "-" }}</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>
|
<span>接收时间</span><strong>{{ ctx.selectedFeedback.createdAt || "-" }}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -72,27 +150,36 @@ defineProps<{ ctx: any }>();
|
|||||||
<option value="urgent">紧急</option>
|
<option value="urgent">紧急</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label>受理人<input v-model="ctx.feedbackUpdate.assignee" placeholder="分配受理人" /></label>
|
||||||
<label>状态说明<input v-model="ctx.feedbackUpdate.statusDetail" /></label>
|
<label>状态说明<input v-model="ctx.feedbackUpdate.statusDetail" /></label>
|
||||||
<label>公开回复<textarea v-model="ctx.feedbackUpdate.publicReply" rows="3"></textarea></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">
|
<div class="button-row">
|
||||||
<button class="btn primary" @click="ctx.saveFeedbackUpdate"><Save :size="16" />保存工单</button>
|
<button class="btn primary" @click="ctx.saveFeedbackUpdate"><Save :size="16" />保存工单</button>
|
||||||
<button class="btn ghost" @click="ctx.retryFeedbackMail"><Mail :size="16" />重试邮件</button>
|
<button class="btn ghost" @click="ctx.retryFeedbackMail"><Mail :size="16" />重试邮件</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr />
|
<!-- mail records -->
|
||||||
<h3>评论</h3>
|
<details style="margin-top:8px">
|
||||||
<div class="comment-list">
|
<summary>邮件记录 ({{ (ctx.selectedFeedback.mailRecords || []).length }})</summary>
|
||||||
<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>
|
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>状态</th><th>收件人</th><th>主题</th><th>错误</th></tr></thead>
|
<thead><tr><th>状态</th><th>收件人</th><th>主题</th><th>错误</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -106,12 +193,173 @@ defineProps<{ ctx: any }>();
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</details>
|
</details>
|
||||||
<details>
|
</div>
|
||||||
<summary>旧反馈事件</summary>
|
|
||||||
<pre class="json-preview small">{{ ctx.pretty({ events: ctx.selectedFeedback.legacyEvents }) }}</pre>
|
<!-- Tab: comments -->
|
||||||
</details>
|
<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>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="empty-state">选择一条工单查看详情。</div>
|
<div v-else class="empty-state">选择一条工单查看详情。</div>
|
||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</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": {
|
"fullInstaller": {
|
||||||
"fileName": "YMhut_Box_WinUI_Setup_2.0.7.6.exe",
|
"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",
|
"url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.7.6.exe",
|
||||||
"sha256": "972478ab77d016165e2f35171571b1f59ba7dfa97d7c86ed9fb44b0b82ad4d93",
|
"sha256": "38990585884af04fe4b86344418e9021dcf319bce351cd46bbe18762bd18bd1d",
|
||||||
"size": 113486448,
|
"size": 113488208,
|
||||||
"version": "2.0.7.6"
|
"version": "2.0.7.6"
|
||||||
},
|
},
|
||||||
"msix": {
|
"msix": {
|
||||||
"fileName": "YMhutBox_2.0.7.6_x64.msix",
|
"fileName": "YMhutBox_2.0.7.6_x64.msix",
|
||||||
"url": "https://update.ymhut.cn/downloads/YMhutBox_2.0.7.6_x64.msix",
|
"url": "https://update.ymhut.cn/downloads/YMhutBox_2.0.7.6_x64.msix",
|
||||||
"sha256": "a05b02ebf5f2d1b71e051e906bf9bdd71bd018893badf6f462e3bd61e3f232c0",
|
"sha256": "015820df9a5076d3759619fc90cb23b0250d6828adb6713ac48d0587162e4070",
|
||||||
"size": 259970957,
|
"size": 259972073,
|
||||||
"version": "2.0.7.6"
|
"version": "2.0.7.6"
|
||||||
},
|
},
|
||||||
"appInstaller": {
|
"appInstaller": {
|
||||||
@@ -32,15 +32,15 @@
|
|||||||
"fullInstaller": {
|
"fullInstaller": {
|
||||||
"fileName": "YMhut_Box_WinUI_Setup_2.0.7.6.exe",
|
"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",
|
"url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.7.6.exe",
|
||||||
"sha256": "972478ab77d016165e2f35171571b1f59ba7dfa97d7c86ed9fb44b0b82ad4d93",
|
"sha256": "38990585884af04fe4b86344418e9021dcf319bce351cd46bbe18762bd18bd1d",
|
||||||
"size": 113486448,
|
"size": 113488208,
|
||||||
"version": "2.0.7.6"
|
"version": "2.0.7.6"
|
||||||
},
|
},
|
||||||
"msix": {
|
"msix": {
|
||||||
"fileName": "YMhutBox_2.0.7.6_x64.msix",
|
"fileName": "YMhutBox_2.0.7.6_x64.msix",
|
||||||
"url": "https://update.ymhut.cn/downloads/YMhutBox_2.0.7.6_x64.msix",
|
"url": "https://update.ymhut.cn/downloads/YMhutBox_2.0.7.6_x64.msix",
|
||||||
"sha256": "a05b02ebf5f2d1b71e051e906bf9bdd71bd018893badf6f462e3bd61e3f232c0",
|
"sha256": "015820df9a5076d3759619fc90cb23b0250d6828adb6713ac48d0587162e4070",
|
||||||
"size": 259970957,
|
"size": 259972073,
|
||||||
"version": "2.0.7.6"
|
"version": "2.0.7.6"
|
||||||
},
|
},
|
||||||
"appInstaller": {
|
"appInstaller": {
|
||||||
@@ -56,5 +56,5 @@
|
|||||||
"updateInfo": "The official update-info catalog only describes the full offline installer, MSIX, and appinstaller artifacts.",
|
"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."
|
"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"
|
||||||
}
|
}
|
||||||
@@ -25,6 +25,13 @@ internal static class ModernUi
|
|||||||
internal static SolidColorBrush Danger { get; } = Brush("#C42B1C");
|
internal static SolidColorBrush Danger { get; } = Brush("#C42B1C");
|
||||||
internal static SolidColorBrush Info { get; } = Brush("#0078D4");
|
internal static SolidColorBrush Info { get; } = Brush("#0078D4");
|
||||||
|
|
||||||
|
// Hover-specific brushes — updated by SetPalette so they track theme changes.
|
||||||
|
internal static SolidColorBrush HoverSurface { get; } = Brush("#EBEBEB");
|
||||||
|
internal static SolidColorBrush HoverAccentSoft { get; } = Brush("#D0EAFF");
|
||||||
|
internal static SolidColorBrush PrimaryHover { get; } = Brush("#005AA3");
|
||||||
|
internal static SolidColorBrush PrimaryPressed { get; } = Brush("#004F8E");
|
||||||
|
internal static SolidColorBrush Transparent { get; } = Brush("#00FFFFFF");
|
||||||
|
|
||||||
internal static void SetPalette(bool dark)
|
internal static void SetPalette(bool dark)
|
||||||
{
|
{
|
||||||
SetBrush(AppBackground, dark ? "#202020" : "#F7F7F7");
|
SetBrush(AppBackground, dark ? "#202020" : "#F7F7F7");
|
||||||
@@ -41,6 +48,12 @@ internal static class ModernUi
|
|||||||
SetBrush(Bronze, dark ? "#F7B189" : "#CA5010");
|
SetBrush(Bronze, dark ? "#F7B189" : "#CA5010");
|
||||||
SetBrush(Danger, dark ? "#F1707B" : "#C42B1C");
|
SetBrush(Danger, dark ? "#F1707B" : "#C42B1C");
|
||||||
SetBrush(Info, dark ? "#60CDFF" : "#0078D4");
|
SetBrush(Info, dark ? "#60CDFF" : "#0078D4");
|
||||||
|
// Hover brushes
|
||||||
|
SetBrush(HoverSurface, dark ? "#3A3A3A" : "#EBEBEB");
|
||||||
|
SetBrush(HoverAccentSoft, dark ? "#0D4F78" : "#D0EAFF");
|
||||||
|
SetBrush(PrimaryHover, dark ? "#4BB8F0" : "#005AA3");
|
||||||
|
SetBrush(PrimaryPressed, dark ? "#38A8E5" : "#004F8E");
|
||||||
|
// Transparent is always the same — no update needed
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static SolidColorBrush Brush(string hex)
|
internal static SolidColorBrush Brush(string hex)
|
||||||
@@ -138,16 +151,40 @@ internal static class ModernUi
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Overrides the WinUI 3 Button ControlTemplate's PointerOver/Pressed theme resource keys
|
||||||
|
/// at the instance level, so VSM animations still run but with the correct custom colors.
|
||||||
|
/// Call this after constructing a Button whose Background/BorderBrush were set manually.
|
||||||
|
/// </summary>
|
||||||
|
internal static void ApplyHoverResources(
|
||||||
|
Button button,
|
||||||
|
Brush hoverBackground,
|
||||||
|
Brush hoverBorder,
|
||||||
|
Brush? pressedBackground = null,
|
||||||
|
Brush? pressedBorder = null,
|
||||||
|
Brush? hoverForeground = null)
|
||||||
|
{
|
||||||
|
button.Resources["ButtonBackgroundPointerOver"] = hoverBackground;
|
||||||
|
button.Resources["ButtonBorderBrushPointerOver"] = hoverBorder;
|
||||||
|
button.Resources["ButtonBackgroundPressed"] = pressedBackground ?? hoverBackground;
|
||||||
|
button.Resources["ButtonBorderBrushPressed"] = pressedBorder ?? hoverBorder;
|
||||||
|
if (hoverForeground is not null)
|
||||||
|
{
|
||||||
|
button.Resources["ButtonForegroundPointerOver"] = hoverForeground;
|
||||||
|
button.Resources["ButtonForegroundPressed"] = hoverForeground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal static Button IconButton(string glyph, string tooltip, Action? click = null)
|
internal static Button IconButton(string glyph, string tooltip, Action? click = null)
|
||||||
{
|
{
|
||||||
var button = new Button
|
var button = new Button
|
||||||
{
|
{
|
||||||
Width = 40,
|
Width = 36,
|
||||||
Height = 36,
|
Height = 36,
|
||||||
Padding = new Thickness(0),
|
Padding = new Thickness(0),
|
||||||
CornerRadius = new CornerRadius(6),
|
CornerRadius = new CornerRadius(6),
|
||||||
Background = Brush("#00FFFFFF"),
|
Background = Transparent,
|
||||||
BorderBrush = Brush("#00FFFFFF"),
|
BorderBrush = Transparent,
|
||||||
Content = new FontIcon
|
Content = new FontIcon
|
||||||
{
|
{
|
||||||
Glyph = glyph,
|
Glyph = glyph,
|
||||||
@@ -155,6 +192,7 @@ internal static class ModernUi
|
|||||||
Foreground = TextSecondary
|
Foreground = TextSecondary
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
ApplyHoverResources(button, SurfaceAlt, Stroke, HoverSurface, StrokeStrong);
|
||||||
ToolTipService.SetToolTip(button, tooltip);
|
ToolTipService.SetToolTip(button, tooltip);
|
||||||
AutomationProperties.SetName(button, tooltip);
|
AutomationProperties.SetName(button, tooltip);
|
||||||
if (click is not null)
|
if (click is not null)
|
||||||
@@ -167,6 +205,7 @@ internal static class ModernUi
|
|||||||
|
|
||||||
internal static Button PillButton(string text, string glyph, Action? click = null, bool primary = false)
|
internal static Button PillButton(string text, string glyph, Action? click = null, bool primary = false)
|
||||||
{
|
{
|
||||||
|
var fgBrush = primary ? Brush("#FFFFFFFF") : TextPrimary;
|
||||||
var panel = new StackPanel
|
var panel = new StackPanel
|
||||||
{
|
{
|
||||||
Orientation = Orientation.Horizontal,
|
Orientation = Orientation.Horizontal,
|
||||||
@@ -175,8 +214,8 @@ internal static class ModernUi
|
|||||||
VerticalAlignment = VerticalAlignment.Center,
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
Children =
|
Children =
|
||||||
{
|
{
|
||||||
new FontIcon { Glyph = glyph, FontSize = 15 },
|
new FontIcon { Glyph = glyph, FontSize = 15, Foreground = fgBrush },
|
||||||
Text(text, 14, FontWeights.SemiBold, primary ? Brush("#FFFFFFFF") : TextPrimary)
|
Text(text, 14, FontWeights.SemiBold, fgBrush)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -184,12 +223,22 @@ internal static class ModernUi
|
|||||||
{
|
{
|
||||||
Padding = new Thickness(14, 8, 14, 8),
|
Padding = new Thickness(14, 8, 14, 8),
|
||||||
CornerRadius = new CornerRadius(6),
|
CornerRadius = new CornerRadius(6),
|
||||||
Background = primary ? Accent : Brush("#00FFFFFF"),
|
Background = primary ? Accent : Transparent,
|
||||||
Foreground = primary ? Brush("#FFFFFFFF") : TextPrimary,
|
Foreground = fgBrush,
|
||||||
BorderBrush = primary ? Accent : Stroke,
|
BorderBrush = primary ? Accent : Stroke,
|
||||||
BorderThickness = new Thickness(1),
|
BorderThickness = new Thickness(1),
|
||||||
Content = panel
|
Content = panel
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (primary)
|
||||||
|
{
|
||||||
|
ApplyHoverResources(button, PrimaryHover, PrimaryHover, PrimaryPressed, PrimaryPressed, Brush("#FFFFFFFF"));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ApplyHoverResources(button, SurfaceAlt, StrokeStrong, HoverSurface, StrokeStrong);
|
||||||
|
}
|
||||||
|
|
||||||
ToolTipService.SetToolTip(button, text);
|
ToolTipService.SetToolTip(button, text);
|
||||||
AutomationProperties.SetName(button, text);
|
AutomationProperties.SetName(button, text);
|
||||||
if (click is not null)
|
if (click is not null)
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ public sealed class HomePage : Page
|
|||||||
BorderThickness = new Thickness(1),
|
BorderThickness = new Thickness(1),
|
||||||
Content = new Border { Padding = new Thickness(16, 12, 16, 12), Child = grid }
|
Content = new Border { Padding = new Thickness(16, 12, 16, 12), Child = grid }
|
||||||
};
|
};
|
||||||
|
ModernUi.ApplyHoverResources(button, ModernUi.SurfaceAlt, ModernUi.StrokeStrong, ModernUi.HoverSurface, ModernUi.StrokeStrong);
|
||||||
button.Click += async (_, _) => await ShowAnnouncementDialogAsync();
|
button.Click += async (_, _) => await ShowAnnouncementDialogAsync();
|
||||||
ToolTipService.SetToolTip(button, AppLocalizer.T("查看完整公告", "View full announcement"));
|
ToolTipService.SetToolTip(button, AppLocalizer.T("查看完整公告", "View full announcement"));
|
||||||
return button;
|
return button;
|
||||||
@@ -663,6 +664,7 @@ public sealed class HomePage : Page
|
|||||||
BorderThickness = new Thickness(1),
|
BorderThickness = new Thickness(1),
|
||||||
Content = grid
|
Content = grid
|
||||||
};
|
};
|
||||||
|
ModernUi.ApplyHoverResources(button, ModernUi.HoverSurface, ModernUi.StrokeStrong, ModernUi.Stroke, ModernUi.StrokeStrong);
|
||||||
ToolTipService.SetToolTip(button, $"{ToolText.Name(module)}\n{ToolText.Description(module)}");
|
ToolTipService.SetToolTip(button, $"{ToolText.Name(module)}\n{ToolText.Description(module)}");
|
||||||
button.Click += (_, _) => _openTool(module);
|
button.Click += (_, _) => _openTool(module);
|
||||||
return button;
|
return button;
|
||||||
@@ -707,6 +709,7 @@ public sealed class HomePage : Page
|
|||||||
BorderThickness = new Thickness(1),
|
BorderThickness = new Thickness(1),
|
||||||
Content = grid
|
Content = grid
|
||||||
};
|
};
|
||||||
|
ModernUi.ApplyHoverResources(button, ModernUi.HoverSurface, ModernUi.StrokeStrong, ModernUi.Stroke, ModernUi.StrokeStrong);
|
||||||
ToolTipService.SetToolTip(button, label);
|
ToolTipService.SetToolTip(button, label);
|
||||||
button.Click += (_, _) => _openToolbox(label);
|
button.Click += (_, _) => _openToolbox(label);
|
||||||
return button;
|
return button;
|
||||||
|
|||||||
@@ -293,13 +293,14 @@ public sealed class SettingsPage : Page
|
|||||||
},
|
},
|
||||||
new StackPanel
|
new StackPanel
|
||||||
{
|
{
|
||||||
Spacing = 10,
|
Spacing = 8,
|
||||||
Children =
|
Children =
|
||||||
{
|
{
|
||||||
_cpuStrip,
|
_cpuStrip,
|
||||||
_memoryStrip,
|
_memoryStrip,
|
||||||
BuildControlStatusLine("\uE950", AppLocalizer.T("处理器", "Processor"), _controlProcessorStatus),
|
new Border { Height = 1, Background = ModernUi.Stroke, Margin = new Thickness(0, 2, 0, 2) },
|
||||||
BuildControlStatusLine("\uE823", AppLocalizer.T("运行", "Uptime"), _controlUptimeStatus)
|
BuildControlStatusLine("", AppLocalizer.T("处理器", "Processor"), _controlProcessorStatus),
|
||||||
|
BuildControlStatusLine("", AppLocalizer.T("运行", "Uptime"), _controlUptimeStatus)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -535,12 +536,13 @@ public sealed class SettingsPage : Page
|
|||||||
MinHeight = 38,
|
MinHeight = 38,
|
||||||
Padding = new Thickness(7, 4, 7, 4),
|
Padding = new Thickness(7, 4, 7, 4),
|
||||||
CornerRadius = new CornerRadius(8),
|
CornerRadius = new CornerRadius(8),
|
||||||
Background = ModernUi.Brush("#00FFFFFF"),
|
Background = ModernUi.Transparent,
|
||||||
BorderBrush = ModernUi.Brush("#00FFFFFF"),
|
BorderBrush = ModernUi.Transparent,
|
||||||
BorderThickness = new Thickness(1),
|
BorderThickness = new Thickness(1),
|
||||||
Content = grid,
|
Content = grid,
|
||||||
Tag = key
|
Tag = key
|
||||||
};
|
};
|
||||||
|
ModernUi.ApplyHoverResources(button, ModernUi.SurfaceAlt, ModernUi.Stroke, ModernUi.HoverSurface, ModernUi.StrokeStrong);
|
||||||
button.Click += (_, _) => ShowSettingsPage(key);
|
button.Click += (_, _) => ShowSettingsPage(key);
|
||||||
AutomationProperties.SetName(button, title);
|
AutomationProperties.SetName(button, title);
|
||||||
return button;
|
return button;
|
||||||
@@ -563,8 +565,15 @@ public sealed class SettingsPage : Page
|
|||||||
foreach (var pair in _settingsPageButtons)
|
foreach (var pair in _settingsPageButtons)
|
||||||
{
|
{
|
||||||
var selected = string.Equals(pair.Key, _activeSettingsPage, StringComparison.OrdinalIgnoreCase);
|
var selected = string.Equals(pair.Key, _activeSettingsPage, StringComparison.OrdinalIgnoreCase);
|
||||||
pair.Value.Background = selected ? ModernUi.AccentSoft : ModernUi.Brush("#00FFFFFF");
|
pair.Value.Background = selected ? ModernUi.AccentSoft : ModernUi.Transparent;
|
||||||
pair.Value.BorderBrush = selected ? ModernUi.Accent : ModernUi.Brush("#00FFFFFF");
|
pair.Value.BorderBrush = selected ? ModernUi.Accent : ModernUi.Transparent;
|
||||||
|
// Keep hover resources in sync with selected state
|
||||||
|
ModernUi.ApplyHoverResources(
|
||||||
|
pair.Value,
|
||||||
|
selected ? ModernUi.HoverAccentSoft : ModernUi.SurfaceAlt,
|
||||||
|
selected ? ModernUi.Accent : ModernUi.Stroke,
|
||||||
|
selected ? ModernUi.HoverAccentSoft : ModernUi.HoverSurface,
|
||||||
|
selected ? ModernUi.Accent : ModernUi.StrokeStrong);
|
||||||
if (pair.Value.Content is Grid grid && grid.Children.FirstOrDefault() is Border iconTile)
|
if (pair.Value.Content is Grid grid && grid.Children.FirstOrDefault() is Border iconTile)
|
||||||
{
|
{
|
||||||
iconTile.Background = selected ? ModernUi.AccentSoft : ModernUi.SurfaceAlt;
|
iconTile.Background = selected ? ModernUi.AccentSoft : ModernUi.SurfaceAlt;
|
||||||
@@ -902,19 +911,27 @@ public sealed class SettingsPage : Page
|
|||||||
private UIElement BuildActionRow(string glyph, string title, UIElement value, Func<Task> action)
|
private UIElement BuildActionRow(string glyph, string title, UIElement value, Func<Task> action)
|
||||||
{
|
{
|
||||||
var row = BaseRow(glyph, title, value);
|
var row = BaseRow(glyph, title, value);
|
||||||
|
var chevron = new FontIcon { Glyph = "", FontSize = 14, Foreground = ModernUi.TextSecondary, VerticalAlignment = VerticalAlignment.Center };
|
||||||
|
row.Children.Add(chevron);
|
||||||
|
Grid.SetColumn(chevron, 2);
|
||||||
|
|
||||||
var tooltip = AppLocalizer.T($"打开 {title}", $"Open {title}");
|
var tooltip = AppLocalizer.T($"打开 {title}", $"Open {title}");
|
||||||
var button = ModernUi.IconButton("\uE974", tooltip, async () => await action());
|
var rowButton = new Button
|
||||||
button.Width = 42;
|
{
|
||||||
button.Height = 38;
|
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||||
button.MinWidth = 42;
|
HorizontalContentAlignment = HorizontalAlignment.Stretch,
|
||||||
button.MinHeight = 38;
|
Padding = new Thickness(0),
|
||||||
button.VerticalAlignment = VerticalAlignment.Center;
|
CornerRadius = new CornerRadius(6),
|
||||||
button.HorizontalAlignment = HorizontalAlignment.Right;
|
Background = ModernUi.Transparent,
|
||||||
ToolTipService.SetToolTip(button, tooltip);
|
BorderBrush = ModernUi.Transparent,
|
||||||
AutomationProperties.SetName(button, tooltip);
|
BorderThickness = new Thickness(0),
|
||||||
row.Children.Add(button);
|
Content = row
|
||||||
Grid.SetColumn(button, 2);
|
};
|
||||||
return row;
|
ModernUi.ApplyHoverResources(rowButton, ModernUi.SurfaceAlt, ModernUi.Transparent, ModernUi.HoverSurface, ModernUi.Transparent);
|
||||||
|
rowButton.Click += async (_, _) => await action();
|
||||||
|
ToolTipService.SetToolTip(rowButton, tooltip);
|
||||||
|
AutomationProperties.SetName(rowButton, tooltip);
|
||||||
|
return rowButton;
|
||||||
}
|
}
|
||||||
|
|
||||||
private UIElement BuildToggleRow(string glyph, string title, string subtitle, ToggleSwitch toggle)
|
private UIElement BuildToggleRow(string glyph, string title, string subtitle, ToggleSwitch toggle)
|
||||||
|
|||||||
@@ -342,13 +342,13 @@ public sealed class ToolboxPage : Page
|
|||||||
|
|
||||||
var grid = new Grid { ColumnSpacing = 20, RowSpacing = 12 };
|
var grid = new Grid { ColumnSpacing = 20, RowSpacing = 12 };
|
||||||
grid.ColumnDefinitions.Add(new ColumnDefinition());
|
grid.ColumnDefinitions.Add(new ColumnDefinition());
|
||||||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(0.72, GridUnitType.Star) });
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(0.85, GridUnitType.Star) });
|
||||||
grid.Children.Add(title);
|
grid.Children.Add(title);
|
||||||
Grid.SetColumn(right, 1);
|
Grid.SetColumn(right, 1);
|
||||||
grid.Children.Add(right);
|
grid.Children.Add(right);
|
||||||
grid.SizeChanged += (_, e) =>
|
grid.SizeChanged += (_, e) =>
|
||||||
{
|
{
|
||||||
var narrow = e.NewSize.Width < 940;
|
var narrow = e.NewSize.Width < 860;
|
||||||
grid.ColumnDefinitions.Clear();
|
grid.ColumnDefinitions.Clear();
|
||||||
grid.RowDefinitions.Clear();
|
grid.RowDefinitions.Clear();
|
||||||
grid.ColumnDefinitions.Add(new ColumnDefinition());
|
grid.ColumnDefinitions.Add(new ColumnDefinition());
|
||||||
@@ -361,7 +361,7 @@ public sealed class ToolboxPage : Page
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(0.72, GridUnitType.Star) });
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(0.85, GridUnitType.Star) });
|
||||||
Grid.SetColumn(right, 1);
|
Grid.SetColumn(right, 1);
|
||||||
Grid.SetRow(right, 0);
|
Grid.SetRow(right, 0);
|
||||||
}
|
}
|
||||||
@@ -708,6 +708,12 @@ public sealed class ToolboxPage : Page
|
|||||||
count,
|
count,
|
||||||
ToolText.CategoryGlyph(category))
|
ToolText.CategoryGlyph(category))
|
||||||
};
|
};
|
||||||
|
ModernUi.ApplyHoverResources(
|
||||||
|
button,
|
||||||
|
selected ? ModernUi.HoverAccentSoft : ModernUi.HoverSurface,
|
||||||
|
selected ? ModernUi.Accent : ModernUi.StrokeStrong,
|
||||||
|
selected ? ModernUi.HoverAccentSoft : ModernUi.SurfaceAlt,
|
||||||
|
selected ? ModernUi.Accent : ModernUi.StrokeStrong);
|
||||||
|
|
||||||
button.Click += (_, _) =>
|
button.Click += (_, _) =>
|
||||||
{
|
{
|
||||||
@@ -780,6 +786,12 @@ public sealed class ToolboxPage : Page
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
ModernUi.ApplyHoverResources(
|
||||||
|
button,
|
||||||
|
selected ? ModernUi.HoverAccentSoft : ModernUi.HoverSurface,
|
||||||
|
selected ? ModernUi.Accent : ModernUi.StrokeStrong,
|
||||||
|
selected ? ModernUi.HoverAccentSoft : ModernUi.SurfaceAlt,
|
||||||
|
selected ? ModernUi.Accent : ModernUi.StrokeStrong);
|
||||||
button.Click += async (_, _) =>
|
button.Click += async (_, _) =>
|
||||||
{
|
{
|
||||||
_selectedScope = scope;
|
_selectedScope = scope;
|
||||||
@@ -942,11 +954,13 @@ public sealed class ToolboxPage : Page
|
|||||||
HorizontalContentAlignment = HorizontalAlignment.Stretch,
|
HorizontalContentAlignment = HorizontalAlignment.Stretch,
|
||||||
VerticalContentAlignment = VerticalAlignment.Stretch,
|
VerticalContentAlignment = VerticalAlignment.Stretch,
|
||||||
CornerRadius = new CornerRadius(8),
|
CornerRadius = new CornerRadius(8),
|
||||||
Background = ModernUi.Brush("#00FFFFFF"),
|
Background = ModernUi.Transparent,
|
||||||
BorderBrush = ModernUi.Brush("#00FFFFFF"),
|
BorderBrush = ModernUi.Transparent,
|
||||||
BorderThickness = new Thickness(0),
|
BorderThickness = new Thickness(0),
|
||||||
ContextFlyout = BuildToolFlyout(module, favorite)
|
ContextFlyout = BuildToolFlyout(module, favorite)
|
||||||
};
|
};
|
||||||
|
// Suppress the built-in template hover overlay on the transparent inner button
|
||||||
|
ModernUi.ApplyHoverResources(button, ModernUi.Transparent, ModernUi.Transparent, ModernUi.Transparent, ModernUi.Transparent);
|
||||||
grid.IsHitTestVisible = false;
|
grid.IsHitTestVisible = false;
|
||||||
ToolTipService.SetToolTip(button, $"{viewModel.Name}\n{viewModel.Description}");
|
ToolTipService.SetToolTip(button, $"{viewModel.Name}\n{viewModel.Description}");
|
||||||
AutomationProperties.SetName(button, viewModel.Name);
|
AutomationProperties.SetName(button, viewModel.Name);
|
||||||
@@ -958,14 +972,20 @@ public sealed class ToolboxPage : Page
|
|||||||
favoriteButton.VerticalAlignment = VerticalAlignment.Top;
|
favoriteButton.VerticalAlignment = VerticalAlignment.Top;
|
||||||
favoriteButton.Margin = new Thickness(0, 12, 12, 0);
|
favoriteButton.Margin = new Thickness(0, 12, 12, 0);
|
||||||
Canvas.SetZIndex(favoriteButton, 2);
|
Canvas.SetZIndex(favoriteButton, 2);
|
||||||
return new Border
|
|
||||||
|
var normalBg = ModernUi.Surface;
|
||||||
|
var normalBorder = favorite ? ModernUi.Bronze : IsRisky(module) ? ModernUi.Danger : ModernUi.Stroke;
|
||||||
|
var hoverBg = ModernUi.HoverSurface;
|
||||||
|
var hoverBorder = favorite ? ModernUi.Bronze : IsRisky(module) ? ModernUi.Danger : ModernUi.StrokeStrong;
|
||||||
|
|
||||||
|
var outerBorder = new Border
|
||||||
{
|
{
|
||||||
Width = buttonWidth,
|
Width = buttonWidth,
|
||||||
Height = compact ? 170 : 236,
|
Height = compact ? 170 : 236,
|
||||||
Margin = new Thickness(7),
|
Margin = new Thickness(7),
|
||||||
CornerRadius = new CornerRadius(8),
|
CornerRadius = new CornerRadius(8),
|
||||||
Background = ModernUi.Surface,
|
Background = normalBg,
|
||||||
BorderBrush = favorite ? ModernUi.Bronze : IsRisky(module) ? ModernUi.Danger : ModernUi.Stroke,
|
BorderBrush = normalBorder,
|
||||||
BorderThickness = new Thickness(1),
|
BorderThickness = new Thickness(1),
|
||||||
Child = new Grid
|
Child = new Grid
|
||||||
{
|
{
|
||||||
@@ -973,6 +993,17 @@ public sealed class ToolboxPage : Page
|
|||||||
},
|
},
|
||||||
ContextFlyout = BuildToolFlyout(module, favorite)
|
ContextFlyout = BuildToolFlyout(module, favorite)
|
||||||
};
|
};
|
||||||
|
outerBorder.PointerEntered += (_, _) =>
|
||||||
|
{
|
||||||
|
outerBorder.Background = hoverBg;
|
||||||
|
outerBorder.BorderBrush = hoverBorder;
|
||||||
|
};
|
||||||
|
outerBorder.PointerExited += (_, _) =>
|
||||||
|
{
|
||||||
|
outerBorder.Background = normalBg;
|
||||||
|
outerBorder.BorderBrush = normalBorder;
|
||||||
|
};
|
||||||
|
return outerBorder;
|
||||||
}
|
}
|
||||||
|
|
||||||
private MenuFlyout BuildToolFlyout(IToolModule module, bool favorite)
|
private MenuFlyout BuildToolFlyout(IToolModule module, bool favorite)
|
||||||
|
|||||||
@@ -206,6 +206,12 @@ public abstract partial class AdaptiveToolPage
|
|||||||
|
|
||||||
private Border BuildCompactGalleryInputCard(IToolModule module)
|
private Border BuildCompactGalleryInputCard(IToolModule module)
|
||||||
{
|
{
|
||||||
|
// Auto-load tools with no user input: collapse to a minimal reload card
|
||||||
|
if (_spec.AutoRunOnOpen && _spec.PrimaryInput is ToolPrimaryInputKind.None)
|
||||||
|
{
|
||||||
|
return BuildAutoLoadInputCard(module);
|
||||||
|
}
|
||||||
|
|
||||||
var panel = new StackPanel { Spacing = _spec.PageDensity == ToolPageDensity.Compact ? 10 : 14 };
|
var panel = new StackPanel { Spacing = _spec.PageDensity == ToolPageDensity.Compact ? 10 : 14 };
|
||||||
panel.Children.Add(BuildGallerySectionHeader(
|
panel.Children.Add(BuildGallerySectionHeader(
|
||||||
AppLocalizer.T("输入", "Input"),
|
AppLocalizer.T("输入", "Input"),
|
||||||
@@ -660,4 +666,59 @@ public abstract partial class AdaptiveToolPage
|
|||||||
_ => AppLocalizer.T("文本", "Text")
|
_ => AppLocalizer.T("文本", "Text")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal input card for auto-load tools that have no user-supplied input.
|
||||||
|
/// Shows only the tool description, a reload button, and a cancel button.
|
||||||
|
/// </summary>
|
||||||
|
private Border BuildAutoLoadInputCard(IToolModule module)
|
||||||
|
{
|
||||||
|
var reloadButton = ModernUi.PillButton(
|
||||||
|
AppLocalizer.T("重新加载", "Reload"),
|
||||||
|
"",
|
||||||
|
async () => await RunCurrentToolAsync(),
|
||||||
|
primary: true);
|
||||||
|
|
||||||
|
var cancelButton = ModernUi.PillButton(
|
||||||
|
AppLocalizer.T("取消", "Cancel"),
|
||||||
|
"",
|
||||||
|
() => CancelRunningTool());
|
||||||
|
|
||||||
|
var actions = new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Orientation.Horizontal,
|
||||||
|
Spacing = 10,
|
||||||
|
Children = { reloadButton, cancelButton }
|
||||||
|
};
|
||||||
|
|
||||||
|
var grid = new Grid { ColumnSpacing = 12 };
|
||||||
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||||
|
grid.ColumnDefinitions.Add(new ColumnDefinition());
|
||||||
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||||
|
|
||||||
|
grid.Children.Add(ModernUi.IconTile(module.Metadata.IconGlyph, 38, ModernUi.AccentSoft, ModernUi.Accent, 17));
|
||||||
|
|
||||||
|
var text = new StackPanel
|
||||||
|
{
|
||||||
|
Spacing = 2,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
ModernUi.Text(AppLocalizer.T("数据源", "Data source"), 11, FontWeights.SemiBold, ModernUi.Accent, maxLines: 1),
|
||||||
|
ModernUi.Text(AppLocalizer.T("自动加载", "Auto load"), 14, FontWeights.SemiBold, maxLines: 1),
|
||||||
|
ModernUi.Text(CompactInputDescription(module), 12, foreground: ModernUi.TextSecondary, maxLines: 2)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Grid.SetColumn(text, 1);
|
||||||
|
grid.Children.Add(text);
|
||||||
|
|
||||||
|
Grid.SetColumn(_progressRing, 2);
|
||||||
|
grid.Children.Add(_progressRing);
|
||||||
|
|
||||||
|
return ModernUi.Card(new StackPanel
|
||||||
|
{
|
||||||
|
Spacing = 12,
|
||||||
|
Children = { grid, actions }
|
||||||
|
}, new Thickness(14), radius: 8);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,18 @@ public abstract partial class AdaptiveToolPage
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ranked list / news / hotboard tools get a dedicated renderer before generic JSON
|
||||||
|
var rankedListToolIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"movie_box_office", "baidu_hot", "bili_hot", "zhihu_hot", "weibo_hot",
|
||||||
|
"cctv_news", "tech_news", "football_news", "history_today",
|
||||||
|
"earthquake_info", "gold_price", "oil_price", "hotboard"
|
||||||
|
};
|
||||||
|
if (rankedListToolIds.Contains(module.Id) || _spec.Result == ToolResultKind.RankedList || _spec.Result == ToolResultKind.NewsCards)
|
||||||
|
{
|
||||||
|
if (TryRenderRankedList(module, output, lines)) return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (TryRenderJson(output, module))
|
if (TryRenderJson(output, module))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
@@ -637,4 +649,194 @@ public abstract partial class AdaptiveToolPage
|
|||||||
_ => "结果摘要"
|
_ => "结果摘要"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders ranked/hotboard/news results with a dedicated rank-badge card layout.
|
||||||
|
/// Handles both JSON array output and numbered-text-line output (e.g. "1. Title · meta").
|
||||||
|
/// </summary>
|
||||||
|
private bool TryRenderRankedList(IToolModule module, string output, IReadOnlyList<string> lines)
|
||||||
|
{
|
||||||
|
// Try JSON array first
|
||||||
|
var trimmed = output.TrimStart();
|
||||||
|
if (trimmed.StartsWith('['))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(output);
|
||||||
|
if (document.RootElement.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = document.RootElement.EnumerateArray().Take(50).ToList();
|
||||||
|
if (items.Count == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_resultCards.Children.Add(BuildInfoGrid([
|
||||||
|
(AppLocalizer.T("条目数", "Items"), items.Count.ToString()),
|
||||||
|
(AppLocalizer.T("类型", "Kind"), AppLocalizer.T("榜单 / 资讯", "Ranked list"))
|
||||||
|
]));
|
||||||
|
|
||||||
|
for (var index = 0; index < items.Count; index++)
|
||||||
|
{
|
||||||
|
var item = items[index];
|
||||||
|
var rank = index + 1;
|
||||||
|
var title = FirstJsonString(item, "title", "name", "text", "subject", "headline") ?? $"#{rank}";
|
||||||
|
var meta = FirstJsonString(item, "score", "hot", "value", "heat", "count", "view", "desc", "description", "meta", "category", "type");
|
||||||
|
var url = FirstJsonString(item, "url", "link", "href");
|
||||||
|
|
||||||
|
// Some APIs return an explicit rank field
|
||||||
|
if (item.TryGetProperty("rank", out var rankElem) && rankElem.TryGetInt32(out var explicitRank))
|
||||||
|
{
|
||||||
|
rank = explicitRank;
|
||||||
|
}
|
||||||
|
|
||||||
|
_resultCards.Children.Add(BuildRankedItemCard(rank, title, meta, url));
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Fall through to text parsing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try numbered text lines: "1. Title · meta", "1、Title", "#1 Title"
|
||||||
|
var rankedLines = lines
|
||||||
|
.Select(ParseRankedLine)
|
||||||
|
.Where(item => item.HasValue)
|
||||||
|
.Select(item => item!.Value)
|
||||||
|
.Take(50)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (rankedLines.Count < 2)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_resultCards.Children.Add(BuildInfoGrid([
|
||||||
|
(AppLocalizer.T("条目数", "Items"), rankedLines.Count.ToString()),
|
||||||
|
(AppLocalizer.T("来源", "Source"), AppLocalizer.T("实时榜单", "Live ranking"))
|
||||||
|
]));
|
||||||
|
|
||||||
|
foreach (var item in rankedLines)
|
||||||
|
{
|
||||||
|
_resultCards.Children.Add(BuildRankedItemCard(item.Rank, item.Title, item.Meta, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (int Rank, string Title, string? Meta)? ParseRankedLine(string line)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(line)) return null;
|
||||||
|
|
||||||
|
// Formats: "1. Title · meta", "1. Title", "#1 Title", "1、Title"
|
||||||
|
var match = Regex.Match(line.Trim(), @"^(?:#?(\d+)[.、\s]+|(\d+)\.\s*)(.+)$");
|
||||||
|
if (!match.Success) return null;
|
||||||
|
|
||||||
|
var rankStr = match.Groups[1].Value.Length > 0 ? match.Groups[1].Value : match.Groups[2].Value;
|
||||||
|
if (!int.TryParse(rankStr, out var rank)) return null;
|
||||||
|
|
||||||
|
var rest = match.Groups[3].Value.Trim();
|
||||||
|
string? meta = null;
|
||||||
|
string title = rest;
|
||||||
|
|
||||||
|
// Split "Title · meta" or "Title - meta" or "Title | meta"
|
||||||
|
var sep = rest.IndexOfAny([' ']);
|
||||||
|
var dotSep = rest.IndexOf(" · ", StringComparison.Ordinal);
|
||||||
|
var dashSep = rest.IndexOf(" - ", StringComparison.Ordinal);
|
||||||
|
var pipeSep = rest.IndexOf(" | ", StringComparison.Ordinal);
|
||||||
|
var tabSep = rest.IndexOf('\t');
|
||||||
|
|
||||||
|
var splitAt = new[] { dotSep, dashSep, pipeSep, tabSep }.Where(pos => pos > 0).OrderBy(pos => pos).FirstOrDefault(-1);
|
||||||
|
if (splitAt > 0)
|
||||||
|
{
|
||||||
|
title = rest[..splitAt].Trim();
|
||||||
|
meta = rest[(splitAt + 1)..].TrimStart(' ', '-', '·', '|', '\t').Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(title) ? null : (rank, title, string.IsNullOrWhiteSpace(meta) ? null : meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FirstJsonString(JsonElement element, params string[] keys)
|
||||||
|
{
|
||||||
|
foreach (var key in keys)
|
||||||
|
{
|
||||||
|
if (element.TryGetProperty(key, out var prop) && prop.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
var value = prop.GetString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(value)) return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also try camelCase variants with number suffixes
|
||||||
|
if (element.TryGetProperty(key.ToLowerInvariant(), out var lowerProp) && lowerProp.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
var value = lowerProp.GetString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(value)) return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Border BuildRankedItemCard(int rank, string title, string? meta, string? url)
|
||||||
|
{
|
||||||
|
// Rank badge color: gold/silver/bronze for top 3, default for rest
|
||||||
|
var rankFg = rank switch
|
||||||
|
{
|
||||||
|
1 => ModernUi.Brush("#CA5010"), // gold-ish / bronze
|
||||||
|
2 => ModernUi.TextSecondary,
|
||||||
|
3 => ModernUi.TextSecondary,
|
||||||
|
_ => ModernUi.TextSecondary
|
||||||
|
};
|
||||||
|
var rankBg = rank switch
|
||||||
|
{
|
||||||
|
1 => ModernUi.AccentSoft,
|
||||||
|
2 => ModernUi.SurfaceAlt,
|
||||||
|
3 => ModernUi.SurfaceAlt,
|
||||||
|
_ => ModernUi.SurfaceAlt
|
||||||
|
};
|
||||||
|
|
||||||
|
var rankBadge = ModernUi.Badge(rank.ToString(), rank == 1 ? ModernUi.Accent : rankFg, rankBg);
|
||||||
|
rankBadge.MinWidth = 32;
|
||||||
|
rankBadge.HorizontalAlignment = HorizontalAlignment.Center;
|
||||||
|
|
||||||
|
var text = new StackPanel
|
||||||
|
{
|
||||||
|
Spacing = 2,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
ModernUi.Text(ModernUi.Ellipsize(title, 100), 14, FontWeights.SemiBold, maxLines: 1),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(meta))
|
||||||
|
{
|
||||||
|
text.Children.Add(ModernUi.Text(ModernUi.Ellipsize(meta, 80), 12, foreground: ModernUi.TextSecondary, maxLines: 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
var row = new Grid { ColumnSpacing = 12, MinHeight = 40 };
|
||||||
|
row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(36) });
|
||||||
|
row.ColumnDefinitions.Add(new ColumnDefinition());
|
||||||
|
|
||||||
|
row.Children.Add(rankBadge);
|
||||||
|
Grid.SetColumn(text, 1);
|
||||||
|
row.Children.Add(text);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(url))
|
||||||
|
{
|
||||||
|
row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||||
|
var linkButton = ModernUi.IconButton("", AppLocalizer.T("打开链接", "Open link"),
|
||||||
|
async () => await Windows.System.Launcher.LaunchUriAsync(new Uri(url!)));
|
||||||
|
Grid.SetColumn(linkButton, 2);
|
||||||
|
row.Children.Add(linkButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ModernUi.Card(row, new Thickness(10, 8, 10, 8), radius: 8, background: ModernUi.SurfaceAlt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -946,8 +946,8 @@ public abstract partial class AdaptiveToolPage : ToolPageBase
|
|||||||
var wrap = new VariableSizedWrapGrid
|
var wrap = new VariableSizedWrapGrid
|
||||||
{
|
{
|
||||||
Orientation = Orientation.Horizontal,
|
Orientation = Orientation.Horizontal,
|
||||||
ItemWidth = 190,
|
ItemWidth = 200,
|
||||||
ItemHeight = 74
|
ItemHeight = 60
|
||||||
};
|
};
|
||||||
wrap.Children.Add(_numberBox);
|
wrap.Children.Add(_numberBox);
|
||||||
if (_secondaryNumberBox.Visibility == Visibility.Visible)
|
if (_secondaryNumberBox.Visibility == Visibility.Visible)
|
||||||
|
|||||||
Reference in New Issue
Block a user