Skip to main content

yggdrasil/pages/
writing_guide.rs

1//! 写作技能的公开阅读页:三步接入(安装技能、连接站点、开始写作)、正文功能卡片与
2//! 技能全文。复用文章渲染器及 MCP 配置生成器。
3use leptos::prelude::*;
4use leptos_meta::Title;
5use leptos_router::{lazy_route, LazyRoute};
6
7use crate::api::writing_guide::{get_writing_guide, WritingGuideData, SKILL_URL};
8use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
9use crate::components::skeletons::writing_guide_skeleton::WritingGuideSkeleton;
10use crate::components::ui::{Icon, IconName, SegmentedControl, SegmentedItem};
11use crate::hooks::ssr_resource::{prefetch_route_data, route_data, unit, PageJson, RouteData};
12use crate::pages::not_found::{NotFound, PageLoadFailed};
13use crate::routes::Route;
14use crate::utils::error::is_not_found;
15
16/// 客户端:名称、配置文件位置与官方文档。顺序与 `WritingGuideData::configs` 一致。
17const CLIENTS: [(&str, &str, &str); 3] = [
18    (
19        "Codex",
20        "~/.codex/config.toml",
21        "https://developers.openai.com/codex/mcp",
22    ),
23    (
24        "OMP",
25        "~/.omp/agent/mcp.json",
26        "https://github.com/can1357/oh-my-pi/blob/main/docs/mcp-config.md",
27    ),
28    (
29        "OpenCode",
30        "~/.config/opencode/opencode.json",
31        "https://opencode.ai/docs/mcp-servers/",
32    ),
33];
34
35/// 正文支持的功能:标题、说明与最小写法。
36const FEATURES: [(&str, &str, &str); 4] = [
37    (
38        "可运行代码",
39        "围栏语言名后加 runnable,阅读页提供运行按钮。",
40        "```python runnable\nprint(sum([2, 4, 6]))\n```",
41    ),
42    (
43        "Mermaid 图表",
44        "流程图、时序图与关系图以代码形式书写,跟随明暗主题。",
45        "```mermaid\nflowchart LR\n  草稿 --> 审阅 --> 发布\n```",
46    ),
47    (
48        "数学与化学公式",
49        "行内与独立公式使用 KaTeX 排版,化学式使用 \\ce{}。",
50        "$$E = mc^2$$\n\n$\\ce{2H2 + O2 -> 2H2O}$",
51    ),
52    (
53        "脚注、表格与折叠",
54        "支持脚注、表格、任务列表与折叠内容。",
55        "适用条件见脚注[^note]。\n\n[^note]: 输入在计算期间不变。",
56    ),
57];
58
59/// 写作指南路由,对应 `/about/writing`。
60pub struct WritingGuideRoute {
61    guide: RouteData<WritingGuideData>,
62}
63
64#[leptos::lazy]
65async fn fetch_guide() -> PageJson<WritingGuideData> {
66    PageJson::new(get_writing_guide().await)
67}
68
69#[lazy_route]
70impl LazyRoute for WritingGuideRoute {
71    fn data() -> Self {
72        Self {
73            guide: route_data(|| (), unit(fetch_guide)),
74        }
75    }
76
77    fn view(this: Self) -> AnyView {
78        let guide = this.guide;
79        view! {
80            <Title text="写作指南 · Yggdrasil"/>
81            <Suspense fallback=|| {
82                view! {
83                    <DelayedSkeleton>
84                        <WritingGuideSkeleton/>
85                    </DelayedSkeleton>
86                }
87            }>
88                {move || Suspend::new(async move {
89                    match guide.await {
90                        Ok(data) => view! { <WritingGuide data=data/> }.into_any(),
91                        Err(err) if is_not_found(&err) => view! { <NotFound/> }.into_any(),
92                        Err(_) => view! { <PageLoadFailed/> }.into_any(),
93                    }
94                })}
95            </Suspense>
96        }
97        .into_any()
98    }
99}
100
101impl WritingGuideRoute {
102    /// 悬停预取:与 `data()` 相同的取数函数与 source。
103    pub fn prefetch_data() {
104        prefetch_route_data((), unit(fetch_guide));
105    }
106}
107
108#[component]
109fn WritingGuide(data: WritingGuideData) -> impl IntoView {
110    let features = FEATURES
111        .iter()
112        .map(|(title, description, code)| {
113            view! {
114                <div class="writing-feature">
115                    <p class="writing-feature-title">{*title}</p>
116                    <p class="writing-feature-description">{*description}</p>
117                    <pre class="writing-feature-code">
118                        <code>{*code}</code>
119                    </pre>
120                </div>
121            }
122        })
123        .collect_view();
124
125    view! {
126        <article class="writing-page">
127            <WritingHeader/>
128            <ol class="writing-steps page-enter" style="--enter-index: 1">
129                <InstallStep prompt=data.install_prompt.clone()/>
130                <ConnectStep data=data.clone()/>
131                <li class="writing-step">
132                    <div class="writing-step-rail">
133                        <span class="writing-step-number is-last tabular-nums">"3"</span>
134                    </div>
135                    <div class="writing-step-body">
136                        <h2 class="writing-step-title">"开始写作"</h2>
137                        <p class="writing-step-description">
138                            "AI 只会保存草稿,发布仍由你在后台确认。可以这样开头:"
139                        </p>
140                        <blockquote class="writing-example">
141                            "让 Agent 使用 yggdrasil-writing,写一篇文章,先保存为草稿。"
142                        </blockquote>
143                    </div>
144                </li>
145            </ol>
146            <section class="writing-features" aria-labelledby="writing-features-title">
147                <h2 id="writing-features-title" class="writing-section-title">
148                    "正文支持的功能"
149                </h2>
150                <div class="writing-feature-grid">{features}</div>
151            </section>
152            <SkillDocument data=data/>
153        </article>
154    }
155}
156
157/// 页头:返回关于、标题与一句说明。骨架屏复用。
158#[component]
159pub fn WritingHeader() -> impl IntoView {
160    view! {
161        <a class="writing-back" href=Route::About.to_string()>
162            <Icon name=IconName::ArrowLeft size=14/>
163            "关于"
164        </a>
165        <header class="writing-header page-enter">
166            <h1 class="writing-title">"写作指南"</h1>
167            <p class="writing-lead">
168                "把这里的写作方式交给你的 AI:一份技能文件告诉它怎么排版,一条 MCP 连接让它能直接保存草稿。"
169            </p>
170        </header>
171    }
172}
173
174/// 第 1 步:一句话让 Agent 安装技能到用户目录。
175#[component]
176fn InstallStep(prompt: String) -> impl IntoView {
177    view! {
178        <li class="writing-step">
179            <div class="writing-step-rail">
180                <span class="writing-step-number tabular-nums">"1"</span>
181                <span class="writing-step-line" aria-hidden="true"></span>
182            </div>
183            <div class="writing-step-body">
184                <h2 class="writing-step-title">"安装写作技能"</h2>
185                <p class="writing-step-description">
186                    "把这句话发给你的 Agent。它会把 SKILL.md 安装到用户目录,之后在任何项目里都能用;Codex、Oh-My-Pi 与 OpenCode 都支持这个目录。"
187                </p>
188                <div class="writing-install">
189                    <p class="writing-install-prompt">{prompt.clone()}</p>
190                    <div class="writing-install-actions">
191                        <CopyButton text=prompt label="复制安装指令" primary=true/>
192                        <a
193                            class="btn btn-ghost btn-sm"
194                            href=SKILL_URL
195                            download="SKILL.md"
196                            rel="external"
197                        >
198                            <Icon name=IconName::Download size=15/>
199                            "下载 SKILL.md"
200                        </a>
201                        <a class="btn btn-ghost btn-sm" href="#skill">
202                            "先读全文"
203                        </a>
204                    </div>
205                </div>
206            </div>
207        </li>
208    }
209}
210
211/// 第 2 步:选择客户端,复制合并到配置文件的 MCP 配置。
212#[component]
213fn ConnectStep(data: WritingGuideData) -> impl IntoView {
214    let selected = RwSignal::new(0usize);
215    let tabs: Vec<SegmentedItem<String>> = CLIENTS
216        .iter()
217        .enumerate()
218        .map(|(i, (name, _, _))| SegmentedItem::new(i.to_string(), *name))
219        .collect();
220    let configs = data.configs;
221    let copy_configs = configs.clone();
222    view! {
223        <li class="writing-step">
224            <div class="writing-step-rail">
225                <span class="writing-step-number tabular-nums">"2"</span>
226                <span class="writing-step-line" aria-hidden="true"></span>
227            </div>
228            <div class="writing-step-body writing-connect">
229                <div class="writing-step-head">
230                    <h2 class="writing-step-title">"连接站点"</h2>
231                    <McpTokenLink/>
232                </div>
233                <p class="writing-step-description">
234                    "选择客户端,把配置合并到它的配置文件,再把令牌占位符换成后台签发的令牌。"
235                </p>
236                <figure class="writing-config">
237                    <div class="writing-config-bar">
238                        <SegmentedControl
239                            items=tabs
240                            value=Signal::derive(move || selected.get().to_string())
241                            on_change=move |value: String| selected.set(value.parse().unwrap_or(0))
242                            aria_label="Agent 客户端"
243                            class="writing-config-tabs"
244                        />
245                        <div class="writing-config-meta">
246                            <code>{move || CLIENTS[selected.get()].1}</code>
247                            // 按客户端重建,复制状态随之清空。
248                            {move || {
249                                let current = selected.get();
250                                view! {
251                                    <CopyButton text=copy_configs[current].clone() label="复制配置"/>
252                                }
253                            }}
254                        </div>
255                    </div>
256                    <pre
257                        class="writing-config-code"
258                        aria-label=move || format!("{} MCP 配置", CLIENTS[selected.get()].0)
259                    >
260                        <code>{move || configs[selected.get()].clone()}</code>
261                    </pre>
262                    <figcaption class="writing-config-foot">
263                        <span>
264                            "将 YOUR_YGGDRASIL_TOKEN 替换为站点签发的令牌;已有配置请合并,保留其他服务。"
265                        </span>
266                        <a
267                            href=move || CLIENTS[selected.get()].2
268                            target="_blank"
269                            rel="noopener noreferrer"
270                        >
271                            {move || format!("{} 文档", CLIENTS[selected.get()].0)}
272                            <Icon name=IconName::ArrowUpRight size=13/>
273                        </a>
274                    </figcaption>
275                </figure>
276                <p class="writing-permissions">
277                    "技能可以公开阅读;通过 MCP 保存文章需要 write 令牌,执行代码验证需要 admin 权限。"
278                </p>
279            </div>
280        </li>
281    }
282}
283
284/// 技能全文:左侧目录,右侧正文默认收起,展开后读全文。
285#[component]
286fn SkillDocument(data: WritingGuideData) -> impl IntoView {
287    let expanded = RwSignal::new(false);
288    view! {
289        <section id="skill" class="writing-skill" aria-labelledby="skill-title">
290            <div class="writing-skill-head">
291                <h2 id="skill-title" class="writing-section-title">
292                    "技能全文"
293                </h2>
294                <div class="writing-actions">
295                    <span class="writing-file-label">"SKILL.md"</span>
296                    <CopyButton text=data.source.clone() label="复制全文"/>
297                </div>
298            </div>
299            <div class="writing-skill-layout">
300                // 目录指向收起区里的小节:按下链接时先展开全文。锚点点击会被全局处理拦下,
301                // 不依赖 click,所以听 pointerdown 与 Enter。
302                <nav
303                    class="writing-toc"
304                    aria-label="技能目录"
305                    on:pointerdown=move |_| expanded.set(true)
306                    on:keydown=move |event| {
307                        if event.key() == "Enter" {
308                            expanded.set(true);
309                        }
310                    }
311                    inner_html=data.toc_html
312                ></nav>
313                <div class="writing-skill-body">
314                    <div
315                        id="skill-content"
316                        class="md-content post-content writing-content"
317                        data-expanded=move || expanded.get().to_string()
318                        inner_html=data.html
319                    ></div>
320                    {move || {
321                        (!expanded.get())
322                            .then(|| {
323                                view! {
324                                    <div class="writing-expand">
325                                        <button
326                                            type="button"
327                                            class="btn btn-secondary"
328                                            aria-controls="skill-content"
329                                            aria-expanded="false"
330                                            on:click=move |_| expanded.set(true)
331                                        >
332                                            "展开全文"
333                                        </button>
334                                    </div>
335                                }
336                            })
337                    }}
338                </div>
339            </div>
340        </section>
341    }
342}
343
344/// 仅在剪贴板写入成功后显示成功;失败时保持原文可选中或下载。
345#[component]
346fn CopyButton(
347    #[prop(into)] text: String,
348    label: &'static str,
349    #[prop(optional)] primary: bool,
350) -> impl IntoView {
351    let result = RwSignal::new(None::<bool>);
352    let on_click = move |_| {
353        #[cfg(target_arch = "wasm32")]
354        {
355            let text = text.clone();
356            leptos::task::spawn_local(async move {
357                let ok = crate::utils::js::copy_text(&text).await;
358                let _ = result.try_set(Some(ok));
359            });
360        }
361        #[cfg(not(target_arch = "wasm32"))]
362        let _ = &text;
363    };
364    view! {
365        <span class="writing-copy-wrap">
366            <button
367                type="button"
368                class=if primary { "btn btn-primary btn-sm" } else { "btn btn-ghost btn-sm" }
369                on:click=on_click
370            >
371                {move || {
372                    let icon = if result.get() == Some(true) { IconName::Check } else { IconName::Copy };
373                    view! { <Icon name=icon size=15/> }
374                }}
375                {label}
376            </button>
377            <span class="writing-copy-status" role="status" aria-live="polite">
378                {move || match result.get() {
379                    Some(true) => "已复制",
380                    Some(false) => "复制失败,请下载或手动复制",
381                    None => "",
382                }}
383            </span>
384        </span>
385    }
386}
387
388/// 「管理 MCP 令牌」入口:后台 MCP 页是站内路由,未登录由后台外壳跳转登录。
389#[component]
390fn McpTokenLink() -> impl IntoView {
391    view! {
392        <a href=Route::Mcp.to_string() class="writing-link">
393            "管理 MCP 令牌"
394            <Icon name=IconName::ArrowUpRight size=14/>
395        </a>
396    }
397}