diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 85b0aaf..a69652c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1589,6 +1589,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libappindicator" version = "0.9.0" @@ -3149,6 +3155,15 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3483,6 +3498,29 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ts-rs" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e640d9b0964e9d39df633548591090ab92f7a4567bc31d3891af23471a3365c6" +dependencies = [ + "lazy_static", + "thiserror 2.0.18", + "ts-rs-macros", +] + +[[package]] +name = "ts-rs-macros" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9d8656589772eeec2cf7a8264d9cda40fb28b9bc53118ceb9e8c07f8f38730" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "termcolor", +] + [[package]] name = "typeid" version = "1.0.3" @@ -3503,6 +3541,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "ts-rs", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e204c1d..a768061 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -17,3 +17,8 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = [] } serde = { version = "1", features = ["derive"] } serde_json = "1" +ts-rs = { version = "10", features = ["no-serde-warnings"] } + +[features] +# 默认不导出类型(避免 CI 构建 fail) +default = [] diff --git a/src-tauri/bindings/types.ts b/src-tauri/bindings/types.ts new file mode 100644 index 0000000..186d78f --- /dev/null +++ b/src-tauri/bindings/types.ts @@ -0,0 +1,33 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AiCfg = { preset: string, protocol: string, base: string, key: string, model: string, proxy: string, img_base: string | null, img_key: string | null, img_model: string | null, }; + +export type AnimType = "fade-up" | "fade" | "scale" | "zoom" | "slide-l" | "slide-r" | "pop" | "rotate" | "bounce" | "flip" | "blur"; + +export type ChartItem = { label: string | null, value: number | null, }; + +export type ChartType = "bar" | "line" | "pie" | "area" | "doughnut" | "radar" | "hbar" | "progress"; + +export type ChatMessage = { role: string, content: string, }; + +export type Deck = { v: number, theme: string, slides: Array, chat_id: string | null, }; + +export type ElementStyle = { font_size: number | null, color: string | null, align: string | null, bold: boolean | null, italic: boolean | null, anim: string | null, label: string | null, label_color: string | null, label_size: number | null, fit: string | null, shape_type: ShapeType | null, fill: string | null, gradient: boolean | null, opacity: number | null, radius: number | null, max: number | null, chart_type: ChartType | null, legend: boolean | null, stack: boolean | null, grid: boolean | null, header: boolean | null, lang: string | null, inline: boolean | null, icon: string | null, accent: string | null, }; + +export type ElementType = "title" | "text" | "list" | "stat" | "quote" | "image" | "shape" | "chart" | "card" | "table" | "code" | "formula"; + +export type LibItem = { id: string, name: string, deck: Deck, created_at: number, updated_at: number, }; + +export type PageTemplate = { id: string, name: string, category: string, background: string, elements: Array, created_at: number | null, }; + +export type RichLine = { segments: Array, }; + +export type RichSegment = { text: string, bold: boolean | null, italic: boolean | null, underline: boolean | null, strike: boolean | null, color: string | null, highlight: boolean | null, code: boolean | null, sup: boolean | null, sub: boolean | null, font_size: number | null, link: string | null, }; + +export type ShapeType = "rect" | "circle" | "triangle"; + +export type Slide = { id: string, background: string, elements: Array, }; + +export type SlideElement = { id: string, type: string, x: number, y: number, w: number, h: number, content: string, style: ElementStyle, segments: Array | null, }; + +export type Theme = { name: string, primary: string, accent: string, bg: string, panel: string, text: string, muted: string, }; diff --git a/src-tauri/src/color.rs b/src-tauri/src/color.rs new file mode 100644 index 0000000..b6b7f35 --- /dev/null +++ b/src-tauri/src/color.rs @@ -0,0 +1,212 @@ +/* ===================================================================== + * color.rs — 颜色/背景解析纯函数(对应 store.ts 的颜色函数) + * + * 这些函数供 Rust 端的渲染/序列化使用(B2 store 迁移时调用)。 + * 当前阶段保持与 TS 版本完全一致的逻辑。 + * ===================================================================== */ + +pub fn hex_to_rgb(hex: &str) -> Option<(u8, u8, u8)> { + let c = hex.trim_start_matches('#'); + let full = if c.len() == 3 { + format!( + "{}{}{}{}{}{}", + &c[0..1], + &c[0..1], + &c[1..2], + &c[1..2], + &c[2..3], + &c[2..3] + ) + } else { + c.to_string() + }; + if full.len() < 6 { + return None; + } + let r = u8::from_str_radix(&full[0..2], 16).ok()?; + let g = u8::from_str_radix(&full[2..4], 16).ok()?; + let b = u8::from_str_radix(&full[4..6], 16).ok()?; + Some((r, g, b)) +} + +pub fn is_dark_hex(hex: &str) -> bool { + match hex_to_rgb(hex) { + Some((r, g, b)) => (0.299 * r as f64 + 0.587 * g as f64 + 0.114 * b as f64) < 145.0, + None => false, + } +} + +pub fn shade(hex: &str, pct: f64) -> String { + let (r, g, b) = match hex_to_rgb(hex) { + Some(rgb) => rgb, + None => return hex.to_string(), + }; + let f = if pct < 0.0 { 0.0 } else { 255.0 }; + let p = pct.abs() / 100.0; + let nr = ((f - r as f64) * p + r as f64).round() as u8; + let ng = ((f - g as f64) * p + g as f64).round() as u8; + let nb = ((f - b as f64) * p + b as f64).round() as u8; + format!("#{:02x}{:02x}{:02x}", nr, ng, nb) +} + +pub fn is_dark_bg(bg: &str) -> bool { + if bg.is_empty() { + return false; + } + if bg.starts_with('#') { + return is_dark_hex(bg); + } + if bg == "primary" || bg == "accent" { + return true; + } + if bg.starts_with("g-") { + return true; + } + false +} + +pub fn is_valid_hex(s: &str) -> bool { + s.starts_with('#') + && s.len() >= 4 + && s[1..].chars().all(|c| c.is_ascii_hexdigit()) +} + +/// 颜色解析:dark=true 时深色文字键反相为浅色 +pub fn resolve_color(key: &str, dark: bool, theme: &crate::model::Theme) -> String { + if key.is_empty() { + return String::new(); + } + if key.starts_with('#') { + if !is_valid_hex(key) { + return if dark { + "#ffffff".into() + } else { + theme.text.clone() + }; + } + if dark && is_dark_hex(key) { + return "#ffffff".into(); + } + return key.into(); + } + if dark { + match key { + "text" => "#ffffff".into(), + "muted" => "rgba(255,255,255,0.72)".into(), + "primary" => "#ffffff".into(), + "accent" => theme.accent.clone(), + _ => "#ffffff".into(), + } + } else { + match key { + "primary" => theme.primary.clone(), + "accent" => theme.accent.clone(), + "text" => theme.text.clone(), + "muted" => theme.muted.clone(), + _ => theme.text.clone(), + } + } +} + +/// 背景解析:渐变键 → CSS gradient +pub fn resolve_bg(key: &str, theme: &crate::model::Theme) -> String { + if key.is_empty() { + return "#ffffff".into(); + } + if key.starts_with('#') { + return if is_valid_hex(key) { + key.into() + } else { + "#ffffff".into() + }; + } + match key { + "g-primary" => format!( + "linear-gradient(135deg, {} 0%, {} 100%)", + theme.primary, theme.accent + ), + "g-deep" => format!( + "linear-gradient(160deg, {} 0%, {} 100%)", + shade(&theme.primary, -28.0), + theme.primary + ), + "g-soft" => format!( + "linear-gradient(135deg, {} 0%, {} 100%)", + theme.panel.clone(), + theme.bg.clone() + ), + "primary" => theme.primary.clone(), + "accent" => theme.accent.clone(), + "panel" => theme.panel.clone(), + "bg" => theme.bg.clone(), + _ => "#ffffff".into(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hex_to_rgb() { + assert_eq!(hex_to_rgb("#4f46e5"), Some((79, 70, 229))); + assert_eq!(hex_to_rgb("#fff"), Some((255, 255, 255))); + assert_eq!(hex_to_rgb("invalid"), None); + } + + #[test] + fn test_is_dark_hex() { + assert!(is_dark_hex("#0f172a")); // 深色 + assert!(is_dark_hex("#4f46e5")); // 青蓝偏深 + assert!(!is_dark_hex("#ffffff")); // 白色 + assert!(!is_dark_hex("#f59e0b")); // 琥珀亮色 + } + + #[test] + fn test_is_dark_bg() { + assert!(is_dark_bg("primary")); + assert!(is_dark_bg("g-deep")); + assert!(!is_dark_bg("bg")); + assert!(!is_dark_bg("panel")); + } + + #[test] + fn test_shade() { + let result = shade("#000000", 50.0); + assert_eq!(result, "#808080"); // 黑色提亮 50% → 灰色 + } + + #[test] + fn test_resolve_color_light() { + use crate::model::Theme; + let theme = Theme { + name: "test".into(), + primary: "#4f46e5".into(), + accent: "#06b6d4".into(), + bg: "#ffffff".into(), + panel: "#f8fafc".into(), + text: "#1e293b".into(), + muted: "#64748b".into(), + }; + assert_eq!(resolve_color("primary", false, &theme), "#4f46e5"); + assert_eq!(resolve_color("text", false, &theme), "#1e293b"); + } + + #[test] + fn test_resolve_color_dark() { + use crate::model::Theme; + let theme = Theme { + name: "test".into(), + primary: "#4f46e5".into(), + accent: "#06b6d4".into(), + bg: "#ffffff".into(), + panel: "#f8fafc".into(), + text: "#1e293b".into(), + muted: "#64748b".into(), + }; + // 深色背景下 text 键 → 白色 + assert_eq!(resolve_color("text", true, &theme), "#ffffff"); + // primary 键在深色背景下也 → 白色 + assert_eq!(resolve_color("primary", true, &theme), "#ffffff"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index deefd12..7d01495 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,19 +1,44 @@ /* ===================================================================== - * lib.rs — Tauri 应用入口(B0 脚手架) + * lib.rs — Tauri 应用入口 * - * 当前阶段:Vue 前端原样跑在 WebView 里,Rust 端只有 ping 验证 IPC。 - * 后续 B1-B5 逐步迁移核心逻辑到 Rust。 + * B0: 脚手架 + ping IPC + * B1: 数据模型(model.rs)+ 颜色纯函数(color.rs) + * B2: store 迁移(待实现) + * B3: AI 引擎迁移(待实现) * ===================================================================== */ -/// IPC 验证命令:前端 invoke('ping') → 返回 'pong' +mod color; +mod model; + +/// IPC 验证命令 #[tauri::command] fn ping() -> String { "pong".to_string() } +/// 返回应用版本信息 +#[tauri::command] +fn app_info() -> serde_json::Value { + serde_json::json!({ + "name": "u-ppt", + "version": env!("CARGO_PKG_VERSION"), + "platform": std::env::consts::OS, + }) +} + pub fn run() { tauri::Builder::default() - .invoke_handler(tauri::generate_handler![ping]) + .invoke_handler(tauri::generate_handler![ping, app_info]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ping() { + assert_eq!(ping(), "pong"); + } +} diff --git a/src-tauri/src/model.rs b/src-tauri/src/model.rs new file mode 100644 index 0000000..dbbf639 --- /dev/null +++ b/src-tauri/src/model.rs @@ -0,0 +1,307 @@ +/* ===================================================================== + * model.rs — 数据模型(对应 src/core/types.ts) + * + * 所有结构体 derive Serialize/Deserialize/TS,确保: + * - serde: JSON 序列化(Rust ↔ IPC ↔ JS) + * - ts-rs: 自动生成 TypeScript 类型定义(Rust ↔ TS 类型同步) + * + * 运行 `cargo test --features export-types` 会在 ../bindings/ 生成 .ts 文件。 + * ===================================================================== */ +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/* ---------- 颜色 / 背景 / 动画 ---------- */ + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../bindings/types.ts")] +pub enum ChartType { + #[serde(rename = "bar")] + Bar, + #[serde(rename = "line")] + Line, + #[serde(rename = "pie")] + Pie, + #[serde(rename = "area")] + Area, + #[serde(rename = "doughnut")] + Doughnut, + #[serde(rename = "radar")] + Radar, + #[serde(rename = "hbar")] + Hbar, + #[serde(rename = "progress")] + Progress, +} + +impl Default for ChartType { + fn default() -> Self { + ChartType::Bar + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../bindings/types.ts")] +pub enum ShapeType { + #[serde(rename = "rect")] + Rect, + #[serde(rename = "circle")] + Circle, + #[serde(rename = "triangle")] + Triangle, +} + +impl Default for ShapeType { + fn default() -> Self { + ShapeType::Rect + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../bindings/types.ts")] +pub enum AnimType { + #[serde(rename = "fade-up")] + FadeUp, + #[serde(rename = "fade")] + Fade, + #[serde(rename = "scale")] + Scale, + #[serde(rename = "zoom")] + Zoom, + #[serde(rename = "slide-l")] + SlideL, + #[serde(rename = "slide-r")] + SlideR, + #[serde(rename = "pop")] + Pop, + #[serde(rename = "rotate")] + Rotate, + #[serde(rename = "bounce")] + Bounce, + #[serde(rename = "flip")] + Flip, + #[serde(rename = "blur")] + Blur, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../bindings/types.ts")] +pub enum ElementType { + #[serde(rename = "title")] + Title, + #[serde(rename = "text")] + Text, + #[serde(rename = "list")] + List, + #[serde(rename = "stat")] + Stat, + #[serde(rename = "quote")] + Quote, + #[serde(rename = "image")] + Image, + #[serde(rename = "shape")] + Shape, + #[serde(rename = "chart")] + Chart, + #[serde(rename = "card")] + Card, + #[serde(rename = "table")] + Table, + #[serde(rename = "code")] + Code, + #[serde(rename = "formula")] + Formula, +} + +/* ---------- 主题 ---------- */ + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../bindings/types.ts")] +pub struct Theme { + pub name: String, + pub primary: String, + pub accent: String, + pub bg: String, + pub panel: String, + pub text: String, + pub muted: String, +} + +/* ---------- 富文本 ---------- */ + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../bindings/types.ts")] +pub struct RichSegment { + pub text: String, + pub bold: Option, + pub italic: Option, + pub underline: Option, + pub strike: Option, + pub color: Option, + pub highlight: Option, + pub code: Option, + #[serde(rename = "sup")] + pub sup_: Option, + #[serde(rename = "sub")] + pub sub_: Option, + pub font_size: Option, + pub link: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../bindings/types.ts")] +pub struct RichLine { + pub segments: Vec, +} + +/* ---------- 元素 ---------- */ + +#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../bindings/types.ts")] +pub struct ElementStyle { + pub font_size: Option, + pub color: Option, + pub align: Option, + pub bold: Option, + pub italic: Option, + pub anim: Option, + // stat + pub label: Option, + pub label_color: Option, + pub label_size: Option, + // image + pub fit: Option, + // shape + pub shape_type: Option, + pub fill: Option, + pub gradient: Option, + pub opacity: Option, + pub radius: Option, + // chart + pub max: Option, + pub chart_type: Option, + pub legend: Option, + pub stack: Option, + pub grid: Option, + // table + pub header: Option, + // code + pub lang: Option, + // formula + #[serde(rename = "inline")] + pub inline_: Option, + // card + pub icon: Option, + pub accent: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../bindings/types.ts")] +pub struct SlideElement { + pub id: String, + #[serde(rename = "type")] + pub element_type: String, + pub x: f64, + pub y: f64, + pub w: f64, + pub h: f64, + pub content: String, + pub style: ElementStyle, + pub segments: Option>, +} + +/* ---------- 幻灯片 / Deck ---------- */ + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../bindings/types.ts")] +pub struct Slide { + pub id: String, + pub background: String, + pub elements: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../bindings/types.ts")] +pub struct Deck { + pub v: i32, + pub theme: String, + pub slides: Vec, + pub chat_id: Option, +} + +/* ---------- AI 配置 ---------- */ + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../bindings/types.ts")] +pub struct AiCfg { + pub preset: String, + pub protocol: String, + pub base: String, + pub key: String, + pub model: String, + pub proxy: String, + pub img_base: Option, + pub img_key: Option, + pub img_model: Option, +} + +/* ---------- 文库 ---------- */ + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../bindings/types.ts")] +pub struct LibItem { + pub id: String, + pub name: String, + pub deck: Deck, + pub created_at: f64, + pub updated_at: f64, +} + +/* ---------- 模板 ---------- */ + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../bindings/types.ts")] +pub struct PageTemplate { + pub id: String, + pub name: String, + pub category: String, + pub background: String, + pub elements: Vec, + pub created_at: Option, +} + +/* ---------- 会话 ---------- */ + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../bindings/types.ts")] +pub struct ChatMessage { + pub role: String, + pub content: String, +} + +/* ---------- Op Log(对应 src/core/op.ts) ---------- */ + +/// Op 的 payload 是任意 JSON(不导出 TS,因为 payload 是动态的) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Op { + #[serde(rename = "type")] + pub op_type: String, + pub client_id: Option, + pub timestamp: Option, + #[serde(flatten)] + pub payload: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HistoryEntry { + pub forward: Op, + pub backward: Op, +} + +/* ---------- 图表数据 ---------- */ + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../bindings/types.ts")] +pub struct ChartItem { + pub label: Option, + pub value: Option, +}