Skip to main content

resopt/
report.rs

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
12/// Refresh presentation only. Does not encode, change JSON, or touch artifacts.
13pub 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    // An inert JSON script still ends at a literal </script>. Escape HTML
47    // delimiters before embedding data; UI code uses textContent for filenames.
48    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                // JSON is escaped for the script context by render_page, not HTML-escaped.
121                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}