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(), &report.backend).into_string())
55}
56
57use maud::{DOCTYPE, Markup, PreEscaped, html};
58
59fn page(data: &str, live: bool, backend: &str) -> 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                    p { "本地分析引擎:" (backend) }
89                    (overview())
90                    (toolbar())
91                    div.workspace {
92                        section.list-pane aria-label="资源清单" {
93                            div.list-head aria-hidden="true" { span { "资源" } span { "原始体积" } span { "可节省" } }
94                            div.results id="results" role="listbox" aria-label="资源列表" {}
95                            div.pager {
96                                span id="range" role="status" aria-live="polite" {}
97                                div.pager-controls {
98                                    button.icon-button id="previous" aria-label="上一页" { "←" }
99                                    span id="page-number" {}
100                                    button.icon-button id="next" aria-label="下一页" { "→" }
101                                }
102                            }
103                        }
104                        aside.inspector id="inspector" aria-label="资源详情" {}
105                    }
106                    footer.footer {
107                        span { "体积采用 KiB / MiB(1024 进制),悬停可查看精确字节数。" }
108                        span { "仅统计源文件收益 · 不等于 App 包体收益" }
109                    }
110                }
111                dialog id="apply-dialog" aria-labelledby="apply-title" {
112                    h2 id="apply-title" { "确认优化图片" }
113                    p id="apply-description" {}
114                    p id="apply-note" { "原文件会备份,可在页面恢复。请先检查原尺寸候选的画质。" }
115                    div.dialog-actions {
116                        button id="apply-cancel" { "取消" }
117                        button.primary id="apply-confirm" { "确认并应用" }
118                    }
119                }
120                noscript { "请启用 JavaScript 查看筛选和图片对比,或打开同目录的 analysis.json。" }
121                // JSON is escaped for the script context by render_page, not HTML-escaped.
122                script type="application/json" id="report-data" { (PreEscaped(data)) }
123                script { (PreEscaped(include_str!("report.js"))) }
124            }
125        }
126    }
127}
128
129fn overview() -> Markup {
130    html! {
131        section.summary aria-label="分析概览" {
132            @for (id, label) in [("total-count", "资源文件"), ("candidate-count", "有更小候选"), ("total-savings", "预估可节省")] {
133                div.stat {
134                    div.stat-label { (label) }
135                    div class={ "stat-value" @if id == "total-savings" { " accent" } } id=(id) { "—" }
136                }
137            }
138            p.summary-note id="scope-note" {}
139        }
140    }
141}
142
143fn toolbar() -> Markup {
144    html! {
145        section.toolbar aria-label="筛选资源" {
146            div.modes role="group" aria-label="资源范围" {
147                @for (mode, label) in [("candidates", "有候选"), ("images", "图片"), ("all", "全部")] {
148                    button class={ "mode" @if mode == "candidates" { " active" } } data-mode=(mode) aria-pressed=(if mode == "candidates" { "true" } else { "false" }) {
149                        (label) " " span id={ "mode-" (mode) } {}
150                    }
151                }
152            }
153            div.search {
154                svg width="15" height="15" viewBox="0 0 20 20" fill="none" aria-hidden="true" {
155                    circle cx="8.5" cy="8.5" r="5.5" stroke="currentColor" stroke-width="1.5" {}
156                    path d="m13 13 4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" {}
157                }
158                label.sr-only for="search" { "搜索资源" }
159                input type="search" id="search" placeholder="搜索文件名或路径…" autocomplete="off";
160            }
161            label.sr-only for="format-filter" { "原始格式" }
162            select id="format-filter" { option value="all" { "全部格式" } }
163            label.sr-only for="sort" { "排序方式" }
164            select id="sort" {
165                option value="savings" { "节省量 ↓" }
166                option value="size" { "原始体积 ↓" }
167                option value="name" { "文件名 A–Z" }
168            }
169        }
170    }
171}