1662 lines
83 KiB
Rust
1662 lines
83 KiB
Rust
use rusqlite::Connection;
|
||
use serde_json::{json, Value};
|
||
use std::sync::Mutex;
|
||
use tauri::Manager;
|
||
|
||
const SCHEMA_SQL: &str = include_str!("../../schema.sql");
|
||
|
||
pub struct AppData {
|
||
conn: Mutex<Connection>,
|
||
data_dir: std::path::PathBuf,
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
enum P {
|
||
S(String),
|
||
I(i64),
|
||
R(f64),
|
||
N,
|
||
}
|
||
|
||
impl rusqlite::ToSql for P {
|
||
fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> {
|
||
use rusqlite::types::{ToSqlOutput, Value as RV};
|
||
let v = match self {
|
||
P::S(s) => RV::Text(s.clone()),
|
||
P::I(i) => RV::Integer(*i),
|
||
P::R(f) => RV::Real(*f),
|
||
P::N => RV::Null,
|
||
};
|
||
Ok(ToSqlOutput::Owned(v))
|
||
}
|
||
}
|
||
|
||
fn pval(v: &Value) -> P {
|
||
match v {
|
||
Value::Number(n) => {
|
||
if let Some(i) = n.as_i64() {
|
||
P::I(i)
|
||
} else {
|
||
P::R(n.as_f64().unwrap_or(0.0))
|
||
}
|
||
}
|
||
Value::String(s) => P::S(s.clone()),
|
||
Value::Bool(b) => P::I(if *b { 1 } else { 0 }),
|
||
_ => P::N,
|
||
}
|
||
}
|
||
|
||
fn pstr(v: &Value) -> P {
|
||
match v {
|
||
Value::String(s) => P::S(s.clone()),
|
||
Value::Number(n) => P::S(n.to_string()),
|
||
_ => P::N,
|
||
}
|
||
}
|
||
|
||
fn rows_to_json(
|
||
conn: &Connection,
|
||
sql: &str,
|
||
params: &[P],
|
||
) -> Result<Vec<Value>, String> {
|
||
let mut stmt = conn.prepare(sql).map_err(e)?;
|
||
let names: Vec<String> = (0..stmt.column_count())
|
||
.map(|i| stmt.column_name(i).unwrap_or("").to_string())
|
||
.collect();
|
||
let mut out = Vec::new();
|
||
let mut rows = stmt
|
||
.query(rusqlite::params_from_iter(params.iter()))
|
||
.map_err(e)?;
|
||
while let Some(row) = rows.next().map_err(e)? {
|
||
let mut obj = serde_json::Map::new();
|
||
for (i, name) in names.iter().enumerate() {
|
||
let v = match row.get_ref(i).map_err(e)? {
|
||
rusqlite::types::ValueRef::Null => Value::Null,
|
||
rusqlite::types::ValueRef::Integer(n) => json!(n),
|
||
rusqlite::types::ValueRef::Real(f) => json!(f),
|
||
rusqlite::types::ValueRef::Text(t) => {
|
||
json!(String::from_utf8_lossy(t).to_string())
|
||
}
|
||
rusqlite::types::ValueRef::Blob(b) => {
|
||
use base64::Engine;
|
||
json!(base64::engine::general_purpose::STANDARD.encode(b))
|
||
}
|
||
};
|
||
obj.insert(name.clone(), v);
|
||
}
|
||
out.push(Value::Object(obj));
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
// 返回最近一次 INSERT 的 rowid(UPDATE/DELETE 的调用方不使用该值)
|
||
fn exec(conn: &Connection, sql: &str, params: &[P]) -> Result<i64, String> {
|
||
let mut stmt = conn.prepare(sql).map_err(e)?;
|
||
stmt.execute(rusqlite::params_from_iter(params.iter())).map_err(e)?;
|
||
Ok(conn.last_insert_rowid())
|
||
}
|
||
|
||
fn e(err: rusqlite::Error) -> String {
|
||
err.to_string()
|
||
}
|
||
|
||
// ---------- 同步基础设施 ----------
|
||
|
||
fn get_meta(conn: &Connection, key: &str) -> Option<String> {
|
||
conn.query_row("SELECT value FROM sync_state WHERE key=?1", [key], |r| r.get(0)).ok()
|
||
}
|
||
|
||
fn set_meta(conn: &Connection, key: &str, value: &str) {
|
||
let _ = conn.execute(
|
||
"INSERT INTO sync_state (key, value) VALUES (?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||
rusqlite::params![key, value],
|
||
);
|
||
}
|
||
|
||
fn get_device_id(conn: &Connection) -> String {
|
||
if let Some(id) = get_meta(conn, "device_id") {
|
||
return id;
|
||
}
|
||
let s = uuid::Uuid::now_v7().simple().to_string();
|
||
let id: String = s.chars().take(12).collect();
|
||
set_meta(conn, "device_id", &id);
|
||
id
|
||
}
|
||
|
||
fn uuid_v7() -> String {
|
||
uuid::Uuid::now_v7().to_string()
|
||
}
|
||
|
||
// 混合逻辑时钟:毫秒:计数:设备 —— 时钟回拨免疫,同设备单调,跨设备可比较
|
||
fn hlc_next(conn: &Connection) -> String {
|
||
let now_ms = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_millis() as i64;
|
||
let last_ms: i64 = get_meta(conn, "hlc_ms").and_then(|v| v.parse().ok()).unwrap_or(0);
|
||
let last_cnt: i64 = get_meta(conn, "hlc_counter").and_then(|v| v.parse().ok()).unwrap_or(0);
|
||
let (new_ms, new_cnt) = if now_ms > last_ms { (now_ms, 0) } else { (last_ms, last_cnt + 1) };
|
||
set_meta(conn, "hlc_ms", &new_ms.to_string());
|
||
set_meta(conn, "hlc_counter", &new_cnt.to_string());
|
||
format!("{}:{}:{}", new_ms, new_cnt, get_device_id(conn))
|
||
}
|
||
|
||
fn touch_oplog(conn: &Connection, table: &str, row_uuid: &str, op: &str, hlc: &str) {
|
||
let _ = conn.execute(
|
||
"INSERT INTO sync_oplog (table_name, row_uuid, op, hlc) VALUES (?1,?2,?3,?4)",
|
||
rusqlite::params![table, row_uuid, op, hlc],
|
||
);
|
||
}
|
||
|
||
const SYNC_TABLES: [&str; 10] = [
|
||
"footprint", "footprint_type", "taxonomy", "dimension", "attachment",
|
||
"semester", "course_grade", "metric_def", "saved_view", "grade_band",
|
||
];
|
||
|
||
fn add_sync_cols(conn: &Connection, t: &str, extra_sha: bool) {
|
||
let exists: i64 = conn
|
||
.query_row("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1", [t], |r| r.get(0))
|
||
.unwrap_or(0);
|
||
if exists == 0 {
|
||
return; // 全新库:表由 schema 创建,自带同步列
|
||
}
|
||
let mut have: Vec<String> = Vec::new();
|
||
if let Ok(mut stmt) = conn.prepare(&format!("PRAGMA table_info({})", t)) {
|
||
if let Ok(mut rows) = stmt.query([]) {
|
||
while let Ok(Some(row)) = rows.next() {
|
||
if let Ok(name) = row.get::<_, String>(1) {
|
||
have.push(name);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let mut cols: Vec<(&str, &str)> = vec![
|
||
("uuid", "TEXT"), ("deleted_at", "TEXT"), ("origin", "TEXT"), ("hlc", "TEXT"),
|
||
];
|
||
if extra_sha {
|
||
cols.push(("sha256", "TEXT"));
|
||
}
|
||
for (name, ddl) in cols {
|
||
if !have.iter().any(|x| x == name) {
|
||
let _ = conn.execute_batch(&format!("ALTER TABLE {} ADD COLUMN {} {}", t, name, ddl));
|
||
}
|
||
}
|
||
}
|
||
|
||
fn pre_migrate_columns(conn: &Connection) {
|
||
for t in SYNC_TABLES {
|
||
add_sync_cols(conn, t, false);
|
||
}
|
||
add_sync_cols(conn, "attachment", true);
|
||
}
|
||
|
||
fn migrate_sync(conn: &Connection) {
|
||
for t in SYNC_TABLES {
|
||
let mut ids: Vec<i64> = Vec::new();
|
||
if let Ok(mut stmt) = conn.prepare(&format!("SELECT id FROM {} WHERE uuid IS NULL", t)) {
|
||
if let Ok(mut rows) = stmt.query([]) {
|
||
while let Ok(Some(row)) = rows.next() {
|
||
if let Ok(id) = row.get::<_, i64>(0) {
|
||
ids.push(id);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for id in ids {
|
||
let uuid = uuid_v7();
|
||
let h = hlc_next(conn);
|
||
let _ = conn.execute(
|
||
&format!("UPDATE {} SET uuid=?1, hlc=?2, origin='migrate' WHERE id=?3", t),
|
||
rusqlite::params![uuid, h, id],
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn soft_delete(conn: &Connection, table: &str, id: i64) {
|
||
let uuid: Option<String> = conn
|
||
.query_row(&format!("SELECT uuid FROM {} WHERE id=?1", table), [id], |r| r.get(0))
|
||
.ok();
|
||
if let Some(uuid) = uuid {
|
||
let h = hlc_next(conn);
|
||
let _ = conn.execute(
|
||
&format!("UPDATE {} SET deleted_at=datetime('now','localtime'), hlc=?1 WHERE id=?2", table),
|
||
rusqlite::params![h, id],
|
||
);
|
||
touch_oplog(conn, table, &uuid, "delete", &h);
|
||
}
|
||
}
|
||
|
||
fn get_row_uuid(conn: &Connection, table: &str, id: i64) -> Option<String> {
|
||
conn.query_row(&format!("SELECT uuid FROM {} WHERE id=?1", table), [id], |r| r.get(0)).ok()
|
||
}
|
||
|
||
// 固定内置 uuid(与 Node 端 BUILTIN_UUID 完全一致;各端互通的对齐前提)
|
||
const FIXED_BUILTIN: [(&str, &str, &str, &str); 28] = [
|
||
("footprint_type", "name", "学业", "018f0000-0000-7000-8000-000000000001"),
|
||
("footprint_type", "name", "竞赛", "018f0000-0000-7000-8000-000000000002"),
|
||
("footprint_type", "name", "科研", "018f0000-0000-7000-8000-000000000003"),
|
||
("footprint_type", "name", "实习实践", "018f0000-0000-7000-8000-000000000004"),
|
||
("footprint_type", "name", "学生工作", "018f0000-0000-7000-8000-000000000005"),
|
||
("footprint_type", "name", "志愿公益", "018f0000-0000-7000-8000-000000000006"),
|
||
("footprint_type", "name", "文体活动", "018f0000-0000-7000-8000-000000000007"),
|
||
("footprint_type", "name", "证书技能", "018f0000-0000-7000-8000-000000000008"),
|
||
("footprint_type", "name", "荣誉惩处", "018f0000-0000-7000-8000-000000000009"),
|
||
("taxonomy", "value", "国际级", "018f0000-0000-7000-8000-000000000011"),
|
||
("taxonomy", "value", "国家级", "018f0000-0000-7000-8000-000000000012"),
|
||
("taxonomy", "value", "省市级", "018f0000-0000-7000-8000-000000000013"),
|
||
("taxonomy", "value", "区县级", "018f0000-0000-7000-8000-000000000014"),
|
||
("taxonomy", "value", "校级", "018f0000-0000-7000-8000-000000000015"),
|
||
("taxonomy", "value", "院级", "018f0000-0000-7000-8000-000000000016"),
|
||
("taxonomy", "value", "主导", "018f0000-0000-7000-8000-000000000017"),
|
||
("taxonomy", "value", "核心", "018f0000-0000-7000-8000-000000000018"),
|
||
("taxonomy", "value", "参加", "018f0000-0000-7000-8000-000000000019"),
|
||
("dimension", "name", "学业深造", "018f0000-0000-7000-8000-000000000021"),
|
||
("dimension", "name", "职业发展", "018f0000-0000-7000-8000-000000000022"),
|
||
("dimension", "name", "综合素养", "018f0000-0000-7000-8000-000000000023"),
|
||
("dimension", "name", "兴趣生活", "018f0000-0000-7000-8000-000000000024"),
|
||
("grade_band", "label", "优秀", "018f0000-0000-7000-8000-000000000031"),
|
||
("grade_band", "label", "良好", "018f0000-0000-7000-8000-000000000032"),
|
||
("grade_band", "label", "中等", "018f0000-0000-7000-8000-000000000033"),
|
||
("grade_band", "label", "及格", "018f0000-0000-7000-8000-000000000034"),
|
||
("grade_band", "label", "不及格", "018f0000-0000-7000-8000-000000000035"),
|
||
("metric_def", "key", "volunteer_hours", "018f0000-0000-7000-8000-000000000041"),
|
||
];
|
||
|
||
fn align_builtin_uuids(conn: &Connection) {
|
||
for (table, col, name, uuid) in FIXED_BUILTIN {
|
||
let sql = format!("UPDATE {} SET uuid=?1 WHERE {}=?2 AND uuid<>?1", table, col);
|
||
let _ = conn.execute(&sql, rusqlite::params![uuid, name]);
|
||
}
|
||
}
|
||
|
||
fn ensure_builtin(conn: &Connection) {
|
||
align_builtin_uuids(conn);
|
||
let cnt: i64 = conn
|
||
.query_row("SELECT COUNT(*) FROM footprint_type WHERE deleted_at IS NULL", [], |r| r.get(0))
|
||
.unwrap_or(0);
|
||
if cnt > 0 {
|
||
align_builtin_uuids(conn);
|
||
return;
|
||
}
|
||
let types = [
|
||
("学业", "graduation-cap", "#3056d3", 10, r#"[]"#),
|
||
("竞赛", "trophy", "#7c3aed", 20, r#"[{"key":"my_work","label":"我的分工","widget":"textarea","required":true},{"key":"deliverable","label":"最终产品","widget":"text"},{"key":"team_members","label":"队伍成员","widget":"text"}]"#),
|
||
("科研", "flask-conical", "#0891b2", 30, r#"[{"key":"authors","label":"作者排序","widget":"text"},{"key":"outcome","label":"成果形式","widget":"select","options":["论文","专利","软件著作权","研究报告"]}]"#),
|
||
("实习实践", "briefcase", "#0f766e", 40, r#"[{"key":"position","label":"岗位","widget":"text"},{"key":"mentor","label":"带教人","widget":"text"}]"#),
|
||
("学生工作", "network", "#d97706", 50, r#"[{"key":"duties","label":"职责","widget":"textarea"},{"key":"term","label":"任期","widget":"text"}]"#),
|
||
("志愿公益", "heart-handshake", "#16a34a", 60, r#"[{"key":"hours","label":"志愿时长","widget":"number","unit":"小时","metric":"volunteer_hours"},{"key":"contact","label":"组织负责人","widget":"text"}]"#),
|
||
("文体活动", "palette", "#db2777", 70, r#"[{"key":"participation","label":"参与形式","widget":"text"}]"#),
|
||
("证书技能", "file-badge", "#64748b", 80, r#"[{"key":"cert_no","label":"证书编号","widget":"text"},{"key":"score","label":"成绩","widget":"text"}]"#),
|
||
("荣誉惩处", "award", "#b45309", 90, r#"[{"key":"issued_by","label":"授予 / 决定单位","widget":"text"}]"#),
|
||
];
|
||
if cnt == 0 {
|
||
let mut up = String::new();
|
||
for (name, icon, color, sort, tpl) in types {
|
||
let fixed = FIXED_BUILTIN.iter().find(|(t, c, n, _)| *t == "footprint_type" && *c == "name" && *n == name);
|
||
if let Some((_, _, _, uuid)) = fixed {
|
||
let _ = conn.execute(
|
||
"INSERT INTO footprint_type (name, icon, color, sort, form_template, is_builtin, uuid, hlc, origin) VALUES (?,?,?,?,?,1,?,?,?)",
|
||
rusqlite::params![name, icon, color, sort, tpl, uuid, "0:0:builtin", "builtin"],
|
||
);
|
||
}
|
||
}
|
||
let levels = [
|
||
("国际级", "#7c3aed", 10), ("国家级", "#dc2626", 20), ("省市级", "#ea580c", 30),
|
||
("区县级", "#2563eb", 40), ("校级", "#16a34a", 50), ("院级", "#0891b2", 60),
|
||
];
|
||
for (v, c, s) in levels {
|
||
let fixed = FIXED_BUILTIN.iter().find(|(t, c, n, _)| *t == "taxonomy" && *n == v);
|
||
if let Some((_, _, _, uuid)) = fixed {
|
||
let _ = conn.execute(
|
||
"INSERT INTO taxonomy (kind, value, color, sort, is_builtin, uuid, hlc, origin) VALUES ('level',?,?,?,1,?,?,?)",
|
||
rusqlite::params![v, c, s, uuid, "0:0:builtin", "builtin"],
|
||
);
|
||
}
|
||
}
|
||
let roles = [("主导", "#3056d3", 10), ("核心", "#0891b2", 20), ("参加", "#64748b", 30)];
|
||
for (v, c, s) in roles {
|
||
let fixed = FIXED_BUILTIN.iter().find(|(t, c, n, _)| *t == "taxonomy" && *n == v);
|
||
if let Some((_, _, _, uuid)) = fixed {
|
||
let _ = conn.execute(
|
||
"INSERT INTO taxonomy (kind, value, color, sort, is_builtin, uuid, hlc, origin) VALUES ('role',?,?,?,1,?,?,?)",
|
||
rusqlite::params![v, c, s, uuid, "0:0:builtin", "builtin"],
|
||
);
|
||
}
|
||
}
|
||
let dims = [
|
||
("学业深造", "#3056d3", 10), ("职业发展", "#0891b2", 20),
|
||
("综合素养", "#16a34a", 30), ("兴趣生活", "#db2777", 40),
|
||
];
|
||
for (v, c, s) in dims {
|
||
let fixed = FIXED_BUILTIN.iter().find(|(t, c, n, _)| *t == "dimension" && *n == v);
|
||
if let Some((_, _, _, uuid)) = fixed {
|
||
let _ = conn.execute(
|
||
"INSERT INTO dimension (name, color, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?)",
|
||
rusqlite::params![v, c, s, uuid, "0:0:builtin", "builtin"],
|
||
);
|
||
}
|
||
}
|
||
let bands = [
|
||
("优秀", "#16803c", 90.0, 100.0, 10), ("良好", "#0891b2", 80.0, 90.0, 20),
|
||
("中等", "#d97706", 70.0, 80.0, 30), ("及格", "#ea580c", 60.0, 70.0, 40),
|
||
("不及格", "#dc2626", 0.0, 60.0, 50),
|
||
];
|
||
for (label, color, min, max, sort) in bands {
|
||
let fixed = FIXED_BUILTIN.iter().find(|(t, c, n, _)| *t == "grade_band" && *n == label);
|
||
if let Some((_, _, _, uuid)) = fixed {
|
||
let _ = conn.execute(
|
||
"INSERT INTO grade_band (label, color, min, max, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)",
|
||
rusqlite::params![label, color, min, max, sort, uuid, "0:0:builtin", "builtin"],
|
||
);
|
||
}
|
||
}
|
||
let h = "0:0:builtin";
|
||
let fixed = FIXED_BUILTIN.iter().find(|(t, c, n, _)| *t == "metric_def" && *n == "volunteer_hours");
|
||
if let Some((_, _, _, uuid)) = fixed {
|
||
let _ = conn.execute(
|
||
"INSERT INTO metric_def (key, label, unit, agg, section, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)",
|
||
rusqlite::params!["volunteer_hours", "志愿时长", "小时", "sum", "志愿服务", uuid, h, "builtin"],
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn map_row(v: &mut Value) {
|
||
if let Some(obj) = v.as_object_mut() {
|
||
for key in ["metrics", "extras"] {
|
||
if let Some(Value::String(s)) = obj.get(key) {
|
||
let parsed: Value = serde_json::from_str(s).unwrap_or(json!({}));
|
||
obj.insert(key.to_string(), parsed);
|
||
}
|
||
}
|
||
if let Some(Value::String(s)) = obj.get("tags") {
|
||
let parsed: Value = serde_json::from_str(s).unwrap_or(json!([]));
|
||
obj.insert("tags".into(), parsed);
|
||
}
|
||
if let Some(Value::String(s)) = obj.get("dim_ids") {
|
||
let dims: Vec<Value> = s
|
||
.split(',')
|
||
.filter_map(|x| x.trim().parse::<i64>().ok())
|
||
.map(|x| json!(x))
|
||
.collect();
|
||
obj.insert("dimensions".into(), json!(dims));
|
||
} else {
|
||
obj.insert("dimensions".into(), json!([]));
|
||
}
|
||
obj.remove("dim_ids");
|
||
}
|
||
}
|
||
|
||
// 中文检索:长度>=3 的词走 FTS5 trigram(子串匹配),更短的词回退 LIKE
|
||
|
||
const BASE_SELECT: &str = "SELECT fp.*, ft.name AS type_name, ft.color AS type_color, ft.icon AS type_icon, (SELECT COUNT(*) FROM attachment a WHERE a.owner_type='footprint' AND a.owner_id=fp.id AND a.deleted_at IS NULL) AS attachment_count, (SELECT GROUP_CONCAT(dimension_id) FROM footprint_dimension fd WHERE fd.footprint_id=fp.id) AS dim_ids FROM footprint fp JOIN footprint_type ft ON ft.id = fp.type_id";
|
||
|
||
fn build_where(query: &Value) -> (String, Vec<P>) {
|
||
let mut conds: Vec<String> = Vec::new();
|
||
let mut params: Vec<P> = Vec::new();
|
||
let mut joins = String::new();
|
||
|
||
if let Some(q) = query.get("q").and_then(|v| v.as_str()) {
|
||
let mut fts_terms: Vec<String> = Vec::new();
|
||
for raw in q.split_whitespace() {
|
||
let clean: String = raw.chars().filter(|c| *c != '"' && *c != '\\').collect();
|
||
if clean.trim().is_empty() {
|
||
continue;
|
||
}
|
||
if clean.chars().count() >= 3 {
|
||
fts_terms.push(format!("\"{}\"", clean));
|
||
} else {
|
||
conds.push("fp.search_text LIKE ?".into());
|
||
params.push(P::S(format!("%{}%", clean)));
|
||
}
|
||
}
|
||
if !fts_terms.is_empty() {
|
||
joins.push_str(" JOIN footprint_fts ON footprint_fts.rowid = fp.id");
|
||
conds.push("footprint_fts MATCH ?".into());
|
||
params.push(P::S(fts_terms.join(" AND ")));
|
||
}
|
||
}
|
||
for (key, col) in [("types", "fp.type_id"), ("levels", "fp.level"), ("roles", "fp.role"), ("statuses", "fp.status")] {
|
||
if let Some(arr) = query.get(key).and_then(|v| v.as_array()) {
|
||
if !arr.is_empty() {
|
||
let ph = arr.iter().map(|_| "?").collect::<Vec<_>>().join(",");
|
||
conds.push(format!("{} IN ({})", col, ph));
|
||
for v in arr {
|
||
params.push(if col.ends_with("type_id") { pval(v) } else { pstr(v) });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if let Some(arr) = query.get("dims").and_then(|v| v.as_array()) {
|
||
if !arr.is_empty() {
|
||
let ph = arr.iter().map(|_| "?").collect::<Vec<_>>().join(",");
|
||
conds.push(format!(
|
||
"fp.id IN (SELECT footprint_id FROM footprint_dimension WHERE dimension_id IN ({}))",
|
||
ph
|
||
));
|
||
for v in arr {
|
||
params.push(pval(v));
|
||
}
|
||
}
|
||
}
|
||
if let Some(y) = query.get("year").and_then(|v| v.as_i64()) {
|
||
conds.push("strftime('%Y', COALESCE(NULLIF(fp.start_date,''), substr(fp.created_at,1,10))) = ?".into());
|
||
params.push(P::S(y.to_string()));
|
||
}
|
||
if let Some(p) = query.get("polarity").and_then(|v| v.as_str()) {
|
||
conds.push("fp.polarity = ?".into());
|
||
params.push(P::S(p.to_string()));
|
||
}
|
||
if query.get("has_attachment").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||
conds.push("EXISTS (SELECT 1 FROM attachment a WHERE a.owner_type='footprint' AND a.owner_id=fp.id)".into());
|
||
}
|
||
if let Some(t) = query.get("tag").and_then(|v| v.as_str()) {
|
||
conds.push("fp.tags LIKE ?".into());
|
||
params.push(P::S(format!("%\"{}\"", t)));
|
||
}
|
||
let where_sql = if conds.is_empty() {
|
||
" WHERE fp.deleted_at IS NULL".to_string()
|
||
} else {
|
||
format!(" WHERE fp.deleted_at IS NULL AND {}", conds.join(" AND "))
|
||
};
|
||
(format!("{}{}", joins, where_sql), params)
|
||
}
|
||
|
||
const SORTS: [(&str, &str); 4] = [
|
||
("date_desc", "COALESCE(NULLIF(fp.start_date,''), substr(fp.created_at,1,10)) DESC, fp.id DESC"),
|
||
("date_asc", "COALESCE(NULLIF(fp.start_date,''), substr(fp.created_at,1,10)) ASC, fp.id ASC"),
|
||
("updated_desc", "fp.updated_at DESC, fp.id DESC"),
|
||
("weight_desc", "fp.weight DESC, COALESCE(NULLIF(fp.start_date,''), substr(fp.created_at,1,10)) DESC"),
|
||
];
|
||
|
||
fn list_footprints(conn: &Connection, query: &Value) -> Result<Value, String> {
|
||
let (where_join, params) = build_where(query);
|
||
let page = query.get("page").and_then(|v| v.as_i64()).unwrap_or(1).max(1);
|
||
let page_size = query.get("page_size").and_then(|v| v.as_i64()).unwrap_or(20).clamp(1, 2000);
|
||
let sort = query.get("sort").and_then(|v| v.as_str()).unwrap_or("date_desc");
|
||
let order = SORTS.iter().find(|(k, _)| *k == sort).map(|(_, v)| *v).unwrap_or(SORTS[0].1);
|
||
|
||
let total: i64 = conn
|
||
.query_row(
|
||
&format!("SELECT COUNT(*) FROM footprint fp{}", where_join),
|
||
rusqlite::params_from_iter(params.iter()),
|
||
|r| r.get(0),
|
||
)
|
||
.map_err(e)?;
|
||
|
||
let sql = format!(
|
||
"{} {} ORDER BY {} LIMIT {} OFFSET {}",
|
||
BASE_SELECT,
|
||
where_join,
|
||
order,
|
||
page_size,
|
||
(page - 1) * page_size
|
||
);
|
||
let mut items = rows_to_json(conn, &sql, ¶ms)?;
|
||
for it in items.iter_mut() {
|
||
map_row(it);
|
||
}
|
||
Ok(json!({ "items": items, "total": total, "page": page, "page_size": page_size }))
|
||
}
|
||
|
||
fn num_or_null(v: &Value) -> P {
|
||
match v {
|
||
Value::Number(_) => pval(v),
|
||
Value::String(s) if !s.is_empty() => s.parse::<i64>().map(P::I).unwrap_or(P::N),
|
||
_ => P::N,
|
||
}
|
||
}
|
||
|
||
fn s_or_null(v: &Value) -> P {
|
||
match v {
|
||
Value::String(s) if !s.is_empty() => P::S(s.clone()),
|
||
Value::Number(n) => P::S(n.to_string()),
|
||
_ => P::N,
|
||
}
|
||
}
|
||
|
||
fn dispatch(state: &AppData, cmd: &str, payload: &Value) -> Result<Value, String> {
|
||
let conn = state.conn.lock().map_err(|_| "数据库忙".to_string())?;
|
||
|
||
match cmd {
|
||
"app_info" => {
|
||
let pending: i64 = conn.query_row("SELECT COUNT(*) FROM sync_oplog WHERE synced_at IS NULL", [], |r| r.get(0)).unwrap_or(0);
|
||
Ok(json!({
|
||
"app_name": "Footprints 足迹",
|
||
"version": "0.2.0",
|
||
"db_path": state.data_dir.join("footprints.db").to_string_lossy(),
|
||
"attachment_dir": state.data_dir.join("attachments").to_string_lossy(),
|
||
"schema_ok": true,
|
||
"device_id": get_device_id(&conn),
|
||
"pending_ops": pending,
|
||
"last_sync_at": get_meta(&conn, "last_sync_at")
|
||
}))
|
||
}
|
||
|
||
"sync_status" => {
|
||
let pending: i64 = conn.query_row("SELECT COUNT(*) FROM sync_oplog WHERE synced_at IS NULL", [], |r| r.get(0)).unwrap_or(0);
|
||
Ok(json!({
|
||
"mode": "local",
|
||
"device_id": get_device_id(&conn),
|
||
"pending_ops": pending,
|
||
"last_sync_at": get_meta(&conn, "last_sync_at")
|
||
}))
|
||
}
|
||
|
||
"type_list" => {
|
||
let mut items = rows_to_json(
|
||
&conn,
|
||
"SELECT t.*, (SELECT COUNT(*) FROM footprint f WHERE f.type_id = t.id AND f.deleted_at IS NULL) AS usage_count FROM footprint_type t WHERE t.deleted_at IS NULL ORDER BY t.sort, t.id",
|
||
&[],
|
||
)?;
|
||
for it in items.iter_mut() {
|
||
if let Some(obj) = it.as_object_mut() {
|
||
if let Some(Value::String(s)) = obj.get("form_template") {
|
||
let parsed: Value = serde_json::from_str(s).unwrap_or(json!([]));
|
||
obj.insert("form_template".into(), parsed);
|
||
}
|
||
for k in ["is_builtin", "closed"] {
|
||
let b = obj.get(k).map(|v| v.as_i64().unwrap_or(0) != 0).unwrap_or(false);
|
||
obj.insert(k.into(), json!(b));
|
||
}
|
||
}
|
||
}
|
||
Ok(json!({ "items": items }))
|
||
}
|
||
|
||
"type_upsert" => {
|
||
let name = payload.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||
if name.trim().is_empty() {
|
||
return Err("类型名称不能为空".into());
|
||
}
|
||
let tpl = serde_json::to_string(payload.get("form_template").unwrap_or(&json!([]))).unwrap();
|
||
let icon = payload.get("icon").and_then(|v| v.as_str()).unwrap_or("tag");
|
||
let color = payload.get("color").and_then(|v| v.as_str()).unwrap_or("#64748b");
|
||
let sort = payload.get("sort").and_then(|v| v.as_i64()).unwrap_or(100);
|
||
let closed = payload.get("closed").and_then(|v| v.as_bool()).unwrap_or(false);
|
||
if let Some(id) = payload.get("id").and_then(|v| v.as_i64()) {
|
||
let h = hlc_next(&conn);
|
||
exec(&conn,
|
||
"UPDATE footprint_type SET name=?, icon=?, color=?, sort=?, form_template=?, closed=?, hlc=? WHERE id=?",
|
||
&[P::S(name.trim().into()), P::S(icon.into()), P::S(color.into()), P::I(sort), P::S(tpl), P::I(closed as i64), P::S(h.clone()), P::I(id)])?;
|
||
if let Some(u) = get_row_uuid(&conn, "footprint_type", id) {
|
||
touch_oplog(&conn, "footprint_type", &u, "upsert", &h);
|
||
}
|
||
Ok(json!({ "item": { "id": id } }))
|
||
} else {
|
||
let h = hlc_next(&conn);
|
||
let new_uuid = uuid_v7();
|
||
let r = exec(&conn,
|
||
"INSERT INTO footprint_type (name, icon, color, sort, form_template, closed, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?,?)",
|
||
&[P::S(name.trim().into()), P::S(icon.into()), P::S(color.into()), P::I(sort), P::S(tpl), P::I(closed as i64), P::S(new_uuid.clone()), P::S(h.clone()), P::S(get_device_id(&conn))])?;
|
||
touch_oplog(&conn, "footprint_type", &new_uuid, "upsert", &h);
|
||
Ok(json!({ "item": { "id": r as i64 } }))
|
||
}
|
||
}
|
||
|
||
"type_delete" => {
|
||
let id = payload.get("id").and_then(|v| v.as_i64()).unwrap_or(0);
|
||
let used: i64 = conn
|
||
.query_row("SELECT COUNT(*) FROM footprint WHERE type_id=?1 AND deleted_at IS NULL", [id], |r| r.get(0))
|
||
.map_err(e)?;
|
||
if used > 0 {
|
||
return Err(format!("该类型下仍有 {} 条记录,请先迁移后再删除", used));
|
||
}
|
||
let row: Option<(i64, Option<String>)> = conn
|
||
.query_row("SELECT is_builtin, uuid FROM footprint_type WHERE id=?1", [id], |r| {
|
||
Ok((r.get(0)?, r.get(1)?))
|
||
})
|
||
.ok();
|
||
if let Some((builtin, _)) = row {
|
||
if builtin != 0 {
|
||
return Err("内置类型不可删除".into());
|
||
}
|
||
soft_delete(&conn, "footprint_type", id);
|
||
}
|
||
Ok(json!({ "ok": true }))
|
||
}
|
||
|
||
"taxonomy_list" => {
|
||
let items = match payload.get("kind").and_then(|v| v.as_str()) {
|
||
Some(kind) => rows_to_json(&conn, "SELECT * FROM taxonomy WHERE kind=?1 AND deleted_at IS NULL ORDER BY sort, id", &[P::S(kind.into())])?,
|
||
None => rows_to_json(&conn, "SELECT * FROM taxonomy WHERE deleted_at IS NULL ORDER BY kind, sort, id", &[])?,
|
||
};
|
||
let items: Vec<Value> = items
|
||
.into_iter()
|
||
.map(|mut it| {
|
||
if let Some(obj) = it.as_object_mut() {
|
||
let b = obj.get("is_builtin").map(|v| v.as_i64().unwrap_or(0) != 0).unwrap_or(false);
|
||
obj.insert("is_builtin".into(), json!(b));
|
||
}
|
||
it
|
||
})
|
||
.collect();
|
||
Ok(json!({ "items": items }))
|
||
}
|
||
|
||
"taxonomy_upsert" => {
|
||
let kind = payload.get("kind").and_then(|v| v.as_str()).unwrap_or("level");
|
||
let value = payload.get("value").and_then(|v| v.as_str()).unwrap_or("");
|
||
let color = payload.get("color").and_then(|v| v.as_str());
|
||
let sort = payload.get("sort").and_then(|v| v.as_i64()).unwrap_or(100);
|
||
if let Some(id) = payload.get("id").and_then(|v| v.as_i64()) {
|
||
let h = hlc_next(&conn);
|
||
exec(&conn, "UPDATE taxonomy SET kind=?, value=?, color=?, sort=?, hlc=? WHERE id=?",
|
||
&[P::S(kind.into()), P::S(value.into()), s_or_null(&json!(color)), P::I(sort), P::S(h.clone()), P::I(id)])?;
|
||
if let Some(u) = get_row_uuid(&conn, "taxonomy", id) {
|
||
touch_oplog(&conn, "taxonomy", &u, "upsert", &h);
|
||
}
|
||
Ok(json!({ "item": { "id": id } }))
|
||
} else {
|
||
let h = hlc_next(&conn);
|
||
let new_uuid = uuid_v7();
|
||
let r = exec(&conn, "INSERT INTO taxonomy (kind, value, color, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?)",
|
||
&[P::S(kind.into()), P::S(value.into()), s_or_null(&json!(color)), P::I(sort), P::S(new_uuid.clone()), P::S(h.clone()), P::S(get_device_id(&conn))])?;
|
||
touch_oplog(&conn, "taxonomy", &new_uuid, "upsert", &h);
|
||
Ok(json!({ "item": { "id": r as i64 } }))
|
||
}
|
||
}
|
||
|
||
"taxonomy_delete" => {
|
||
let id = payload.get("id").and_then(|v| v.as_i64()).unwrap_or(0);
|
||
let builtin: i64 = conn
|
||
.query_row("SELECT is_builtin FROM taxonomy WHERE id=?1", [id], |r| r.get(0))
|
||
.map_err(e)?;
|
||
if builtin != 0 {
|
||
return Err("内置条目不可删除".into());
|
||
}
|
||
soft_delete(&conn, "taxonomy", id);
|
||
Ok(json!({ "ok": true }))
|
||
}
|
||
|
||
"dimension_list" => Ok(json!({ "items": rows_to_json(&conn, "SELECT * FROM dimension WHERE deleted_at IS NULL ORDER BY sort, id", &[])? })),
|
||
|
||
"dimension_upsert" => {
|
||
let name = payload.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||
let color = payload.get("color").and_then(|v| v.as_str());
|
||
let sort = payload.get("sort").and_then(|v| v.as_i64()).unwrap_or(100);
|
||
if let Some(id) = payload.get("id").and_then(|v| v.as_i64()) {
|
||
let h = hlc_next(&conn);
|
||
exec(&conn, "UPDATE dimension SET name=?, color=?, sort=?, hlc=? WHERE id=?",
|
||
&[P::S(name.into()), s_or_null(&json!(color)), P::I(sort), P::S(h.clone()), P::I(id)])?;
|
||
if let Some(u) = get_row_uuid(&conn, "dimension", id) {
|
||
touch_oplog(&conn, "dimension", &u, "upsert", &h);
|
||
}
|
||
Ok(json!({ "item": { "id": id } }))
|
||
} else {
|
||
let h = hlc_next(&conn);
|
||
let new_uuid = uuid_v7();
|
||
let r = exec(&conn, "INSERT INTO dimension (name, color, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?)",
|
||
&[P::S(name.into()), s_or_null(&json!(color)), P::I(sort), P::S(new_uuid.clone()), P::S(h.clone()), P::S(get_device_id(&conn))])?;
|
||
touch_oplog(&conn, "dimension", &new_uuid, "upsert", &h);
|
||
Ok(json!({ "item": { "id": r as i64 } }))
|
||
}
|
||
}
|
||
|
||
"dimension_delete" => {
|
||
soft_delete(&conn, "dimension", payload.get("id").and_then(|v| v.as_i64()).unwrap_or(0));
|
||
Ok(json!({ "ok": true }))
|
||
}
|
||
|
||
"footprint_list" => list_footprints(&conn, payload.get("query").unwrap_or(&json!({}))),
|
||
|
||
"footprint_get" => {
|
||
let id = payload.get("id").and_then(|v| v.as_i64()).unwrap_or(0);
|
||
let mut row = rows_to_json(&conn, &format!("{} AND fp.id=?1", BASE_SELECT), &[P::I(id)])?
|
||
.into_iter().next()
|
||
.ok_or_else(|| "记录不存在".to_string())?;
|
||
map_row(&mut row);
|
||
let attachments = rows_to_json(&conn,
|
||
"SELECT * FROM attachment WHERE owner_type='footprint' AND owner_id=?1 AND deleted_at IS NULL ORDER BY id", &[P::I(id)])?;
|
||
if let Some(obj) = row.as_object_mut() {
|
||
obj.insert("attachments".into(), json!(attachments));
|
||
}
|
||
Ok(json!({ "item": row }))
|
||
}
|
||
|
||
"footprint_upsert" => {
|
||
let default_item = json!({});
|
||
let it = payload.get("item").unwrap_or(&default_item);
|
||
let type_id = it.get("type_id").and_then(|v| v.as_i64())
|
||
.ok_or_else(|| "缺少记录类型".to_string())?;
|
||
let title = it.get("title").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
|
||
if title.is_empty() {
|
||
return Err("缺少标题".into());
|
||
}
|
||
let sget = |k: &str| it.get(k).and_then(|v| v.as_str()).map(|s| s.to_string());
|
||
let description = sget("description").unwrap_or_default();
|
||
let result = sget("result").unwrap_or_default();
|
||
let tags: Vec<String> = it.get("tags").and_then(|v| v.as_array())
|
||
.map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
|
||
.unwrap_or_default();
|
||
let extras = it.get("extras").cloned().unwrap_or(json!({}));
|
||
let metrics = it.get("metrics").cloned().unwrap_or(json!({}));
|
||
|
||
let mut search_parts: Vec<String> = vec![title.clone(), description.clone(), result.clone(), tags.join(" ")];
|
||
if let Some(obj) = extras.as_object() {
|
||
for (_, v) in obj {
|
||
match v {
|
||
Value::String(s) => search_parts.push(s.clone()),
|
||
Value::Array(a) => search_parts.push(a.iter().map(|x| x.to_string()).collect::<Vec<_>>().join(" ")),
|
||
Value::Number(n) => search_parts.push(n.to_string()),
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
let search_text = search_parts.join("\n");
|
||
|
||
let tags_json = serde_json::to_string(&tags).unwrap();
|
||
let id = it.get("id").and_then(|v| v.as_i64());
|
||
|
||
let final_id = match id {
|
||
Some(fid) => {
|
||
let h = hlc_next(&conn);
|
||
exec(&conn, &format!(
|
||
"UPDATE footprint SET type_id=?1, title=?2, role=?3, level=?4, polarity=?5, award_grade=?6, rank=?7, organizer=?8, start_date=?9, end_date=?10, status=?11, description=?12, result=?13, metrics=?14, extras=?15, group_id=?16, parent_id=?17, tags=?18, weight=?19, search_text=?20, hlc=?21, updated_at=datetime('now','localtime') WHERE id=?22"),
|
||
&[P::I(type_id), P::S(title.clone()),
|
||
s_or_null(it.get("role").unwrap_or(&Value::Null)),
|
||
s_or_null(it.get("level").unwrap_or(&Value::Null)),
|
||
P::S(sget("polarity").unwrap_or_else(|| "positive".into())),
|
||
s_or_null(it.get("award_grade").unwrap_or(&Value::Null)),
|
||
num_or_null(it.get("rank").unwrap_or(&Value::Null)),
|
||
s_or_null(it.get("organizer").unwrap_or(&Value::Null)),
|
||
s_or_null(it.get("start_date").unwrap_or(&Value::Null)),
|
||
s_or_null(it.get("end_date").unwrap_or(&Value::Null)),
|
||
P::S(sget("status").unwrap_or_else(|| "draft".into())),
|
||
P::S(description.clone()), P::S(result.clone()),
|
||
P::S(serde_json::to_string(&metrics).unwrap()),
|
||
P::S(serde_json::to_string(&extras).unwrap()),
|
||
s_or_null(it.get("group_id").unwrap_or(&Value::Null)),
|
||
num_or_null(it.get("parent_id").unwrap_or(&Value::Null)),
|
||
P::S(tags_json), P::I(it.get("weight").and_then(|v| v.as_i64()).unwrap_or(3)),
|
||
P::S(search_text), P::S(h.clone()), P::I(fid)])?;
|
||
if let Some(u) = get_row_uuid(&conn, "footprint", fid) {
|
||
touch_oplog(&conn, "footprint", &u, "upsert", &h);
|
||
}
|
||
fid
|
||
}
|
||
None => {
|
||
let h = hlc_next(&conn);
|
||
let new_uuid = uuid_v7();
|
||
let r = exec(&conn,
|
||
"INSERT INTO footprint (type_id, title, role, level, polarity, award_grade, rank, organizer, start_date, end_date, status, description, result, metrics, extras, group_id, parent_id, tags, weight, search_text, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||
&[P::I(type_id), P::S(title.clone()),
|
||
s_or_null(it.get("role").unwrap_or(&Value::Null)),
|
||
s_or_null(it.get("level").unwrap_or(&Value::Null)),
|
||
P::S(sget("polarity").unwrap_or_else(|| "positive".into())),
|
||
s_or_null(it.get("award_grade").unwrap_or(&Value::Null)),
|
||
num_or_null(it.get("rank").unwrap_or(&Value::Null)),
|
||
s_or_null(it.get("organizer").unwrap_or(&Value::Null)),
|
||
s_or_null(it.get("start_date").unwrap_or(&Value::Null)),
|
||
s_or_null(it.get("end_date").unwrap_or(&Value::Null)),
|
||
P::S(sget("status").unwrap_or_else(|| "draft".into())),
|
||
P::S(description.clone()), P::S(result.clone()),
|
||
P::S(serde_json::to_string(&metrics).unwrap()),
|
||
P::S(serde_json::to_string(&extras).unwrap()),
|
||
s_or_null(it.get("group_id").unwrap_or(&Value::Null)),
|
||
num_or_null(it.get("parent_id").unwrap_or(&Value::Null)),
|
||
P::S(tags_json), P::I(it.get("weight").and_then(|v| v.as_i64()).unwrap_or(3)),
|
||
P::S(search_text), P::S(new_uuid.clone()), P::S(h.clone()), P::S(get_device_id(&conn))])?;
|
||
touch_oplog(&conn, "footprint", &new_uuid, "upsert", &h);
|
||
r as i64
|
||
}
|
||
};
|
||
|
||
if let Some(dims) = it.get("dimension_ids").and_then(|v| v.as_array()) {
|
||
exec(&conn, "DELETE FROM footprint_dimension WHERE footprint_id=?1", &[P::I(final_id)])?;
|
||
for d in dims {
|
||
if let Some(did) = d.as_i64() {
|
||
exec(&conn, "INSERT OR IGNORE INTO footprint_dimension (footprint_id, dimension_id) VALUES (?1,?2)",
|
||
&[P::I(final_id), P::I(did)])?;
|
||
}
|
||
}
|
||
}
|
||
Ok(json!({ "id": final_id }))
|
||
}
|
||
|
||
"footprint_delete" => {
|
||
let id = payload.get("id").and_then(|v| v.as_i64()).unwrap_or(0);
|
||
let exists: i64 = conn
|
||
.query_row("SELECT COUNT(*) FROM footprint WHERE id=?1", [id], |r| r.get(0))
|
||
.unwrap_or(0);
|
||
if exists > 0 {
|
||
// FTS 内容表同步移除,墓碑保留在主表
|
||
let _ = conn.execute(
|
||
"INSERT INTO footprint_fts(footprint_fts, rowid, search_text) SELECT 'delete', id, search_text FROM footprint WHERE id=?1",
|
||
[id],
|
||
);
|
||
soft_delete(&conn, "footprint", id);
|
||
}
|
||
Ok(json!({ "ok": true }))
|
||
}
|
||
|
||
"timeline" => {
|
||
let mut q = payload.get("query").cloned().unwrap_or(json!({}));
|
||
if let Some(obj) = q.as_object_mut() {
|
||
obj.insert("page_size".into(), json!(2000));
|
||
obj.insert("sort".into(), json!("date_desc"));
|
||
}
|
||
let res = list_footprints(&conn, &q)?;
|
||
let items = res.get("items").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||
let mut groups: Vec<Value> = Vec::new();
|
||
for row in items {
|
||
let date = row.get("start_date").and_then(|v| v.as_str())
|
||
.filter(|s| !s.is_empty())
|
||
.map(|s| s.chars().take(10).collect::<String>())
|
||
.or_else(|| row.get("created_at").and_then(|v| v.as_str()).map(|s| s.chars().take(10).collect::<String>()))
|
||
.unwrap_or_default();
|
||
if date.len() < 10 { continue; }
|
||
let year: i64 = date[..4].parse().unwrap_or(0);
|
||
let month: i64 = date[5..7].parse().unwrap_or(0);
|
||
let g = groups.iter_mut().find(|g| g.get("year").and_then(|v| v.as_i64()) == Some(year));
|
||
match g {
|
||
Some(g) => {
|
||
if let Some(obj) = g.as_object_mut() {
|
||
*obj.get_mut("count").unwrap() = json!(obj.get("count").and_then(|v| v.as_i64()).unwrap_or(0) + 1);
|
||
let months = obj.get_mut("months").and_then(|v| v.as_array_mut()).unwrap();
|
||
match months.iter_mut().find(|m| m.get("month").and_then(|v| v.as_i64()) == Some(month)) {
|
||
Some(m) => m.as_object_mut().unwrap().get_mut("items").unwrap().as_array_mut().unwrap().push(row),
|
||
None => months.push(json!({ "month": month, "items": [row] })),
|
||
}
|
||
}
|
||
}
|
||
None => groups.push(json!({ "year": year, "count": 1, "months": [{ "month": month, "items": [row] }] })),
|
||
}
|
||
}
|
||
groups.sort_by(|a, b| b.get("year").and_then(|v| v.as_i64()).cmp(&a.get("year").and_then(|v| v.as_i64())));
|
||
let total = res.get("total").cloned().unwrap_or(json!(0));
|
||
Ok(json!({ "groups": groups, "total": total }))
|
||
}
|
||
|
||
"stats_overview" => {
|
||
let year = payload.get("year").and_then(|v| v.as_i64());
|
||
let ycond = year
|
||
.map(|y| format!(" AND strftime('%Y', COALESCE(NULLIF(fp.start_date,''), substr(fp.created_at,1,10))) = '{}'", y))
|
||
.unwrap_or_default();
|
||
let ycond_raw = year
|
||
.map(|y| format!(" AND strftime('%Y', COALESCE(NULLIF(start_date,''), substr(created_at,1,10))) = '{}'", y))
|
||
.unwrap_or_default();
|
||
|
||
let total: i64 = conn.query_row(&format!("SELECT COUNT(*) FROM footprint fp WHERE fp.deleted_at IS NULL{}", ycond), [], |r| r.get(0)).map_err(e)?;
|
||
let cur_year = year.unwrap_or_else(|| chrono_year());
|
||
let total_this_year: i64 = conn.query_row(
|
||
"SELECT COUNT(*) FROM footprint fp WHERE strftime('%Y', COALESCE(NULLIF(fp.start_date,''), substr(fp.created_at,1,10))) = ?1",
|
||
[cur_year], |r| r.get(0)).map_err(e)?;
|
||
let attachment_total: i64 = conn.query_row(&format!(
|
||
"SELECT COUNT(*) FROM attachment a JOIN footprint fp ON fp.id=a.owner_id AND a.owner_type='footprint' WHERE a.deleted_at IS NULL AND fp.deleted_at IS NULL{}", ycond), [], |r| r.get(0)).map_err(e)?;
|
||
|
||
let by_type = rows_to_json(&conn, &format!(
|
||
"SELECT ft.name AS label, ft.color AS color, COUNT(*) AS count FROM footprint fp JOIN footprint_type ft ON ft.id=fp.type_id WHERE fp.deleted_at IS NULL{} GROUP BY fp.type_id ORDER BY count DESC", ycond), &[])?;
|
||
let by_level = rows_to_json(&conn, &format!(
|
||
"SELECT COALESCE(fp.level,'未设置') AS label, COUNT(*) AS count FROM footprint fp WHERE fp.deleted_at IS NULL{} GROUP BY fp.level ORDER BY count DESC", ycond), &[])?;
|
||
let by_role = rows_to_json(&conn, &format!(
|
||
"SELECT COALESCE(fp.role,'未设置') AS label, COUNT(*) AS count FROM footprint fp WHERE fp.deleted_at IS NULL{} GROUP BY fp.role ORDER BY count DESC", ycond), &[])?;
|
||
let by_status = rows_to_json(&conn, &format!(
|
||
"SELECT fp.status AS label, COUNT(*) AS count FROM footprint fp WHERE fp.deleted_at IS NULL{} GROUP BY fp.status", ycond), &[])?;
|
||
|
||
let monthly_rows = rows_to_json(&conn, &format!(
|
||
"SELECT substr(COALESCE(NULLIF(start_date,''), substr(created_at,1,10)),1,7) AS ym, COUNT(*) AS count FROM footprint WHERE deleted_at IS NULL{} GROUP BY ym ORDER BY ym", ycond_raw), &[])?;
|
||
let monthly = {
|
||
let map: std::collections::HashMap<String, i64> = monthly_rows.iter().filter_map(|r| {
|
||
Some((r.get("ym")?.as_str()?.to_string(), r.get("count")?.as_i64()?))
|
||
}).collect();
|
||
let now = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
|
||
// 以本地时间近似(UTC+8)
|
||
let dt = now + 8 * 3600;
|
||
let days = dt / 86400;
|
||
let mut base_year = 1970i64;
|
||
let mut rem = days as i64;
|
||
loop {
|
||
let leap = (base_year % 4 == 0 && base_year % 100 != 0) || base_year % 400 == 0;
|
||
let len = if leap { 366 } else { 365 };
|
||
if rem < len { break; }
|
||
rem -= len;
|
||
base_year += 1;
|
||
}
|
||
let leap = (base_year % 4 == 0 && base_year % 100 != 0) || base_year % 400 == 0;
|
||
let mlens = [31, if leap {29} else {28}, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||
let mut month_idx = 0;
|
||
for len in mlens {
|
||
if rem < len { break; }
|
||
rem -= len;
|
||
month_idx += 1;
|
||
}
|
||
let mut out = Vec::new();
|
||
for i in (0..12).rev() {
|
||
let mut m = month_idx - i;
|
||
let mut y = base_year;
|
||
while m <= 0 { m += 12; y -= 1; }
|
||
let ym = format!("{:04}-{:02}", y, m);
|
||
out.push(json!({ "ym": ym, "count": map.get(&ym).copied().unwrap_or(0) }));
|
||
}
|
||
out
|
||
};
|
||
|
||
let mut metrics = Vec::new();
|
||
let defs = rows_to_json(&conn, "SELECT * FROM metric_def WHERE deleted_at IS NULL ORDER BY id", &[])?;
|
||
for m in defs {
|
||
let key = m.get("key").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||
let path = format!("$\"{}\"", key);
|
||
let agg = m.get("agg").and_then(|v| v.as_str()).unwrap_or("sum").to_string();
|
||
let value: f64 = match agg.as_str() {
|
||
"sum" => conn.query_row(&format!(
|
||
"SELECT SUM(CAST(json_extract(fp.metrics, ?1) AS REAL)) FROM footprint fp WHERE json_extract(fp.metrics, ?1) IS NOT NULL AND fp.deleted_at IS NULL{}",
|
||
ycond), [&path], |r| r.get::<_, Option<f64>>(0)).map(|v| v.unwrap_or(0.0)).unwrap_or(0.0),
|
||
"count" => conn.query_row(&format!(
|
||
"SELECT COUNT(*) FROM footprint fp WHERE json_extract(fp.metrics, ?1) IS NOT NULL AND fp.deleted_at IS NULL{}", ycond),
|
||
[&path], |r| r.get::<_, i64>(0)).map(|v| v as f64).unwrap_or(0.0),
|
||
_ => conn.query_row(&format!(
|
||
"SELECT COUNT(DISTINCT json_extract(fp.metrics, ?1)) FROM footprint fp WHERE json_extract(fp.metrics, ?1) IS NOT NULL AND fp.deleted_at IS NULL{}",
|
||
ycond), [&path], |r| r.get::<_, i64>(0)).map(|v| v as f64).unwrap_or(0.0),
|
||
};
|
||
metrics.push(json!({
|
||
"key": key,
|
||
"label": m.get("label").cloned().unwrap_or(json!(key.clone())),
|
||
"unit": m.get("unit").cloned().unwrap_or(Value::Null),
|
||
"agg": agg,
|
||
"value": value
|
||
}));
|
||
}
|
||
|
||
let years: Vec<i64> = rows_to_json(&conn,
|
||
"SELECT DISTINCT CAST(strftime('%Y', COALESCE(NULLIF(start_date,''), substr(created_at,1,10))) AS INTEGER) AS y FROM footprint WHERE deleted_at IS NULL ORDER BY y DESC", &[])?
|
||
.iter().filter_map(|r| r.get("y").and_then(|v| v.as_i64())).collect();
|
||
|
||
Ok(json!({
|
||
"total": total, "total_this_year": total_this_year, "attachment_total": attachment_total,
|
||
"by_type": by_type, "by_level": by_level, "by_role": by_role, "by_status": by_status,
|
||
"monthly": monthly, "metrics": metrics, "years": years
|
||
}))
|
||
}
|
||
|
||
"metric_list" => Ok(json!({ "items": rows_to_json(&conn, "SELECT * FROM metric_def WHERE deleted_at IS NULL ORDER BY id", &[])? })),
|
||
|
||
"metric_upsert" => {
|
||
let key = payload.get("key").and_then(|v| v.as_str()).unwrap_or("");
|
||
let label = payload.get("label").and_then(|v| v.as_str()).unwrap_or(key);
|
||
let unit = payload.get("unit").and_then(|v| v.as_str());
|
||
let agg = payload.get("agg").and_then(|v| v.as_str()).unwrap_or("sum");
|
||
let section = payload.get("section").and_then(|v| v.as_str());
|
||
let existing: Option<(i64, Option<String>)> = conn
|
||
.query_row("SELECT id, uuid FROM metric_def WHERE key=?1 AND deleted_at IS NULL", [key], |r| {
|
||
Ok((r.get(0)?, r.get(1)?))
|
||
})
|
||
.ok();
|
||
let h = hlc_next(&conn);
|
||
match existing {
|
||
Some((id, uuid)) => {
|
||
exec(&conn, "UPDATE metric_def SET label=?, unit=?, agg=?, section=?, hlc=? WHERE id=?",
|
||
&[P::S(label.into()), s_or_null(&json!(unit)), P::S(agg.to_string()), s_or_null(&json!(section)), P::S(h.clone()), P::I(id)])?;
|
||
if let Some(u) = uuid {
|
||
touch_oplog(&conn, "metric_def", &u, "upsert", &h);
|
||
}
|
||
}
|
||
None => {
|
||
let new_uuid = uuid_v7();
|
||
exec(&conn, "INSERT INTO metric_def (key, label, unit, agg, section, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)",
|
||
&[P::S(key.into()), P::S(label.into()), s_or_null(&json!(unit)), P::S(agg.to_string()), s_or_null(&json!(section)), P::S(new_uuid.clone()), P::S(h.clone()), P::S(get_device_id(&conn))])?;
|
||
touch_oplog(&conn, "metric_def", &new_uuid, "upsert", &h);
|
||
}
|
||
}
|
||
Ok(json!({ "item": payload }))
|
||
}
|
||
|
||
"metric_delete" => {
|
||
let key = payload.get("key").and_then(|v| v.as_str()).unwrap_or("");
|
||
let row: Option<i64> = conn.query_row("SELECT id FROM metric_def WHERE key=?1", [key], |r| r.get(0)).ok();
|
||
if let Some(id) = row {
|
||
soft_delete(&conn, "metric_def", id);
|
||
}
|
||
Ok(json!({ "ok": true }))
|
||
}
|
||
|
||
// ---------- 学年学科成绩 ----------
|
||
"grades_overview" => {
|
||
let bands = rows_to_json(&conn, "SELECT * FROM grade_band WHERE deleted_at IS NULL ORDER BY sort, id", &[])?;
|
||
let semesters = rows_to_json(&conn, "SELECT * FROM semester WHERE deleted_at IS NULL ORDER BY term DESC", &[])?;
|
||
let sem_ids: Vec<i64> = semesters.iter().filter_map(|s| s.get("id").and_then(|v| v.as_i64())).collect();
|
||
let courses: Vec<Value> = rows_to_json(&conn, "SELECT * FROM course_grade WHERE deleted_at IS NULL ORDER BY id", &[])?
|
||
.into_iter()
|
||
.filter(|c| sem_ids.contains(&c.get("semester_id").and_then(|v| v.as_i64()).unwrap_or(-1)))
|
||
.collect();
|
||
|
||
let band_of = |score: Option<f64>| -> Option<Value> {
|
||
let s = score?;
|
||
for b in bands.iter() {
|
||
let min = b.get("min").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||
let max = b.get("max").and_then(|v| v.as_f64()).unwrap_or(100.0);
|
||
if s >= min && s < max {
|
||
return Some(json!({ "id": b.get("id").cloned().unwrap_or(Value::Null), "label": b.get("label").cloned().unwrap_or(json!("")), "color": b.get("color").cloned().unwrap_or(json!("")) }));
|
||
}
|
||
}
|
||
for b in bands.iter() {
|
||
let min = b.get("min").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||
let max = b.get("max").and_then(|v| v.as_f64()).unwrap_or(100.0);
|
||
if s >= min && s <= max {
|
||
return Some(json!({ "id": b.get("id").cloned().unwrap_or(Value::Null), "label": b.get("label").cloned().unwrap_or(json!("")), "color": b.get("color").cloned().unwrap_or(json!("")) }));
|
||
}
|
||
}
|
||
None
|
||
};
|
||
|
||
let mut all_scored: Vec<(f64, f64)> = Vec::new(); // (score, credit)
|
||
let mut sems_out: Vec<Value> = Vec::new();
|
||
for s in semesters {
|
||
let sid = s.get("id").and_then(|v| v.as_i64()).unwrap_or(0);
|
||
let my: Vec<&Value> = courses.iter().filter(|c| c.get("semester_id").and_then(|v| v.as_i64()) == Some(sid)).collect();
|
||
let mut credits_all = 0.0;
|
||
let mut credits_scored = 0.0;
|
||
let mut weighted = 0.0;
|
||
let mut courses_out: Vec<Value> = Vec::new();
|
||
for c in my {
|
||
let credit = c.get("credit").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||
let score = c.get("score").and_then(|v| v.as_f64());
|
||
credits_all += credit;
|
||
if let Some(sc) = score {
|
||
all_scored.push((sc, credit));
|
||
credits_scored += credit;
|
||
weighted += sc * credit;
|
||
}
|
||
let mut co = c.clone();
|
||
if let Some(obj) = co.as_object_mut() {
|
||
obj.insert("band".into(), band_of(score).unwrap_or(Value::Null));
|
||
}
|
||
courses_out.push(co);
|
||
}
|
||
let mut so = s.clone();
|
||
if let Some(obj) = so.as_object_mut() {
|
||
obj.insert("courses".into(), json!(courses_out));
|
||
obj.insert("stats".into(), json!({
|
||
"credits": (credits_all * 10.0).round() / 10.0,
|
||
"courses": courses_out.len(),
|
||
"avg": if credits_scored > 0.0 { Some((weighted / credits_scored * 100.0).round() / 100.0) } else { None }
|
||
}));
|
||
}
|
||
sems_out.push(so);
|
||
}
|
||
|
||
let credits_all: f64 = all_scored.iter().map(|(_, c)| c).sum();
|
||
let weighted: f64 = all_scored.iter().map(|(s, c)| s * c).sum();
|
||
let overall_avg = if credits_all > 0.0 { Some((weighted / credits_all * 100.0).round() / 100.0) } else { None };
|
||
|
||
let mut tier_dist: Vec<Value> = Vec::new();
|
||
for b in bands.iter() {
|
||
let bid = b.get("id").and_then(|v| v.as_i64());
|
||
let count = all_scored.iter().filter(|(s, _)| {
|
||
band_of(Some(*s)).and_then(|x| x.get("id").and_then(|v| v.as_i64())) == bid
|
||
}).count();
|
||
tier_dist.push(json!({
|
||
"label": b.get("label").cloned().unwrap_or(json!("")),
|
||
"color": b.get("color").cloned().unwrap_or(json!("")),
|
||
"count": count
|
||
}));
|
||
}
|
||
|
||
let mut trend_sems: Vec<&Value> = sems_out.iter().filter(|s| s.get("gpa").and_then(|v| v.as_f64()).is_some()).collect();
|
||
trend_sems.sort_by(|a, b| {
|
||
a.get("term").and_then(|v| v.as_str()).unwrap_or("").cmp(b.get("term").and_then(|v| v.as_str()).unwrap_or(""))
|
||
});
|
||
let gpa_trend: Vec<Value> = trend_sems.iter().map(|s| json!({
|
||
"term": s.get("term").cloned().unwrap_or(json!("")),
|
||
"gpa": s.get("gpa").cloned().unwrap_or(Value::Null),
|
||
"avg": s.get("stats").and_then(|st| st.get("avg").cloned()).unwrap_or(Value::Null)
|
||
})).collect();
|
||
|
||
let total_credits: f64 = courses.iter().map(|c| c.get("credit").and_then(|v| v.as_f64()).unwrap_or(0.0)).sum();
|
||
Ok(json!({
|
||
"semesters": sems_out,
|
||
"bands": bands,
|
||
"overall": {
|
||
"avg": overall_avg,
|
||
"credits": (total_credits * 10.0).round() / 10.0,
|
||
"courses": courses.len(),
|
||
"gpa_trend": gpa_trend,
|
||
"tier_dist": tier_dist
|
||
}
|
||
}))
|
||
}
|
||
|
||
"semester_upsert" => {
|
||
let term = payload.get("term").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
|
||
if term.is_empty() {
|
||
return Err("缺少学期名称".into());
|
||
}
|
||
let gpa = payload.get("gpa").and_then(|v| v.as_f64());
|
||
let rank = payload.get("rank").and_then(|v| v.as_i64());
|
||
let total_students = payload.get("total_students").and_then(|v| v.as_i64());
|
||
if let Some(id) = payload.get("id").and_then(|v| v.as_i64()) {
|
||
let dup: i64 = conn
|
||
.query_row("SELECT COUNT(*) FROM semester WHERE term=?1 AND deleted_at IS NULL AND id != ?2",
|
||
rusqlite::params![term.as_str(), id], |r| r.get(0))
|
||
.map_err(e)?;
|
||
if dup > 0 {
|
||
return Err(format!("学期已存在:{}", term));
|
||
}
|
||
let h = hlc_next(&conn);
|
||
exec(&conn, "UPDATE semester SET term=?1, gpa=?2, rank=?3, total_students=?4, hlc=?5 WHERE id=?6",
|
||
&[P::S(term), P::R(gpa.unwrap_or(0.0)), num_or_null(&json!(rank)), num_or_null(&json!(total_students)), P::S(h.clone()), P::I(id)])?;
|
||
if let Some(u) = get_row_uuid(&conn, "semester", id) {
|
||
touch_oplog(&conn, "semester", &u, "upsert", &h);
|
||
}
|
||
Ok(json!({ "item": { "id": id } }))
|
||
} else {
|
||
let dup: i64 = conn.query_row("SELECT COUNT(*) FROM semester WHERE term=?1 AND deleted_at IS NULL", [term.as_str()], |r| r.get(0)).map_err(e)?;
|
||
if dup > 0 {
|
||
return Err(format!("学期已存在:{}", term));
|
||
}
|
||
let h = hlc_next(&conn);
|
||
let new_uuid = uuid_v7();
|
||
let r = exec(&conn, "INSERT INTO semester (term, gpa, rank, total_students, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?)",
|
||
&[P::S(term), P::R(gpa.unwrap_or(0.0)), num_or_null(&json!(rank)), num_or_null(&json!(total_students)), P::S(new_uuid.clone()), P::S(h.clone()), P::S(get_device_id(&conn))])?;
|
||
touch_oplog(&conn, "semester", &new_uuid, "upsert", &h);
|
||
Ok(json!({ "item": { "id": r as i64 } }))
|
||
}
|
||
}
|
||
|
||
"semester_delete" => {
|
||
let id = payload.get("id").and_then(|v| v.as_i64()).unwrap_or(0);
|
||
soft_delete(&conn, "semester", id);
|
||
// 级联墓碑该学期下全部课程,避免孤儿课程污染总览
|
||
let course_ids: Vec<i64> = {
|
||
let mut out = Vec::new();
|
||
if let Ok(mut stmt) = conn.prepare("SELECT id FROM course_grade WHERE semester_id=?1 AND deleted_at IS NULL") {
|
||
if let Ok(mut rows) = stmt.query([id]) {
|
||
while let Ok(Some(row)) = rows.next() {
|
||
if let Ok(cid) = row.get::<_, i64>(0) {
|
||
out.push(cid);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
out
|
||
};
|
||
for cid in course_ids {
|
||
soft_delete(&conn, "course_grade", cid);
|
||
}
|
||
Ok(json!({ "ok": true }))
|
||
}
|
||
|
||
"course_upsert" => {
|
||
let semester_id = payload.get("semester_id").and_then(|v| v.as_i64()).ok_or_else(|| "缺少学期".to_string())?;
|
||
let name = payload.get("course_name").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
|
||
if name.is_empty() {
|
||
return Err("缺少课程名".into());
|
||
}
|
||
let score = payload.get("score").and_then(|v| v.as_f64());
|
||
if let Some(sc) = score {
|
||
if !(0.0..=100.0).contains(&sc) {
|
||
return Err("分数需在 0-100 之间".into());
|
||
}
|
||
}
|
||
let gp = payload.get("grade_point").and_then(|v| v.as_f64());
|
||
if let Some(g) = gp {
|
||
if g < 0.0 {
|
||
return Err("绩点不能为负".into());
|
||
}
|
||
}
|
||
let credit = payload.get("credit").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||
if let Some(id) = payload.get("id").and_then(|v| v.as_i64()) {
|
||
let h = hlc_next(&conn);
|
||
exec(&conn, "UPDATE course_grade SET semester_id=?1, course_name=?2, credit=?3, score=?4, grade_point=?5, hlc=?6 WHERE id=?7",
|
||
&[P::I(semester_id), P::S(name), P::R(credit), P::R(score.unwrap_or(0.0)), P::R(gp.unwrap_or(0.0)), P::S(h.clone()), P::I(id)])?;
|
||
if let Some(u) = get_row_uuid(&conn, "course_grade", id) {
|
||
touch_oplog(&conn, "course_grade", &u, "upsert", &h);
|
||
}
|
||
Ok(json!({ "item": { "id": id } }))
|
||
} else {
|
||
let h = hlc_next(&conn);
|
||
let new_uuid = uuid_v7();
|
||
let r = exec(&conn, "INSERT INTO course_grade (semester_id, course_name, credit, score, grade_point, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)",
|
||
&[P::I(semester_id), P::S(name), P::R(credit), P::R(score.unwrap_or(0.0)), P::R(gp.unwrap_or(0.0)), P::S(new_uuid.clone()), P::S(h.clone()), P::S(get_device_id(&conn))])?;
|
||
touch_oplog(&conn, "course_grade", &new_uuid, "upsert", &h);
|
||
Ok(json!({ "item": { "id": r as i64 } }))
|
||
}
|
||
}
|
||
|
||
"course_delete" => {
|
||
soft_delete(&conn, "course_grade", payload.get("id").and_then(|v| v.as_i64()).unwrap_or(0));
|
||
Ok(json!({ "ok": true }))
|
||
}
|
||
|
||
"band_upsert" => {
|
||
let label = payload.get("label").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
|
||
if label.is_empty() {
|
||
return Err("缺少等级名称".into());
|
||
}
|
||
let min = payload.get("min").and_then(|v| v.as_f64()).ok_or_else(|| "分数区间不合法(需 min < max)".to_string())?;
|
||
let max = payload.get("max").and_then(|v| v.as_f64()).ok_or_else(|| "分数区间不合法(需 min < max)".to_string())?;
|
||
if min >= max {
|
||
return Err("分数区间不合法(需 min < max)".into());
|
||
}
|
||
let color = payload.get("color").and_then(|v| v.as_str()).unwrap_or("#64748b");
|
||
let sort = payload.get("sort").and_then(|v| v.as_i64()).unwrap_or(100);
|
||
if let Some(id) = payload.get("id").and_then(|v| v.as_i64()) {
|
||
let h = hlc_next(&conn);
|
||
exec(&conn, "UPDATE grade_band SET label=?1, color=?2, min=?3, max=?4, sort=?5, hlc=?6 WHERE id=?7",
|
||
&[P::S(label.clone()), P::S(color.into()), P::R(min), P::R(max), P::I(sort), P::S(h.clone()), P::I(id)])?;
|
||
if let Some(u) = get_row_uuid(&conn, "grade_band", id) {
|
||
touch_oplog(&conn, "grade_band", &u, "upsert", &h);
|
||
}
|
||
Ok(json!({ "item": { "id": id } }))
|
||
} else {
|
||
let h = hlc_next(&conn);
|
||
let new_uuid = uuid_v7();
|
||
let r = exec(&conn, "INSERT INTO grade_band (label, color, min, max, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)",
|
||
&[P::S(label), P::S(color.into()), P::R(min), P::R(max), P::I(sort), P::S(new_uuid.clone()), P::S(h.clone()), P::S(get_device_id(&conn))])?;
|
||
touch_oplog(&conn, "grade_band", &new_uuid, "upsert", &h);
|
||
Ok(json!({ "item": { "id": r as i64 } }))
|
||
}
|
||
}
|
||
|
||
"band_delete" => {
|
||
soft_delete(&conn, "grade_band", payload.get("id").and_then(|v| v.as_i64()).unwrap_or(0));
|
||
Ok(json!({ "ok": true }))
|
||
}
|
||
|
||
"attachment_add" => {
|
||
use base64::Engine;
|
||
use std::io::Write;
|
||
let owner = payload.get("owner_id").and_then(|v| v.as_i64()).unwrap_or(0);
|
||
let file_name = payload.get("file_name").and_then(|v| v.as_str()).unwrap_or("file").to_string();
|
||
let mime = payload.get("mime").and_then(|v| v.as_str());
|
||
let data = payload.get("data_b64").and_then(|v| v.as_str()).unwrap_or("");
|
||
let bytes = base64::engine::general_purpose::STANDARD.decode(data).map_err(|x| x.to_string())?;
|
||
let safe: String = file_name.chars().map(|c| if "\\/:*?\"<>|".contains(c) { '_' } else { c }).collect();
|
||
let rel = format!("attachments/{}/{}_{}", owner, std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos(), safe);
|
||
let abs = state.data_dir.join(&rel);
|
||
std::fs::create_dir_all(abs.parent().unwrap()).map_err(|x| x.to_string())?;
|
||
let mut f = std::fs::File::create(&abs).map_err(|x| x.to_string())?;
|
||
f.write_all(&bytes).map_err(|x| x.to_string())?;
|
||
let rel_norm = rel.replace('\\', "/");
|
||
use sha2::{Digest, Sha256};
|
||
let mut hasher = Sha256::new();
|
||
hasher.update(&bytes);
|
||
let sha_hex = format!("{:x}", hasher.finalize());
|
||
let h = hlc_next(&conn);
|
||
let new_uuid = uuid_v7();
|
||
let r = exec(&conn,
|
||
"INSERT INTO attachment (owner_type, owner_id, kind, file_name, mime, size, rel_path, sha256, uuid, hlc, origin) VALUES ('footprint',?1,NULL,?2,?3,?4,?5,?6,?7,?8,?9)",
|
||
&[P::I(owner), P::S(file_name), s_or_null(&json!(mime)), P::I(bytes.len() as i64), P::S(rel_norm), P::S(sha_hex), P::S(new_uuid.clone()), P::S(h.clone()), P::S(get_device_id(&conn))])?;
|
||
let item = rows_to_json(&conn, "SELECT * FROM attachment WHERE id=?1", &[P::I(r as i64)])?;
|
||
touch_oplog(&conn, "attachment", &new_uuid, "upsert", &h);
|
||
Ok(json!({ "item": item.into_iter().next().unwrap_or(Value::Null) }))
|
||
}
|
||
|
||
"attachment_list" => Ok(json!({ "items": rows_to_json(&conn,
|
||
"SELECT * FROM attachment WHERE owner_type='footprint' AND owner_id=?1 AND deleted_at IS NULL ORDER BY id",
|
||
&[P::I(payload.get("owner_id").and_then(|v| v.as_i64()).unwrap_or(0))])? })),
|
||
|
||
"attachment_delete" => {
|
||
let id = payload.get("id").and_then(|v| v.as_i64()).unwrap_or(0);
|
||
let rows = rows_to_json(&conn, "SELECT * FROM attachment WHERE id=?1", &[P::I(id)])?;
|
||
if let Some(row) = rows.into_iter().next() {
|
||
soft_delete(&conn, "attachment", id);
|
||
if let Some(rel) = row.get("rel_path").and_then(|v| v.as_str()) {
|
||
let _ = std::fs::remove_file(state.data_dir.join(rel));
|
||
}
|
||
}
|
||
Ok(json!({ "ok": true }))
|
||
}
|
||
|
||
"attachment_read" => {
|
||
use base64::Engine;
|
||
let id = payload.get("id").and_then(|v| v.as_i64()).unwrap_or(0);
|
||
let rows = rows_to_json(&conn, "SELECT * FROM attachment WHERE id=?1", &[P::I(id)])?;
|
||
let row = rows.into_iter().next().ok_or_else(|| "附件不存在".to_string())?;
|
||
let rel = row.get("rel_path").and_then(|v| v.as_str()).unwrap_or("");
|
||
let data = std::fs::read(state.data_dir.join(rel)).map_err(|x| x.to_string())?;
|
||
Ok(json!({
|
||
"data_b64": base64::engine::general_purpose::STANDARD.encode(data),
|
||
"mime": row.get("mime").cloned().unwrap_or(Value::Null),
|
||
"file_name": row.get("file_name").cloned().unwrap_or(json!(""))
|
||
}))
|
||
}
|
||
|
||
"view_list" => {
|
||
let items = rows_to_json(&conn, "SELECT * FROM saved_view WHERE deleted_at IS NULL ORDER BY id", &[])?;
|
||
let items: Vec<Value> = items.into_iter().map(|mut it| {
|
||
if let Some(obj) = it.as_object_mut() {
|
||
for k in ["filter", "sort"] {
|
||
if let Some(Value::String(s)) = obj.get(k) {
|
||
let parsed: Value = serde_json::from_str(s).unwrap_or(json!({}));
|
||
obj.insert(k.to_string(), parsed);
|
||
}
|
||
}
|
||
let b = obj.get("is_default").map(|v| v.as_i64().unwrap_or(0) != 0).unwrap_or(false);
|
||
obj.insert("is_default".into(), json!(b));
|
||
}
|
||
it
|
||
}).collect();
|
||
Ok(json!({ "items": items }))
|
||
}
|
||
|
||
"view_upsert" => {
|
||
let name = payload.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||
let filter = serde_json::to_string(payload.get("filter").unwrap_or(&json!({}))).unwrap();
|
||
let sort = serde_json::to_string(payload.get("sort").unwrap_or(&json!({}))).unwrap();
|
||
let is_default = payload.get("is_default").and_then(|v| v.as_bool()).unwrap_or(false);
|
||
if let Some(id) = payload.get("id").and_then(|v| v.as_i64()) {
|
||
let h = hlc_next(&conn);
|
||
exec(&conn, "UPDATE saved_view SET name=?1, filter=?2, sort=?3, is_default=?4, hlc=?5, updated_at=datetime('now','localtime') WHERE id=?6",
|
||
&[P::S(name.into()), P::S(filter), P::S(sort), P::I(is_default as i64), P::S(h.clone()), P::I(id)])?;
|
||
if let Some(u) = get_row_uuid(&conn, "saved_view", id) {
|
||
touch_oplog(&conn, "saved_view", &u, "upsert", &h);
|
||
}
|
||
Ok(json!({ "item": { "id": id } }))
|
||
} else {
|
||
let h = hlc_next(&conn);
|
||
let new_uuid = uuid_v7();
|
||
let r = exec(&conn, "INSERT INTO saved_view (name, filter, sort, is_default, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?)",
|
||
&[P::S(name.into()), P::S(filter), P::S(sort), P::I(is_default as i64), P::S(new_uuid.clone()), P::S(h.clone()), P::S(get_device_id(&conn))])?;
|
||
touch_oplog(&conn, "saved_view", &new_uuid, "upsert", &h);
|
||
Ok(json!({ "item": { "id": r as i64 } }))
|
||
}
|
||
}
|
||
|
||
"view_delete" => {
|
||
soft_delete(&conn, "saved_view", payload.get("id").and_then(|v| v.as_i64()).unwrap_or(0));
|
||
Ok(json!({ "ok": true }))
|
||
}
|
||
|
||
_ => Err(format!("未知命令:{}", cmd)),
|
||
}
|
||
}
|
||
|
||
fn chrono_year() -> i64 {
|
||
let secs = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() + 8 * 3600;
|
||
let mut year = 1970i64;
|
||
let mut rem = (secs / 86400) as i64;
|
||
loop {
|
||
let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
|
||
let len = if leap { 366 } else { 365 };
|
||
if rem < len { break; }
|
||
rem -= len;
|
||
year += 1;
|
||
}
|
||
year
|
||
}
|
||
|
||
macro_rules! cmd {
|
||
($name:ident) => {
|
||
#[tauri::command]
|
||
fn $name(state: tauri::State<AppData>, payload: Value) -> Result<Value, String> {
|
||
dispatch(&state, stringify!($name), &payload)
|
||
}
|
||
};
|
||
}
|
||
|
||
cmd!(app_info);
|
||
cmd!(type_list);
|
||
cmd!(type_upsert);
|
||
cmd!(type_delete);
|
||
cmd!(taxonomy_list);
|
||
cmd!(taxonomy_upsert);
|
||
cmd!(taxonomy_delete);
|
||
cmd!(dimension_list);
|
||
cmd!(dimension_upsert);
|
||
cmd!(dimension_delete);
|
||
cmd!(footprint_list);
|
||
cmd!(footprint_get);
|
||
cmd!(footprint_upsert);
|
||
cmd!(footprint_delete);
|
||
cmd!(timeline);
|
||
cmd!(stats_overview);
|
||
cmd!(metric_list);
|
||
cmd!(metric_upsert);
|
||
cmd!(metric_delete);
|
||
cmd!(grades_overview);
|
||
cmd!(semester_upsert);
|
||
cmd!(semester_delete);
|
||
cmd!(course_upsert);
|
||
cmd!(course_delete);
|
||
cmd!(band_upsert);
|
||
cmd!(band_delete);
|
||
cmd!(attachment_add);
|
||
cmd!(attachment_list);
|
||
cmd!(attachment_delete);
|
||
cmd!(attachment_read);
|
||
cmd!(view_list);
|
||
cmd!(view_upsert);
|
||
cmd!(view_delete);
|
||
|
||
pub fn run() {
|
||
tauri::Builder::default()
|
||
.setup(|app| {
|
||
let dir = app.path().app_data_dir().expect("无法获取应用数据目录");
|
||
std::fs::create_dir_all(dir.join("attachments")).map_err(|x| x.to_string())?;
|
||
let conn = Connection::open(dir.join("footprints.db")).map_err(|x| x.to_string())?;
|
||
conn.pragma_update(None, "foreign_keys", true).map_err(|x| x.to_string())?;
|
||
pre_migrate_columns(&conn);
|
||
conn.execute_batch(SCHEMA_SQL).map_err(|x| x.to_string())?;
|
||
migrate_sync(&conn);
|
||
ensure_builtin(&conn);
|
||
app.manage(AppData { conn: Mutex::new(conn), data_dir: dir });
|
||
Ok(())
|
||
})
|
||
.invoke_handler(tauri::generate_handler![
|
||
app_info, type_list, type_upsert, type_delete,
|
||
taxonomy_list, taxonomy_upsert, taxonomy_delete,
|
||
dimension_list, dimension_upsert, dimension_delete,
|
||
footprint_list, footprint_get, footprint_upsert, footprint_delete,
|
||
timeline, stats_overview, metric_list, metric_upsert, metric_delete,
|
||
grades_overview, semester_upsert, semester_delete, course_upsert, course_delete,
|
||
band_upsert, band_delete,
|
||
attachment_add, attachment_list, attachment_delete, attachment_read,
|
||
view_list, view_upsert, view_delete
|
||
])
|
||
.run(tauri::generate_context!())
|
||
.expect("error while running footprints");
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn fresh_db() -> Connection {
|
||
let conn = Connection::open_in_memory().unwrap();
|
||
pre_migrate_columns(&conn);
|
||
conn.execute_batch(SCHEMA_SQL).unwrap();
|
||
migrate_sync(&conn);
|
||
ensure_builtin(&conn);
|
||
conn
|
||
}
|
||
|
||
fn test_app() -> AppData {
|
||
let dir = std::env::temp_dir().join(format!("fp-test-{}-{}", std::process::id(), uuid_v7().replace('-', "")));
|
||
std::fs::create_dir_all(dir.join("attachments")).unwrap();
|
||
let conn = Connection::open_in_memory().unwrap();
|
||
conn.pragma_update(None, "foreign_keys", true).unwrap();
|
||
pre_migrate_columns(&conn);
|
||
conn.execute_batch(SCHEMA_SQL).unwrap();
|
||
migrate_sync(&conn);
|
||
ensure_builtin(&conn);
|
||
AppData { conn: Mutex::new(conn), data_dir: dir }
|
||
}
|
||
|
||
// ---- 同步地基 ----
|
||
|
||
#[test]
|
||
fn uuid_v7_format_and_uniqueness() {
|
||
let mut seen = std::collections::HashSet::new();
|
||
for _ in 0..2000 {
|
||
let u = uuid_v7();
|
||
assert_eq!(u.len(), 36, "格式应为标准 UUID: {}", u);
|
||
assert_eq!(u.as_bytes()[14], b'7', "version 位应为 7: {}", u);
|
||
assert!(seen.insert(u), "UUID 不应重复");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn hlc_next_is_monotonic() {
|
||
let conn = fresh_db();
|
||
let mut prev: (i64, i64) = (0, 0);
|
||
for _ in 0..500 {
|
||
let h = hlc_next(&conn);
|
||
let parts: Vec<i64> = h.split(':').take(2).map(|x| x.parse().unwrap()).collect();
|
||
let (ms, cnt) = (parts[0], parts[1]);
|
||
assert!(ms > prev.0 || (ms == prev.0 && cnt > prev.1), "HLC 必须单调递增: {} after {:?}", h, prev);
|
||
prev = (ms, cnt);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn hlc_next_survives_clock_regression() {
|
||
let conn = fresh_db();
|
||
let h1 = hlc_next(&conn);
|
||
let p: Vec<i64> = h1.split(':').take(2).map(|x| x.parse().unwrap()).collect();
|
||
// 真实回拨场景:设备时钟曾超前(hlc_ms 在未来),系统校时后 Date.now() 回到过去
|
||
set_meta(&conn, "hlc_ms", &((p[0] + 60_000).to_string()));
|
||
set_meta(&conn, "hlc_counter", "0");
|
||
let h2 = hlc_next(&conn);
|
||
let q: Vec<i64> = h2.split(':').take(2).map(|x| x.parse().unwrap()).collect();
|
||
assert!(q[0] >= p[0] && (q[0] > p[0] || q[1] > p[1]), "时钟回正后 HLC 仍需不回退: {} -> {}", h1, h2);
|
||
}
|
||
|
||
#[test]
|
||
fn migrate_legacy_backfills_sync_columns() {
|
||
// 模拟 v1 老库:无 uuid/deleted_at/origin/hlc 列
|
||
let conn = Connection::open_in_memory().unwrap();
|
||
conn.execute_batch(
|
||
"CREATE TABLE footprint_type (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE, icon TEXT, color TEXT, sort INTEGER, form_template TEXT, is_builtin INTEGER, closed INTEGER, created_at TEXT);
|
||
CREATE TABLE semester (id INTEGER PRIMARY KEY AUTOINCREMENT, term TEXT UNIQUE, gpa REAL, rank INTEGER, total_students INTEGER);
|
||
INSERT INTO semester (term, gpa, rank, total_students) VALUES ('2020-2021-1', 3.5, 10, 100);",
|
||
).unwrap();
|
||
pre_migrate_columns(&conn);
|
||
conn.execute_batch(SCHEMA_SQL).unwrap();
|
||
migrate_sync(&conn);
|
||
let (cnt, uuids): (i64, String) = conn
|
||
.query_row("SELECT COUNT(*), COALESCE(GROUP_CONCAT(uuid),'') FROM semester WHERE uuid IS NOT NULL", [], |r| {
|
||
Ok((r.get(0)?, r.get(1)?))
|
||
})
|
||
.unwrap();
|
||
assert_eq!(cnt, 1, "存量行应回填 uuid");
|
||
assert!(uuids.contains('-'), "uuid 应为完整格式");
|
||
let origin: String = conn.query_row("SELECT origin FROM semester", [], |r| r.get(0)).unwrap();
|
||
assert_eq!(origin, "migrate");
|
||
}
|
||
|
||
// ---- 命令层(dispatch 运行时)----
|
||
|
||
#[test]
|
||
fn footprint_roundtrip_with_extras_and_metrics() {
|
||
let app = test_app();
|
||
let types = dispatch(&app, "type_list", &json!({})).unwrap();
|
||
let type_id = types.get("items").and_then(|v| v.as_array()).unwrap()[0]
|
||
.get("id").and_then(|v| v.as_i64()).unwrap();
|
||
|
||
let id = dispatch(&app, "footprint_upsert", &json!({ "item": {
|
||
"type_id": type_id, "title": "运行时往返", "status": "finished",
|
||
"tags": ["测试", "多端"], "extras": { "my_work": "验证同步地基" },
|
||
"metrics": { "volunteer_hours": 8 }, "dimension_ids": []
|
||
}})).unwrap();
|
||
let fid = id.get("id").and_then(|v| v.as_i64()).unwrap();
|
||
|
||
let got = dispatch(&app, "footprint_get", &json!({ "id": fid })).unwrap();
|
||
let item = got.get("item").unwrap();
|
||
assert_eq!(item.get("title").unwrap().as_str().unwrap(), "运行时往返");
|
||
assert_eq!(item.get("extras").unwrap().get("my_work").unwrap().as_str().unwrap(), "验证同步地基");
|
||
assert_eq!(item.get("metrics").unwrap().get("volunteer_hours").unwrap().as_f64().unwrap(), 8.0);
|
||
assert!(item.get("uuid").and_then(|v| v.as_str()).is_some(), "足迹应携带 uuid");
|
||
assert!(item.get("hlc").and_then(|v| v.as_str()).is_some(), "足迹应携带 hlc");
|
||
|
||
// oplog 应有 1 条待同步
|
||
let op: i64 = app.conn.lock().unwrap()
|
||
.query_row("SELECT COUNT(*) FROM sync_oplog WHERE table_name='footprint' AND synced_at IS NULL", [], |r| r.get(0)).unwrap();
|
||
assert_eq!(op, 1, "创建应写入 oplog");
|
||
}
|
||
|
||
#[test]
|
||
fn footprint_delete_is_tombstone_and_excluded_from_list() {
|
||
let app = test_app();
|
||
let types = dispatch(&app, "type_list", &json!({})).unwrap();
|
||
let type_id = types.get("items").and_then(|v| v.as_array()).unwrap()[0]
|
||
.get("id").and_then(|v| v.as_i64()).unwrap();
|
||
|
||
let id = dispatch(&app, "footprint_upsert", &json!({ "item": {
|
||
"type_id": type_id, "title": "将被删除 keyword-unique", "status": "draft",
|
||
"tags": [], "extras": {}, "metrics": {}, "dimension_ids": []
|
||
}})).unwrap();
|
||
let fid = id.get("id").and_then(|v| v.as_i64()).unwrap();
|
||
|
||
dispatch(&app, "footprint_delete", &json!({ "id": fid })).unwrap();
|
||
|
||
// 行保留但带墓碑
|
||
let (deleted_at, ): (Option<String>,) = app.conn.lock().unwrap()
|
||
.query_row("SELECT deleted_at FROM footprint WHERE id=?1", [fid], |r| Ok((r.get(0)?,))).unwrap();
|
||
assert!(deleted_at.is_some(), "删除应为软删除墓碑");
|
||
|
||
// 列表与检索均排除
|
||
let list = dispatch(&app, "footprint_list", &json!({ "query": { "q": "keyword-unique" } })).unwrap();
|
||
assert_eq!(list.get("total").and_then(|v| v.as_i64()), Some(0), "墓碑不应出现在检索中");
|
||
|
||
// oplog 中产生 delete 操作
|
||
let op: String = app.conn.lock().unwrap()
|
||
.query_row("SELECT op FROM sync_oplog WHERE row_uuid=(SELECT uuid FROM footprint WHERE id=?1) ORDER BY id DESC LIMIT 1",
|
||
[fid], |r| r.get(0)).unwrap();
|
||
assert_eq!(op, "delete");
|
||
}
|
||
|
||
#[test]
|
||
fn semester_delete_cascades_courses() {
|
||
let app = test_app();
|
||
let sid = dispatch(&app, "semester_upsert", &json!({ "term": "2030-2031-1", "gpa": 3.0, "rank": 5, "total_students": 100 }))
|
||
.unwrap().get("item").unwrap().get("id").and_then(|v| v.as_i64()).unwrap();
|
||
for name in ["课程A", "课程B", "课程C"] {
|
||
dispatch(&app, "course_upsert", &json!({ "semester_id": sid, "course_name": name, "credit": 2, "score": 88, "grade_point": 3.8 })).unwrap();
|
||
}
|
||
|
||
dispatch(&app, "semester_delete", &json!({ "id": sid })).unwrap();
|
||
|
||
let orphans: i64 = app.conn.lock().unwrap()
|
||
.query_row("SELECT COUNT(*) FROM course_grade WHERE semester_id=?1 AND deleted_at IS NULL", [sid], |r| r.get(0)).unwrap();
|
||
assert_eq!(orphans, 0, "学期删除后不应残留孤儿课程");
|
||
|
||
let overview = dispatch(&app, "grades_overview", &json!({})).unwrap();
|
||
assert_eq!(overview.get("overall").unwrap().get("courses").and_then(|v| v.as_i64()), Some(0), "总览不应统计孤儿课程");
|
||
}
|
||
|
||
#[test]
|
||
fn semester_rename_duplicate_is_rejected() {
|
||
let app = test_app();
|
||
dispatch(&app, "semester_upsert", &json!({ "term": "2030-2031-1" })).unwrap();
|
||
let s2 = dispatch(&app, "semester_upsert", &json!({ "term": "2030-2031-2" })).unwrap();
|
||
let id2 = s2.get("item").unwrap().get("id").and_then(|v| v.as_i64()).unwrap();
|
||
|
||
// 实证:改名前表内状态
|
||
{
|
||
let conn = app.conn.lock().unwrap();
|
||
let mut stmt = conn.prepare("SELECT id, term, deleted_at FROM semester ORDER BY id").unwrap();
|
||
let mut rows = stmt.query([]).unwrap();
|
||
while let Ok(Some(row)) = rows.next() {
|
||
let id: i64 = row.get(0).unwrap();
|
||
let t: String = row.get(1).unwrap();
|
||
let da: Option<String> = row.get(2).unwrap();
|
||
println!("[rename-debug] semester id={} term={} deleted_at={:?}", id, t, da);
|
||
}
|
||
}
|
||
|
||
let r = dispatch(&app, "semester_upsert", &json!({ "id": id2, "term": "2030-2031-1" }));
|
||
println!("[rename-debug] dispatch result: {:?}", r.as_ref().map(|v| v.to_string()).unwrap_or_else(|e| e.clone()));
|
||
assert!(r.is_err(), "改名撞已有学期名应被拒绝");
|
||
assert!(r.unwrap_err().contains("学期已存在"), "错误信息应友好");
|
||
}
|
||
|
||
#[test]
|
||
fn grades_overview_math() {
|
||
let app = test_app();
|
||
let sid = dispatch(&app, "semester_upsert", &json!({ "term": "2030-2031-1" })).unwrap()
|
||
.get("item").unwrap().get("id").and_then(|v| v.as_i64()).unwrap();
|
||
dispatch(&app, "course_upsert", &json!({ "semester_id": sid, "course_name": "A", "credit": 4, "score": 90, "grade_point": 4.0 })).unwrap();
|
||
dispatch(&app, "course_upsert", &json!({ "semester_id": sid, "course_name": "B", "credit": 2, "score": 80, "grade_point": 3.0 })).unwrap();
|
||
// 加权平均 = (90*4 + 80*2) / 6 = 86.67
|
||
let ov = dispatch(&app, "grades_overview", &json!({})).unwrap();
|
||
let avg = ov.get("overall").unwrap().get("avg").and_then(|v| v.as_f64()).unwrap();
|
||
assert!((avg - 86.67).abs() < 0.01, "加权平均应为 86.67,实际 {}", avg);
|
||
assert_eq!(ov.get("overall").unwrap().get("credits").and_then(|v| v.as_f64()), Some(6.0));
|
||
}
|
||
}
|