新增: Rust 数据模型 + 颜色纯函数 + ts-rs 类型同步(B1)

- model.rs: 全部数据结构(Deck/Slide/Element/Style/Theme/AiCfg/LibItem/PageTemplate/ChatMessage/Op/RichSegment)
  所有结构体 derive Serialize/Deserialize/TS,自动生成 TS 类型到 bindings/types.ts
- color.rs: 颜色解析纯函数(resolve_color/resolve_bg/is_dark_bg/shade/hex_to_rgb)
  对应 store.ts 的颜色逻辑,23 个单元测试全部通过
- lib.rs: 新增 app_info IPC 命令
- ts-rs 自动生成的 bindings/types.ts 与手写 types.ts 类型一致
This commit is contained in:
2026-07-12 18:20:49 +08:00
parent b9a388b570
commit 1430e9e061
6 changed files with 626 additions and 5 deletions

39
src-tauri/Cargo.lock generated
View File

@@ -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]]

View File

@@ -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 = []

View File

@@ -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<Slide>, 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<SlideElement>, created_at: number | null, };
export type RichLine = { segments: Array<RichSegment>, };
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<SlideElement>, };
export type SlideElement = { id: string, type: string, x: number, y: number, w: number, h: number, content: string, style: ElementStyle, segments: Array<RichLine> | null, };
export type Theme = { name: string, primary: string, accent: string, bg: string, panel: string, text: string, muted: string, };

212
src-tauri/src/color.rs Normal file
View File

@@ -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");
}
}

View File

@@ -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");
}
}

307
src-tauri/src/model.rs Normal file
View File

@@ -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<bool>,
pub italic: Option<bool>,
pub underline: Option<bool>,
pub strike: Option<bool>,
pub color: Option<String>,
pub highlight: Option<bool>,
pub code: Option<bool>,
#[serde(rename = "sup")]
pub sup_: Option<bool>,
#[serde(rename = "sub")]
pub sub_: Option<bool>,
pub font_size: Option<f64>,
pub link: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../bindings/types.ts")]
pub struct RichLine {
pub segments: Vec<RichSegment>,
}
/* ---------- 元素 ---------- */
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../bindings/types.ts")]
pub struct ElementStyle {
pub font_size: Option<f64>,
pub color: Option<String>,
pub align: Option<String>,
pub bold: Option<bool>,
pub italic: Option<bool>,
pub anim: Option<String>,
// stat
pub label: Option<String>,
pub label_color: Option<String>,
pub label_size: Option<f64>,
// image
pub fit: Option<String>,
// shape
pub shape_type: Option<ShapeType>,
pub fill: Option<String>,
pub gradient: Option<bool>,
pub opacity: Option<f64>,
pub radius: Option<f64>,
// chart
pub max: Option<f64>,
pub chart_type: Option<ChartType>,
pub legend: Option<bool>,
pub stack: Option<bool>,
pub grid: Option<bool>,
// table
pub header: Option<bool>,
// code
pub lang: Option<String>,
// formula
#[serde(rename = "inline")]
pub inline_: Option<bool>,
// card
pub icon: Option<String>,
pub accent: Option<String>,
}
#[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<Vec<RichLine>>,
}
/* ---------- 幻灯片 / 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<SlideElement>,
}
#[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<Slide>,
pub chat_id: Option<String>,
}
/* ---------- 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<String>,
pub img_key: Option<String>,
pub img_model: Option<String>,
}
/* ---------- 文库 ---------- */
#[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<SlideElement>,
pub created_at: Option<f64>,
}
/* ---------- 会话 ---------- */
#[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<String>,
pub timestamp: Option<f64>,
#[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<String>,
pub value: Option<f64>,
}