init: Wardrobe 衣物管理系统 - 项目首页、OTA 升级、Android CI/CD

- 响应式项目首页 (Landing, 含 R2 APK 下载) + 路由 /home 调整
- OTA 升级: tauri-plugin-hotswap (lib.rs/main.rs 拆分, 客户端热替换)
- OTA 服务端 (check/bundle/manifest-web) + build-ota/genkey/upload 脚本
- 密钥/凭据统一存配置中心 (signing + ota + cloudflare 分组)
- Android CI (android-* 分支) + OTA CI (手动触发), 产物上传 R2
- 修复 tsc/构建阻塞问题
This commit is contained in:
jif2.zhang
2026-09-11 15:46:42 +08:00
commit 38b5e3190a
62 changed files with 17963 additions and 0 deletions
+151
View File
@@ -0,0 +1,151 @@
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use log::info;
use crate::db::Database;
use crate::models::*;
const VERIFICATION_CODE: &str = "1111";
const CODE_EXPIRY_SECONDS: i64 = 300;
pub struct AuthManager {
codes: Mutex<HashMap<String, VerificationCode>>,
}
impl AuthManager {
pub fn new() -> Self {
Self {
codes: Mutex::new(HashMap::new()),
}
}
fn now_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
pub fn send_verification(&self, target: &str) -> Result<String, String> {
let code = VERIFICATION_CODE.to_string();
let now = Self::now_secs();
let vc = VerificationCode {
target: target.to_string(),
code: code.clone(),
created_at: now,
expires_at: now + CODE_EXPIRY_SECONDS,
};
self.codes.lock().unwrap().insert(target.to_string(), vc);
info!("Verification code sent to {}: {}", target, code);
Ok(format!("验证码已发送至 {}", target))
}
fn verify_code(&self, target: &str, code: &str) -> bool {
let codes = self.codes.lock().unwrap();
if let Some(vc) = codes.get(target) {
let now = Self::now_secs();
vc.code == code && now < vc.expires_at
} else {
false
}
}
pub fn register(
&self,
db: &Database,
email: Option<&str>,
phone: Option<&str>,
code: &str,
nickname: &str,
) -> Result<AuthResult, String> {
let target = email.or(phone).ok_or("请提供邮箱或手机号")?;
if !self.verify_code(target, code) {
return Ok(AuthResult {
success: false,
message: "验证码无效或已过期".to_string(),
user: None,
token: None,
});
}
if email.is_some() {
if db.get_user_by_email(target).map_err(|e| e.to_string())?.is_some() {
return Ok(AuthResult {
success: false,
message: "该邮箱已注册".to_string(),
user: None,
token: None,
});
}
} else if phone.is_some() {
if db.get_user_by_phone(target).map_err(|e| e.to_string())?.is_some() {
return Ok(AuthResult {
success: false,
message: "该手机号已注册".to_string(),
user: None,
token: None,
});
}
}
let id = uuid::Uuid::new_v4().to_string();
let user = db.create_user(&id, email, phone, nickname).map_err(|e| e.to_string())?;
let token = format!("tok_{}", uuid::Uuid::new_v4().to_string().replace('-', ""));
self.codes.lock().unwrap().remove(target);
info!("User registered: {}", id);
Ok(AuthResult {
success: true,
message: "注册成功".to_string(),
user: Some(user),
token: Some(token),
})
}
pub fn login(
&self,
db: &Database,
email: Option<&str>,
phone: Option<&str>,
code: &str,
) -> Result<AuthResult, String> {
let target = email.or(phone).ok_or("请提供邮箱或手机号")?;
if !self.verify_code(target, code) {
return Ok(AuthResult {
success: false,
message: "验证码无效或已过期".to_string(),
user: None,
token: None,
});
}
let user = if email.is_some() {
db.get_user_by_email(target).map_err(|e| e.to_string())?
} else {
db.get_user_by_phone(target).map_err(|e| e.to_string())?
};
self.codes.lock().unwrap().remove(target);
match user {
Some(u) => {
let token = format!("tok_{}", uuid::Uuid::new_v4().to_string().replace('-', ""));
Ok(AuthResult {
success: true,
message: "登录成功".to_string(),
user: Some(u),
token: Some(token),
})
}
None => Ok(AuthResult {
success: false,
message: "账号不存在,请先注册".to_string(),
user: None,
token: None,
}),
}
}
}
+395
View File
@@ -0,0 +1,395 @@
use duckdb::{Connection, params};
use std::sync::Mutex;
use std::path::PathBuf;
use log::info;
use crate::models::*;
pub struct Database {
conn: Mutex<Connection>,
path: PathBuf,
}
impl Database {
pub fn new(data_dir: &PathBuf) -> Result<Self, Box<dyn std::error::Error>> {
std::fs::create_dir_all(data_dir)?;
let db_path = data_dir.join("wardrobe.duckdb");
let conn = Connection::open(&db_path)?;
let db = Self {
conn: Mutex::new(conn),
path: db_path,
};
db.init_tables()?;
info!("DuckDB initialized at {:?}", db.path);
Ok(db)
}
fn init_tables(&self) -> Result<(), Box<dyn std::error::Error>> {
let conn = self.conn.lock().unwrap();
conn.execute_batch("
CREATE TABLE IF NOT EXISTS users (
id VARCHAR PRIMARY KEY,
email VARCHAR UNIQUE,
phone VARCHAR UNIQUE,
nickname VARCHAR NOT NULL DEFAULT '',
avatar_path VARCHAR,
created_at VARCHAR NOT NULL
);
CREATE TABLE IF NOT EXISTS clothing_items (
id VARCHAR PRIMARY KEY,
user_id VARCHAR NOT NULL,
name VARCHAR NOT NULL,
category VARCHAR NOT NULL,
color VARCHAR NOT NULL,
brand VARCHAR,
material VARCHAR,
seasons VARCHAR DEFAULT '[]',
occasions VARCHAR DEFAULT '[]',
formality INTEGER DEFAULT 50,
thickness INTEGER DEFAULT 50,
image_path VARCHAR,
purchase_date VARCHAR,
purchase_price DOUBLE,
last_cleaned VARCHAR,
clean_method VARCHAR,
wear_count INTEGER DEFAULT 0,
last_worn VARCHAR,
is_favorite BOOLEAN DEFAULT FALSE,
created_at VARCHAR NOT NULL,
updated_at VARCHAR NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS outfits (
id VARCHAR PRIMARY KEY,
user_id VARCHAR NOT NULL,
name VARCHAR NOT NULL,
item_ids VARCHAR DEFAULT '[]',
occasion VARCHAR,
season VARCHAR,
match_score INTEGER,
ai_analysis VARCHAR,
is_saved BOOLEAN DEFAULT FALSE,
created_at VARCHAR NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS daily_records (
id VARCHAR PRIMARY KEY,
user_id VARCHAR NOT NULL,
date VARCHAR NOT NULL,
outfit_id VARCHAR,
weather_temp DOUBLE,
weather_condition VARCHAR,
notes VARCHAR,
created_at VARCHAR NOT NULL,
UNIQUE(user_id, date),
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_clothing_user ON clothing_items(user_id);
CREATE INDEX IF NOT EXISTS idx_clothing_category ON clothing_items(category);
CREATE INDEX IF NOT EXISTS idx_outfits_user ON outfits(user_id);
CREATE INDEX IF NOT EXISTS idx_daily_user_date ON daily_records(user_id, date);
")?;
Ok(())
}
// ── User CRUD ──
pub fn create_user(&self, id: &str, email: Option<&str>, phone: Option<&str>, nickname: &str) -> Result<User, Box<dyn std::error::Error>> {
let conn = self.conn.lock().unwrap();
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO users (id, email, phone, nickname, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
params![id, email, phone, nickname, now],
)?;
Ok(User {
id: id.to_string(),
email: email.map(String::from),
phone: phone.map(String::from),
nickname: nickname.to_string(),
avatar_path: None,
created_at: now,
})
}
pub fn get_user_by_email(&self, email: &str) -> Result<Option<User>, Box<dyn std::error::Error>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT id, email, phone, nickname, avatar_path, created_at FROM users WHERE email = ?1")?;
let mut rows = stmt.query_map(params![email], |row| {
Ok(User {
id: row.get(0)?,
email: row.get(1)?,
phone: row.get(2)?,
nickname: row.get(3)?,
avatar_path: row.get(4)?,
created_at: row.get(5)?,
})
})?;
match rows.next() {
Some(row) => Ok(Some(row?)),
None => Ok(None),
}
}
pub fn get_user_by_phone(&self, phone: &str) -> Result<Option<User>, Box<dyn std::error::Error>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT id, email, phone, nickname, avatar_path, created_at FROM users WHERE phone = ?1")?;
let mut rows = stmt.query_map(params![phone], |row| {
Ok(User {
id: row.get(0)?,
email: row.get(1)?,
phone: row.get(2)?,
nickname: row.get(3)?,
avatar_path: row.get(4)?,
created_at: row.get(5)?,
})
})?;
match rows.next() {
Some(row) => Ok(Some(row?)),
None => Ok(None),
}
}
// ── Clothing CRUD ──
pub fn create_clothing(&self, item: &ClothingItem) -> Result<(), Box<dyn std::error::Error>> {
let conn = self.conn.lock().unwrap();
let seasons_json = serde_json::to_string(&item.seasons)?;
let occasions_json = serde_json::to_string(&item.occasions)?;
conn.execute(
"INSERT INTO clothing_items (id, user_id, name, category, color, brand, material, seasons, occasions, formality, thickness, image_path, purchase_date, purchase_price, last_cleaned, clean_method, wear_count, last_worn, is_favorite, created_at, updated_at)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21)",
params![
item.id, item.user_id, item.name, item.category, item.color,
item.brand, item.material, seasons_json, occasions_json,
item.formality, item.thickness, item.image_path,
item.purchase_date, item.purchase_price, item.last_cleaned,
item.clean_method, item.wear_count, item.last_worn,
item.is_favorite, item.created_at, item.updated_at
],
)?;
Ok(())
}
pub fn list_clothing(&self, user_id: &str, category: Option<&str>) -> Result<Vec<ClothingItem>, Box<dyn std::error::Error>> {
let conn = self.conn.lock().unwrap();
let base = "SELECT id, user_id, name, category, color, brand, material, seasons, occasions, formality, thickness, image_path, purchase_date, purchase_price, last_cleaned, clean_method, wear_count, last_worn, is_favorite, created_at, updated_at FROM clothing_items WHERE user_id = ?1";
let (sql, params_clause): (String, Vec<Box<dyn duckdb::ToSql>>) = match category {
Some(cat) => (
format!("{} AND category = ?2 ORDER BY created_at DESC", base),
vec![Box::new(cat.to_string())],
),
None => (
format!("{} ORDER BY created_at DESC", base),
Vec::new(),
),
};
let mut stmt = conn.prepare(&sql)?;
// Build unified param list: user_id + optional category
let mut all_params: Vec<Box<dyn duckdb::ToSql>> = Vec::with_capacity(1 + params_clause.len());
all_params.push(Box::new(user_id.to_string()));
all_params.extend(params_clause);
let param_refs: Vec<&dyn duckdb::ToSql> = all_params.iter().map(|b| b.as_ref()).collect();
let mut rows = stmt.query(param_refs.as_slice())?;
let mut items = Vec::new();
while let Some(row) = rows.next()? {
items.push(Self::map_clothing_row(row)?);
}
Ok(items)
}
pub fn get_clothing(&self, id: &str) -> Result<Option<ClothingItem>, Box<dyn std::error::Error>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT id, user_id, name, category, color, brand, material, seasons, occasions, formality, thickness, image_path, purchase_date, purchase_price, last_cleaned, clean_method, wear_count, last_worn, is_favorite, created_at, updated_at FROM clothing_items WHERE id = ?1")?;
let mut rows = stmt.query_map(params![id], Self::map_clothing_row)?;
match rows.next() {
Some(row) => Ok(Some(row?)),
None => Ok(None),
}
}
pub fn update_clothing(&self, id: &str, name: &str, category: &str, color: &str) -> Result<(), Box<dyn std::error::Error>> {
let conn = self.conn.lock().unwrap();
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"UPDATE clothing_items SET name = ?1, category = ?2, color = ?3, updated_at = ?4 WHERE id = ?5",
params![name, category, color, now, id],
)?;
Ok(())
}
pub fn delete_clothing(&self, id: &str) -> Result<(), Box<dyn std::error::Error>> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM clothing_items WHERE id = ?1", params![id])?;
Ok(())
}
fn map_clothing_row(row: &duckdb::Row) -> Result<ClothingItem, duckdb::Error> {
let seasons_str: String = row.get(7)?;
let occasions_str: String = row.get(8)?;
Ok(ClothingItem {
id: row.get(0)?,
user_id: row.get(1)?,
name: row.get(2)?,
category: row.get(3)?,
color: row.get(4)?,
brand: row.get(5)?,
material: row.get(6)?,
seasons: serde_json::from_str(&seasons_str).unwrap_or_default(),
occasions: serde_json::from_str(&occasions_str).unwrap_or_default(),
formality: row.get(9)?,
thickness: row.get(10)?,
image_path: row.get(11)?,
purchase_date: row.get(12)?,
purchase_price: row.get(13)?,
last_cleaned: row.get(14)?,
clean_method: row.get(15)?,
wear_count: row.get(16)?,
last_worn: row.get(17)?,
is_favorite: row.get(18)?,
created_at: row.get(19)?,
updated_at: row.get(20)?,
})
}
// ── Outfit CRUD ──
pub fn create_outfit(&self, outfit: &Outfit) -> Result<(), Box<dyn std::error::Error>> {
let conn = self.conn.lock().unwrap();
let item_ids_json = serde_json::to_string(&outfit.item_ids)?;
conn.execute(
"INSERT INTO outfits (id, user_id, name, item_ids, occasion, season, match_score, ai_analysis, is_saved, created_at)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",
params![
outfit.id, outfit.user_id, outfit.name, item_ids_json,
outfit.occasion, outfit.season, outfit.match_score,
outfit.ai_analysis, outfit.is_saved, outfit.created_at
],
)?;
Ok(())
}
pub fn list_outfits(&self, user_id: &str) -> Result<Vec<Outfit>, Box<dyn std::error::Error>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT id, user_id, name, item_ids, occasion, season, match_score, ai_analysis, is_saved, created_at FROM outfits WHERE user_id = ?1 ORDER BY created_at DESC")?;
let rows = stmt.query_map(params![user_id], |row| {
let item_ids_str: String = row.get(3)?;
Ok(Outfit {
id: row.get(0)?,
user_id: row.get(1)?,
name: row.get(2)?,
item_ids: serde_json::from_str(&item_ids_str).unwrap_or_default(),
occasion: row.get(4)?,
season: row.get(5)?,
match_score: row.get(6)?,
ai_analysis: row.get(7)?,
is_saved: row.get(8)?,
created_at: row.get(9)?,
})
})?;
let mut outfits = Vec::new();
for row in rows {
outfits.push(row?);
}
Ok(outfits)
}
// ── Daily Record CRUD ──
pub fn upsert_daily_record(&self, record: &DailyRecord) -> Result<(), Box<dyn std::error::Error>> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO daily_records (id, user_id, date, outfit_id, weather_temp, weather_condition, notes, created_at)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8)
ON CONFLICT (user_id, date) DO UPDATE SET outfit_id = ?4, weather_temp = ?5, weather_condition = ?6, notes = ?7",
params![
record.id, record.user_id, record.date, record.outfit_id,
record.weather_temp, record.weather_condition, record.notes, record.created_at
],
)?;
Ok(())
}
pub fn list_daily_records(&self, user_id: &str, limit: i32) -> Result<Vec<DailyRecord>, Box<dyn std::error::Error>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT id, user_id, date, outfit_id, weather_temp, weather_condition, notes, created_at FROM daily_records WHERE user_id = ?1 ORDER BY date DESC LIMIT ?2")?;
let rows = stmt.query_map(params![user_id, limit], |row| {
Ok(DailyRecord {
id: row.get(0)?,
user_id: row.get(1)?,
date: row.get(2)?,
outfit_id: row.get(3)?,
weather_temp: row.get(4)?,
weather_condition: row.get(5)?,
notes: row.get(6)?,
created_at: row.get(7)?,
})
})?;
let mut records = Vec::new();
for row in rows {
records.push(row?);
}
Ok(records)
}
// ── Stats ──
pub fn get_stats(&self, user_id: &str) -> Result<WardrobeStats, Box<dyn std::error::Error>> {
let conn = self.conn.lock().unwrap();
let total_items: i32 = conn.query_row(
"SELECT COUNT(*) FROM clothing_items WHERE user_id = ?1", params![user_id], |r| r.get(0)
)?;
let total_outfits: i32 = conn.query_row(
"SELECT COUNT(*) FROM outfits WHERE user_id = ?1", params![user_id], |r| r.get(0)
)?;
let favorite_count: i32 = conn.query_row(
"SELECT COUNT(*) FROM clothing_items WHERE user_id = ?1 AND is_favorite = TRUE", params![user_id], |r| r.get(0)
)?;
let unworn_30d: i32 = conn.query_row(
"SELECT COUNT(*) FROM clothing_items WHERE user_id = ?1 AND (last_worn IS NULL OR last_worn < strftime('%Y-%m-%d', 'now', '-30 days'))",
params![user_id], |r| r.get(0)
)?;
let needs_cleaning: i32 = conn.query_row(
"SELECT COUNT(*) FROM clothing_items WHERE user_id = ?1 AND (last_cleaned IS NULL OR last_cleaned < strftime('%Y-%m-%d', 'now', '-30 days'))",
params![user_id], |r| r.get(0)
)?;
let utilization_rate = if total_items > 0 {
((total_items - unworn_30d) as f64 / total_items as f64 * 100.0).round()
} else {
0.0
};
let mut category_counts = std::collections::HashMap::new();
let cats = ["top", "bottom", "outer", "dress", "shoes", "accessory"];
for cat in cats {
let count: i32 = conn.query_row(
"SELECT COUNT(*) FROM clothing_items WHERE user_id = ?1 AND category = ?2",
params![user_id, cat],
|r| r.get(0),
)?;
category_counts.insert(cat.to_string(), count);
}
Ok(WardrobeStats {
total_items,
total_outfits,
utilization_rate,
category_counts,
favorite_count,
unworn_30d,
needs_cleaning,
})
}
pub fn db_path(&self) -> &PathBuf {
&self.path
}
}
+298
View File
@@ -0,0 +1,298 @@
mod auth;
mod db;
mod mcp;
mod models;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use log::info;
use tauri::Manager;
use auth::AuthManager;
use db::Database;
use mcp::McpService;
use models::*;
struct AppState {
db: Arc<Database>,
auth: Arc<AuthManager>,
mcp: Arc<McpService>,
shutdown_flag: Arc<AtomicBool>,
}
// ══════════════════════════════════════════════════
// Tauri IPC Commands
// ══════════════════════════════════════════════════
#[tauri::command]
fn send_code(state: tauri::State<AppState>, target: String) -> Result<String, String> {
state.auth.send_verification(&target)
}
#[tauri::command]
fn register(
state: tauri::State<AppState>,
email: Option<String>,
phone: Option<String>,
code: String,
nickname: String,
) -> Result<AuthResult, String> {
state.auth.register(&state.db, email.as_deref(), phone.as_deref(), &code, &nickname)
}
#[tauri::command]
fn login(
state: tauri::State<AppState>,
email: Option<String>,
phone: Option<String>,
code: String,
) -> Result<AuthResult, String> {
state.auth.login(&state.db, email.as_deref(), phone.as_deref(), &code)
}
#[tauri::command]
fn add_clothing(
state: tauri::State<AppState>,
user_id: String,
name: String,
category: String,
color: String,
brand: Option<String>,
seasons: Option<Vec<String>>,
occasions: Option<Vec<String>>,
) -> Result<ClothingItem, String> {
let now = chrono::Utc::now().to_rfc3339();
let item = ClothingItem {
id: uuid::Uuid::new_v4().to_string(),
user_id,
name,
category,
color,
brand,
material: None,
seasons: seasons.unwrap_or_default(),
occasions: occasions.unwrap_or_default(),
formality: 50,
thickness: 50,
image_path: None,
purchase_date: None,
purchase_price: None,
last_cleaned: None,
clean_method: None,
wear_count: 0,
last_worn: None,
is_favorite: false,
created_at: now.clone(),
updated_at: now,
};
state.db.create_clothing(&item).map_err(|e| e.to_string())?;
Ok(item)
}
#[tauri::command]
fn list_clothing(
state: tauri::State<AppState>,
user_id: String,
category: Option<String>,
) -> Result<Vec<ClothingItem>, String> {
state.db.list_clothing(&user_id, category.as_deref()).map_err(|e| e.to_string())
}
#[tauri::command]
fn get_clothing(state: tauri::State<AppState>, id: String) -> Result<Option<ClothingItem>, String> {
state.db.get_clothing(&id).map_err(|e| e.to_string())
}
#[tauri::command]
fn update_clothing(
state: tauri::State<AppState>,
id: String,
name: String,
category: String,
color: String,
) -> Result<(), String> {
state.db.update_clothing(&id, &name, &category, &color).map_err(|e| e.to_string())
}
#[tauri::command]
fn delete_clothing(state: tauri::State<AppState>, id: String) -> Result<(), String> {
state.db.delete_clothing(&id).map_err(|e| e.to_string())
}
#[tauri::command]
fn add_outfit(
state: tauri::State<AppState>,
user_id: String,
name: String,
item_ids: Vec<String>,
occasion: Option<String>,
season: Option<String>,
) -> Result<Outfit, String> {
let now = chrono::Utc::now().to_rfc3339();
let outfit = Outfit {
id: uuid::Uuid::new_v4().to_string(),
user_id,
name,
item_ids,
occasion,
season,
match_score: None,
ai_analysis: None,
is_saved: false,
created_at: now,
};
state.db.create_outfit(&outfit).map_err(|e| e.to_string())?;
Ok(outfit)
}
#[tauri::command]
fn list_outfits(state: tauri::State<AppState>, user_id: String) -> Result<Vec<Outfit>, String> {
state.db.list_outfits(&user_id).map_err(|e| e.to_string())
}
#[tauri::command]
fn record_daily(
state: tauri::State<AppState>,
user_id: String,
date: String,
outfit_id: Option<String>,
notes: Option<String>,
) -> Result<DailyRecord, String> {
let now = chrono::Utc::now().to_rfc3339();
let record = DailyRecord {
id: uuid::Uuid::new_v4().to_string(),
user_id,
date,
outfit_id,
weather_temp: None,
weather_condition: None,
notes,
created_at: now,
};
state.db.upsert_daily_record(&record).map_err(|e| e.to_string())?;
Ok(record)
}
#[tauri::command]
fn list_daily_records(
state: tauri::State<AppState>,
user_id: String,
limit: Option<i32>,
) -> Result<Vec<DailyRecord>, String> {
state.db.list_daily_records(&user_id, limit.unwrap_or(30)).map_err(|e| e.to_string())
}
#[tauri::command]
fn get_stats(state: tauri::State<AppState>, user_id: String) -> Result<WardrobeStats, String> {
state.db.get_stats(&user_id).map_err(|e| e.to_string())
}
// ── MCP Commands ──
#[tauri::command]
fn mcp_list_tools(state: tauri::State<AppState>) -> Result<Vec<mcp::McpTool>, String> {
Ok(state.mcp.list_tools())
}
#[tauri::command]
fn mcp_call_tool(
state: tauri::State<AppState>,
tool_name: String,
args: serde_json::Value,
) -> Result<serde_json::Value, String> {
state.mcp.call_tool(&tool_name, &args)
}
// ══════════════════════════════════════════════════
// Main (桌面 + Android 共用)
// ══════════════════════════════════════════════════
// 必须用 tauri_plugin_hotswap::init 包装 generate_context,
// 返回 (插件实例, 新的 context), 并把插件注入 Builder。
// Android 上通过 mobile_entry_point 从这里启动。
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
env_logger::init();
info!("Wardrobe starting...");
let shutdown_flag = Arc::new(AtomicBool::new(false));
let ctrlc_flag = shutdown_flag.clone();
ctrlc::set_handler(move || {
info!("Ctrl+C received, shutting down gracefully...");
ctrlc_flag.store(true, Ordering::SeqCst);
})
.expect("Error setting Ctrl-C handler");
// 初始化 hotswap 插件 (OTA 升级), 返回改装后的 context
let context = tauri::generate_context!();
let (hotswap, context) = match tauri_plugin_hotswap::init(context) {
Ok(ok) => ok,
Err(e) => {
eprintln!("failed to initialize tauri-plugin-hotswap: {:?}", e);
std::process::exit(1);
}
};
let app = tauri::Builder::default()
.plugin(hotswap)
.plugin(tauri_plugin_shell::init())
.setup(|app| {
info!("Initializing database...");
let data_dir = app.path().app_data_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let database = match Database::new(&data_dir) {
Ok(db) => Arc::new(db),
Err(e) => {
eprintln!("Failed to initialize database: {}", e);
std::process::exit(1);
}
};
let auth = Arc::new(AuthManager::new());
let mcp = Arc::new(McpService::new(database.clone()));
app.manage(AppState {
db: database,
auth,
mcp,
shutdown_flag: shutdown_flag.clone(),
});
info!("Wardrobe app initialized, data dir: {:?}", data_dir);
Ok(())
})
.on_window_event(move |_window, event| {
if let tauri::WindowEvent::Destroyed = event {
info!("Window destroyed, shutting down gracefully...");
shutdown_flag.store(true, Ordering::SeqCst);
}
})
.invoke_handler(tauri::generate_handler![
send_code,
register,
login,
add_clothing,
list_clothing,
get_clothing,
update_clothing,
delete_clothing,
add_outfit,
list_outfits,
record_daily,
list_daily_records,
get_stats,
mcp_list_tools,
mcp_call_tool,
])
.build(context)
.expect("error while building tauri application");
info!("Running Tauri application");
app.run(|_app, _event| {
// Additional lifecycle handling can be added here
});
info!("Wardrobe shut down gracefully");
}
+7
View File
@@ -0,0 +1,7 @@
// Windows / Linux / macOS 桌面端入口
// Android 不走这里, 走 .lib crate (mobile_entry_point)
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
wardrobe_lib::run();
}
+205
View File
@@ -0,0 +1,205 @@
use std::sync::Arc;
use log::info;
use crate::db::Database;
use crate::models::*;
pub struct McpService {
db: Arc<Database>,
}
impl McpService {
pub fn new(db: Arc<Database>) -> Self {
Self { db }
}
pub fn list_tools(&self) -> Vec<McpTool> {
vec![
McpTool {
name: "list_clothing".to_string(),
description: "获取用户衣橱中的所有衣物列表".to_string(),
input_schema: r#"{"type":"object","properties":{"user_id":{"type":"string"},"category":{"type":"string"}},"required":["user_id"]}"#.to_string(),
},
McpTool {
name: "get_clothing".to_string(),
description: "根据ID获取单件衣物详情".to_string(),
input_schema: r#"{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]}"#.to_string(),
},
McpTool {
name: "add_clothing".to_string(),
description: "添加新衣物到衣橱".to_string(),
input_schema: r#"{"type":"object","properties":{"user_id":{"type":"string"},"name":{"type":"string"},"category":{"type":"string"},"color":{"type":"string"},"brand":{"type":"string"},"seasons":{"type":"array","items":{"type":"string"}},"occasions":{"type":"array","items":{"type":"string"}}},"required":["user_id","name","category","color"]}"#.to_string(),
},
McpTool {
name: "update_clothing".to_string(),
description: "更新衣物信息".to_string(),
input_schema: r#"{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"category":{"type":"string"},"color":{"type":"string"}},"required":["id"]}"#.to_string(),
},
McpTool {
name: "delete_clothing".to_string(),
description: "从衣橱中删除衣物".to_string(),
input_schema: r#"{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]}"#.to_string(),
},
McpTool {
name: "list_outfits".to_string(),
description: "获取用户的所有搭配方案".to_string(),
input_schema: r#"{"type":"object","properties":{"user_id":{"type":"string"}},"required":["user_id"]}"#.to_string(),
},
McpTool {
name: "add_outfit".to_string(),
description: "创建新的搭配方案".to_string(),
input_schema: r#"{"type":"object","properties":{"user_id":{"type":"string"},"name":{"type":"string"},"item_ids":{"type":"array","items":{"type":"string"}},"occasion":{"type":"string"},"season":{"type":"string"}},"required":["user_id","name","item_ids"]}"#.to_string(),
},
McpTool {
name: "get_wardrobe_stats".to_string(),
description: "获取衣橱统计数据(总数、利用率、分类统计等)".to_string(),
input_schema: r#"{"type":"object","properties":{"user_id":{"type":"string"}},"required":["user_id"]}"#.to_string(),
},
McpTool {
name: "record_daily_outfit".to_string(),
description: "记录每日穿搭".to_string(),
input_schema: r#"{"type":"object","properties":{"user_id":{"type":"string"},"date":{"type":"string"},"outfit_id":{"type":"string"},"notes":{"type":"string"}},"required":["user_id","date"]}"#.to_string(),
},
McpTool {
name: "list_daily_records".to_string(),
description: "获取穿搭日历记录".to_string(),
input_schema: r#"{"type":"object","properties":{"user_id":{"type":"string"},"limit":{"type":"integer"}},"required":["user_id"]}"#.to_string(),
},
]
}
pub fn call_tool(&self, name: &str, args: &serde_json::Value) -> Result<serde_json::Value, String> {
info!("MCP tool call: {}", name);
match name {
"list_clothing" => {
let user_id = args["user_id"].as_str().ok_or("missing user_id")?;
let category = args["category"].as_str();
let items = self.db.list_clothing(user_id, category).map_err(|e| e.to_string())?;
Ok(serde_json::to_value(items).unwrap())
}
"get_clothing" => {
let id = args["id"].as_str().ok_or("missing id")?;
match self.db.get_clothing(id).map_err(|e| e.to_string())? {
Some(item) => Ok(serde_json::to_value(item).unwrap()),
None => Ok(serde_json::json!({"error": "衣物不存在"})),
}
}
"add_clothing" => {
let user_id = args["user_id"].as_str().ok_or("missing user_id")?;
let name = args["name"].as_str().ok_or("missing name")?;
let category = args["category"].as_str().ok_or("missing category")?;
let color = args["color"].as_str().ok_or("missing color")?;
let brand = args["brand"].as_str();
let seasons: Vec<String> = args["seasons"].as_array()
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let occasions: Vec<String> = args["occasions"].as_array()
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let now = chrono::Utc::now().to_rfc3339();
let item = ClothingItem {
id: uuid::Uuid::new_v4().to_string(),
user_id: user_id.to_string(),
name: name.to_string(),
category: category.to_string(),
color: color.to_string(),
brand: brand.map(String::from),
material: None,
seasons,
occasions,
formality: args["formality"].as_i64().unwrap_or(50) as i32,
thickness: args["thickness"].as_i64().unwrap_or(50) as i32,
image_path: None,
purchase_date: None,
purchase_price: None,
last_cleaned: None,
clean_method: None,
wear_count: 0,
last_worn: None,
is_favorite: false,
created_at: now.clone(),
updated_at: now,
};
self.db.create_clothing(&item).map_err(|e| e.to_string())?;
Ok(serde_json::to_value(item).unwrap())
}
"update_clothing" => {
let id = args["id"].as_str().ok_or("missing id")?;
let name = args["name"].as_str().unwrap_or("");
let category = args["category"].as_str().unwrap_or("");
let color = args["color"].as_str().unwrap_or("");
self.db.update_clothing(id, name, category, color).map_err(|e| e.to_string())?;
Ok(serde_json::json!({"success": true}))
}
"delete_clothing" => {
let id = args["id"].as_str().ok_or("missing id")?;
self.db.delete_clothing(id).map_err(|e| e.to_string())?;
Ok(serde_json::json!({"success": true}))
}
"list_outfits" => {
let user_id = args["user_id"].as_str().ok_or("missing user_id")?;
let outfits = self.db.list_outfits(user_id).map_err(|e| e.to_string())?;
Ok(serde_json::to_value(outfits).unwrap())
}
"add_outfit" => {
let user_id = args["user_id"].as_str().ok_or("missing user_id")?;
let name = args["name"].as_str().ok_or("missing name")?;
let item_ids: Vec<String> = args["item_ids"].as_array()
.ok_or("missing item_ids")?
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect();
let now = chrono::Utc::now().to_rfc3339();
let outfit = Outfit {
id: uuid::Uuid::new_v4().to_string(),
user_id: user_id.to_string(),
name: name.to_string(),
item_ids,
occasion: args["occasion"].as_str().map(String::from),
season: args["season"].as_str().map(String::from),
match_score: None,
ai_analysis: None,
is_saved: false,
created_at: now,
};
self.db.create_outfit(&outfit).map_err(|e| e.to_string())?;
Ok(serde_json::to_value(outfit).unwrap())
}
"get_wardrobe_stats" => {
let user_id = args["user_id"].as_str().ok_or("missing user_id")?;
let stats = self.db.get_stats(user_id).map_err(|e| e.to_string())?;
Ok(serde_json::to_value(stats).unwrap())
}
"record_daily_outfit" => {
let user_id = args["user_id"].as_str().ok_or("missing user_id")?;
let date = args["date"].as_str().ok_or("missing date")?;
let now = chrono::Utc::now().to_rfc3339();
let record = DailyRecord {
id: uuid::Uuid::new_v4().to_string(),
user_id: user_id.to_string(),
date: date.to_string(),
outfit_id: args["outfit_id"].as_str().map(String::from),
weather_temp: args["weather_temp"].as_f64(),
weather_condition: args["weather_condition"].as_str().map(String::from),
notes: args["notes"].as_str().map(String::from),
created_at: now,
};
self.db.upsert_daily_record(&record).map_err(|e| e.to_string())?;
Ok(serde_json::to_value(record).unwrap())
}
"list_daily_records" => {
let user_id = args["user_id"].as_str().ok_or("missing user_id")?;
let limit = args["limit"].as_i64().unwrap_or(30) as i32;
let records = self.db.list_daily_records(user_id, limit).map_err(|e| e.to_string())?;
Ok(serde_json::to_value(records).unwrap())
}
_ => Err(format!("Unknown tool: {}", name)),
}
}
}
#[derive(serde::Serialize)]
pub struct McpTool {
pub name: String,
pub description: String,
pub input_schema: String,
}
+89
View File
@@ -0,0 +1,89 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClothingItem {
pub id: String,
pub user_id: String,
pub name: String,
pub category: String,
pub color: String,
pub brand: Option<String>,
pub material: Option<String>,
pub seasons: Vec<String>,
pub occasions: Vec<String>,
pub formality: i32,
pub thickness: i32,
pub image_path: Option<String>,
pub purchase_date: Option<String>,
pub purchase_price: Option<f64>,
pub last_cleaned: Option<String>,
pub clean_method: Option<String>,
pub wear_count: i32,
pub last_worn: Option<String>,
pub is_favorite: bool,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Outfit {
pub id: String,
pub user_id: String,
pub name: String,
pub item_ids: Vec<String>,
pub occasion: Option<String>,
pub season: Option<String>,
pub match_score: Option<i32>,
pub ai_analysis: Option<String>,
pub is_saved: bool,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DailyRecord {
pub id: String,
pub user_id: String,
pub date: String,
pub outfit_id: Option<String>,
pub weather_temp: Option<f64>,
pub weather_condition: Option<String>,
pub notes: Option<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: String,
pub email: Option<String>,
pub phone: Option<String>,
pub nickname: String,
pub avatar_path: Option<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerificationCode {
pub target: String,
pub code: String,
pub created_at: i64,
pub expires_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthResult {
pub success: bool,
pub message: String,
pub user: Option<User>,
pub token: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WardrobeStats {
pub total_items: i32,
pub total_outfits: i32,
pub utilization_rate: f64,
pub category_counts: std::collections::HashMap<String, i32>,
pub favorite_count: i32,
pub unworn_30d: i32,
pub needs_cleaning: i32,
}