1use crate::{
2 AnalysisReport,
3 filesystem::{contained_file, replace, write_new},
4 resources::bounded_read,
5};
6use anyhow::{Result, ensure};
7use std::{
8 fs,
9 path::{Path, PathBuf},
10};
11
12pub fn refresh_report(directory: impl AsRef<Path>) -> Result<PathBuf> {
14 let directory = fs::canonicalize(directory)?;
15 let data = contained_file(&directory, Path::new("analysis.json"))?;
16 let report: AnalysisReport = serde_json::from_slice(&bounded_read(&data)?)?;
17 ensure!(
18 report.schema_version == 1,
19 "unsupported analysis schema version"
20 );
21 let html = render_html(&report)?;
22 let output = directory.join("report.html");
23 if fs::symlink_metadata(&output).is_ok() {
24 contained_file(&directory, Path::new("report.html"))?;
25 replace(&output, html.as_bytes())?;
26 } else {
27 write_new(&output, html.as_bytes())?;
28 }
29 Ok(output)
30}
31
32pub(crate) fn render_html(report: &AnalysisReport) -> Result<String> {
33 render_page(report, None)
34}
35
36pub(crate) fn render_page(report: &AnalysisReport, token: Option<&str>) -> Result<String> {
37 let payload = serde_json::to_string(&serde_json::json!({
38 "root": report.root,
39 "sessionToken": token,
40 "options": report.options,
41 "resources": report.resources,
42 "savings": report.potential_source_bytes_saved,
43 "diagnostics": report.inventory.diagnostics,
44 "excludedDirectories": report.inventory.excluded_directories,
45 }))?;
46 let safe = payload
49 .replace('<', "\\u003c")
50 .replace('>', "\\u003e")
51 .replace('&', "\\u0026")
52 .replace('\u{2028}', "\\u2028")
53 .replace('\u{2029}', "\\u2029");
54 Ok(page(&safe, token.is_some()).into_string())
55}
56
57use maud::{DOCTYPE, Markup, PreEscaped, html};
58
59fn page(data: &str, live: bool) -> Markup {
60 html! {
61 (DOCTYPE)
62 html lang="zh-CN" {
63 head {
64 meta charset="utf-8";
65 meta name="viewport" content="width=device-width, initial-scale=1";
66 title { "resopt · 资源分析" }
67 script { (PreEscaped("try{const t=localStorage.getItem('resopt-theme')||'system';document.documentElement.dataset.theme=t==='system'?(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'):t}catch{}")) }
68 style { (PreEscaped(include_str!("report.css"))) }
69 }
70 body {
71 header {
72 div.brand {
73 span.mark aria-hidden="true" { i {} i {} i {} i {} }
74 strong { "resopt" } span { "资源分析" }
75 }
76 div.header-meta {
77 label.sr-only for="theme" { "页面主题" }
78 select id="theme" aria-label="页面主题" {
79 option value="system" { "跟随系统" }
80 option value="light" { "浅色" }
81 option value="dark" { "深色" }
82 }
83 span.read-only { @if live { "本地服务 · 逐张确认应用" } @else { "离线报告 · 仅供审阅" } }
84 a href="analysis.json" target="_blank" rel="noopener" { "查看 JSON ↗" }
85 }
86 }
87 main {
88 (overview())
89 (toolbar())
90 div.workspace {
91 section.list-pane aria-label="资源清单" {
92 div.list-head aria-hidden="true" { span { "资源" } span { "原始体积" } span { "可节省" } }
93 div.results id="results" role="listbox" aria-label="资源列表" {}
94 div.pager {
95 span id="range" role="status" aria-live="polite" {}
96 div.pager-controls {
97 button.icon-button id="previous" aria-label="上一页" { "←" }
98 span id="page-number" {}
99 button.icon-button id="next" aria-label="下一页" { "→" }
100 }
101 }
102 }
103 aside.inspector id="inspector" aria-label="资源详情" {}
104 }
105 footer.footer {
106 span { "体积采用 KiB / MiB(1024 进制),悬停可查看精确字节数。" }
107 span { "仅统计源文件收益 · 不等于 App 包体收益" }
108 }
109 }
110 dialog id="apply-dialog" aria-labelledby="apply-title" {
111 h2 id="apply-title" { "确认优化图片" }
112 p id="apply-description" {}
113 p id="apply-note" { "原文件会备份,可在页面恢复。请先检查原尺寸候选的画质。" }
114 div.dialog-actions {
115 button id="apply-cancel" { "取消" }
116 button.primary id="apply-confirm" { "确认并应用" }
117 }
118 }
119 noscript { "请启用 JavaScript 查看筛选和图片对比,或打开同目录的 analysis.json。" }
120 script type="application/json" id="report-data" { (PreEscaped(data)) }
122 script { (PreEscaped(include_str!("report.js"))) }
123 }
124 }
125 }
126}
127
128fn overview() -> Markup {
129 html! {
130 section.summary aria-label="分析概览" {
131 @for (id, label) in [("total-count", "资源文件"), ("candidate-count", "有更小候选"), ("total-savings", "预估可节省")] {
132 div.stat {
133 div.stat-label { (label) }
134 div class={ "stat-value" @if id == "total-savings" { " accent" } } id=(id) { "—" }
135 }
136 }
137 p.summary-note id="scope-note" {}
138 }
139 }
140}
141
142fn toolbar() -> Markup {
143 html! {
144 section.toolbar aria-label="筛选资源" {
145 div.modes role="group" aria-label="资源范围" {
146 @for (mode, label) in [("candidates", "有候选"), ("images", "图片"), ("all", "全部")] {
147 button class={ "mode" @if mode == "candidates" { " active" } } data-mode=(mode) aria-pressed=(if mode == "candidates" { "true" } else { "false" }) {
148 (label) " " span id={ "mode-" (mode) } {}
149 }
150 }
151 }
152 div.search {
153 svg width="15" height="15" viewBox="0 0 20 20" fill="none" aria-hidden="true" {
154 circle cx="8.5" cy="8.5" r="5.5" stroke="currentColor" stroke-width="1.5" {}
155 path d="m13 13 4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" {}
156 }
157 label.sr-only for="search" { "搜索资源" }
158 input type="search" id="search" placeholder="搜索文件名或路径…" autocomplete="off";
159 }
160 label.sr-only for="format-filter" { "原始格式" }
161 select id="format-filter" { option value="all" { "全部格式" } }
162 label.sr-only for="sort" { "排序方式" }
163 select id="sort" {
164 option value="savings" { "节省量 ↓" }
165 option value="size" { "原始体积 ↓" }
166 option value="name" { "文件名 A–Z" }
167 }
168 }
169 }
170}