更新后端服务UI

客户端重构部分界面UI
This commit is contained in:
QWQLwToo
2026-06-30 11:45:52 +08:00
parent 7745e7a2d4
commit 1d0e862299
13 changed files with 942 additions and 121 deletions
@@ -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>
+9 -9
View File
@@ -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"
} }
+56 -7
View File
@@ -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)
+3
View File
@@ -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;
+36 -19
View File
@@ -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)
+39 -8
View File
@@ -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)