From 0a1ac9f77985943f8ccb58f79857446a1a46fbcd Mon Sep 17 00:00:00 2001
From: JunieXD <1240840076@qq.com>
Date: Sat, 31 Oct 2026 15:51:15 +0800
Subject: [PATCH 1/4] =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=92=8C=E5=90=88?=
=?UTF-8?q?=E5=B9=B6=E4=B8=8D=E5=86=8D=E8=A6=86=E7=9B=96=E6=9C=9F=E9=97=B4?=
=?UTF-8?q?=E5=88=AB=E5=A4=84=E7=9A=84=E6=94=B9=E5=8A=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...75ef2ac5949e72297ec5c38ee8601f989fd4.json} | 6 +--
crates/api/src/agent/confirmation.rs | 2 +-
crates/api/src/lib.rs | 42 +++++++++++++---
crates/api/tests/agent_effects.rs | 30 ++++++++----
crates/email/src/lib.rs | 2 +-
crates/email/tests/todos.rs | 2 +-
crates/library/tests/library.rs | 4 +-
crates/matching/src/lib.rs | 2 +-
crates/matching/tests/matching.rs | 12 ++---
crates/mentor/src/lib.rs | 35 +++++++++++++
crates/mentor/src/merge.rs | 23 +++++++--
crates/mentor/tests/edit.rs | 42 +++++++++++++++-
crates/mentor/tests/matching.rs | 20 ++++----
crates/mentor/tests/merge.rs | 49 ++++++++++++++++---
crates/profile/src/lib.rs | 34 +++++++------
crates/profile/tests/profile.rs | 20 +++++---
crates/receive/tests/outcome.rs | 4 +-
crates/send/tests/send.rs | 2 +-
crates/task/tests/tasks.rs | 12 +++--
crates/writing/tests/write.rs | 6 +--
docs/data-model.md | 2 +-
docs/ui.md | 4 +-
ui/src/api/types.ts | 12 +++--
ui/src/components/mentor-dialog.tsx | 13 +++--
ui/src/components/merge-dialog.tsx | 11 +++--
ui/src/pages/mentor.tsx | 21 +++++---
ui/src/pages/profile.test.tsx | 33 +++++++++++++
ui/src/pages/profile.tsx | 29 +++++------
28 files changed, 350 insertions(+), 124 deletions(-)
rename .sqlx/{query-96a24702eee8385434e669b392058c3b75158752c2495728db47c7eee34d0319.json => query-c8da8973a8c206ca185d191a960075ef2ac5949e72297ec5c38ee8601f989fd4.json} (66%)
diff --git a/.sqlx/query-96a24702eee8385434e669b392058c3b75158752c2495728db47c7eee34d0319.json b/.sqlx/query-c8da8973a8c206ca185d191a960075ef2ac5949e72297ec5c38ee8601f989fd4.json
similarity index 66%
rename from .sqlx/query-96a24702eee8385434e669b392058c3b75158752c2495728db47c7eee34d0319.json
rename to .sqlx/query-c8da8973a8c206ca185d191a960075ef2ac5949e72297ec5c38ee8601f989fd4.json
index 40d3ee6de..3cfdd544c 100644
--- a/.sqlx/query-96a24702eee8385434e669b392058c3b75158752c2495728db47c7eee34d0319.json
+++ b/.sqlx/query-c8da8973a8c206ca185d191a960075ef2ac5949e72297ec5c38ee8601f989fd4.json
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
- "query": "UPDATE profile\n SET name = ?1, research_interests = ?2, rewrite_preference = ?3,\n match_basis_changed_at = CASE WHEN research_interests IS NOT ?2 THEN max(\n coalesce(match_basis_changed_at, ?4), ?4) ELSE match_basis_changed_at END\n RETURNING name, research_interests, rewrite_preference, onboarded AS \"onboarded: bool\"",
+ "query": "UPDATE profile\n SET name = iif(?1, ?2, name), research_interests = iif(?3, ?4, research_interests),\n rewrite_preference = iif(?5, ?6, rewrite_preference),\n match_basis_changed_at = CASE WHEN ?3 AND research_interests IS NOT ?4 THEN max(\n coalesce(match_basis_changed_at, ?7), ?7) ELSE match_basis_changed_at END\n RETURNING name, research_interests, rewrite_preference, onboarded AS \"onboarded: bool\"",
"describe": {
"columns": [
{
@@ -49,7 +49,7 @@
}
],
"parameters": {
- "Right": 4
+ "Right": 7
},
"nullable": [
true,
@@ -58,5 +58,5 @@
false
]
},
- "hash": "96a24702eee8385434e669b392058c3b75158752c2495728db47c7eee34d0319"
+ "hash": "c8da8973a8c206ca185d191a960075ef2ac5949e72297ec5c38ee8601f989fd4"
}
diff --git a/crates/api/src/agent/confirmation.rs b/crates/api/src/agent/confirmation.rs
index a4e36958a..c4f57c698 100644
--- a/crates/api/src/agent/confirmation.rs
+++ b/crates/api/src/agent/confirmation.rs
@@ -671,7 +671,7 @@ async fn describe(
)
}
"mergeMentors" => {
- let input: crate::MentorMerge = parse(input)?;
+ let crate::PreviewedMerge { pair: input, .. } = parse(input)?;
let drafts = mentor::merge::conflicting_drafts(conn, input.keep, input.other).await?;
let canceled = sqlx::query_scalar!(
r#"SELECT count(*) AS "count!: i64" FROM email
diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs
index 89f899ae7..4159ee42e 100644
--- a/crates/api/src/lib.rs
+++ b/crates/api/src/lib.rs
@@ -643,7 +643,8 @@ operations! {
"setMentorNote" [none] => set_mentor_note(MentorNote) -> ();
/// 添加一位导师,返回新导师的 id。
"addMentor" [none] => add_mentor(mentor::MentorForm) -> MentorId;
- /// 更新指定导师的资料。
+ /// 更新指定导师的资料。before 是编辑前 getMentor 读到的 name、aliases、emails、
+ /// affiliations、homepages、research;这之后资料有了变化就不保存,重新读取后再改。
"updateMentor" [confirm_if "更改已批准邮件的收件地址时"] =>
update_mentor(MentorEdit) -> mentor::MentorDetail;
/// 设置联系导师使用的邮箱,mailboxId 为 null 时取消指定。
@@ -654,8 +655,9 @@ operations! {
add_mentor_email(MentorAddress) -> mentor::MentorDetail;
/// 预览把 other 导师并入 keep 导师的结果。
"previewMerge" [none] => preview_merge(MentorMerge) -> mentor::merge::Preview;
- /// 把 other 导师合并到 keep 导师。
- "mergeMentors" [confirm] => merge_mentors(MentorMerge) -> ();
+ /// 把 other 导师合并到 keep 导师。discardedDrafts 是 previewMerge 给出的;这之后要删的草稿
+ /// 有了变化就不合并,重新预览后再合并。
+ "mergeMentors" [confirm] => merge_mentors(PreviewedMerge) -> ();
/// 按筛选和分页条件读取回收站导师。
"listTrash" [none] => list_trash(mentor::trash::TrashQuery) -> mentor::MentorPage;
/// 将指定 id 列表中的导师移入回收站。
@@ -710,7 +712,7 @@ operations! {
"个人资料" {
/// 读取学生个人资料。
"getProfile" [none] => get_profile(Nothing) -> profile::Profile;
- /// 保存学生个人资料。
+ /// 保存学生个人资料,只改传了的字段,空字符串清空。
"updateProfile" [none] => update_profile(profile::ProfileForm) -> profile::Profile;
/// 标记首次使用引导已完成。
"finishOnboarding" [none] => finish_onboarding(Nothing) -> ();
@@ -978,6 +980,8 @@ pub struct MentorIds {
#[derive(Debug, Deserialize, TS)]
pub struct MentorEdit {
pub id: i64,
+ /// The fields as they were when editing began.
+ pub before: mentor::MentorFields,
#[serde(flatten)]
pub form: mentor::MentorForm,
}
@@ -1002,6 +1006,15 @@ pub struct MentorMerge {
pub other: i64,
}
+/// A merge as its preview showed it.
+#[derive(Debug, Deserialize, TS)]
+#[serde(rename_all = "camelCase")]
+pub struct PreviewedMerge {
+ #[serde(flatten)]
+ pub pair: MentorMerge,
+ pub discarded_drafts: Vec,
+}
+
#[derive(Debug, Deserialize, TS)]
pub struct MentorNote {
pub id: i64,
@@ -1076,8 +1089,17 @@ async fn update_mentor(
input: MentorEdit,
) -> Result {
let fields = input.form.check().map_err(CallError::invalid)?;
- let applied = mentor::update(&ctx.db, input.id, &fields, now()).await?;
- require_applied(applied, NO_MENTOR)?;
+ match mentor::edit(&ctx.db, input.id, &input.before, &fields, now()).await? {
+ mentor::Edited::Saved => {}
+ mentor::Edited::Changed => {
+ return Err(CallError::invalid(
+ "打开编辑之后,这位导师的资料在别处有了变化(比如合并、导入或导师库更新)。\
+ 关掉后重新打开,再改一次。"
+ .into(),
+ ));
+ }
+ mentor::Edited::Gone => return Err(CallError::not_found(NO_MENTOR)),
+ }
look_up(ctx, input.id).await?;
get_mentor(ctx, MentorId { id: input.id }).await
}
@@ -1123,8 +1145,9 @@ async fn preview_merge(
Ok(mentor::merge::preview(&ctx.db, input.keep, input.other).await?)
}
-async fn merge_mentors(ctx: &Context, input: MentorMerge) -> Result<(), CallError> {
- mentor::merge::merge(&ctx.db, input.keep, input.other, now()).await?;
+async fn merge_mentors(ctx: &Context, input: PreviewedMerge) -> Result<(), CallError> {
+ let MentorMerge { keep, other } = input.pair;
+ mentor::merge::merge(&ctx.db, keep, other, &input.discarded_drafts, now()).await?;
ctx.reply_processor.wake();
ctx.todo_reminders.wake();
Ok(())
@@ -1278,6 +1301,9 @@ impl From for CallError {
"{name}老师还有 {count} 封邮件会发出(待发送、发送中或发送失败)。等它们发出,\
或改回草稿后再合并。"
)),
+ mentor::merge::MergeError::Changed => {
+ CallError::invalid("预览之后两位导师的草稿有了变化,请看过新的预览再合并。".into())
+ }
mentor::merge::MergeError::Db(e) => e.into(),
}
}
diff --git a/crates/api/tests/agent_effects.rs b/crates/api/tests/agent_effects.rs
index ee5c659a8..06107518f 100644
--- a/crates/api/tests/agent_effects.rs
+++ b/crates/api/tests/agent_effects.rs
@@ -632,6 +632,16 @@ async fn empty_save_does_not_delete_a_draft_whose_attachment_was_swapped(db: Sql
}
/// A paused task holding one approved first email to the mentor.
+/// The profile dialog's save of the mentor with only its emails changed.
+async fn emails_edit(ctx: &Context, id: i64, emails: &[&str]) -> Value {
+ let m = mentor::get(&ctx.db, id).await.unwrap().unwrap();
+ let before = json!({"name":m.name, "aliases":m.aliases, "emails":m.emails,
+ "affiliations":m.affiliations, "homepages":m.homepages, "research":m.research});
+ json!({"id":id, "before":before, "name":m.name, "aliases":m.aliases, "emails":emails,
+ "affiliations":m.affiliations, "homepages":m.homepages,
+ "research":m.research.unwrap_or_default()})
+}
+
async fn paused_task(ctx: &Context, mentor: i64) -> (i64, i64) {
let task = sqlx::query_scalar::<_, i64>(
r#"INSERT INTO task (name, kind, writer, schedule, state, created_at)
@@ -672,8 +682,7 @@ async fn resume_task_rechecks_the_pending_recipient_address(db: SqlitePool) {
call(
&ctx,
"updateMentor",
- json!({"id":mentor, "name":"王伟", "emails":["new@example.invalid"],
- "aliases":[], "affiliations":[], "homepages":[], "research":""}),
+ emails_edit(&ctx, mentor, &["new@example.invalid"]).await,
)
.await
.unwrap();
@@ -975,8 +984,7 @@ async fn recipient_edits_recheck_schedules_including_unassigned_task_mail(db: Sq
.await
.unwrap();
let input = if op == "updateMentor" {
- json!({"id":mentor, "name":name, "emails":[after], "aliases":[],
- "affiliations":[], "homepages":[], "research":""})
+ emails_edit(&ctx, mentor, &[&after]).await
} else {
let candidate = sqlx::query_scalar::<_, i64>(
r#"INSERT INTO candidate (name, emails, review, enrich)
@@ -1653,8 +1661,7 @@ async fn resume_mailbox_rechecks_recipients_and_deleting_a_task_rechecks_its_ema
call(
&ctx,
"updateMentor",
- json!({"id":mentor, "name":"王伟", "emails":["after@example.invalid"],
- "aliases":[], "affiliations":[], "homepages":[], "research":""}),
+ emails_edit(&ctx, mentor, &["after@example.invalid"]).await,
)
.await
.unwrap();
@@ -1839,10 +1846,7 @@ async fn recipient_edits_require_confirmation_only_for_approved_mail(db: SqliteP
.unwrap();
let decisions = json!({"take":[], "matches":[], "restore":[]});
let input = match op {
- "updateMentor" => json!({
- "id":mentor, "name":name, "emails":[after], "aliases":[],
- "affiliations":[], "homepages":[], "research":""
- }),
+ "updateMentor" => emails_edit(&ctx, mentor, &[&after]).await,
"addMentorEmail" => json!({"id":mentor, "address":after}),
"importMentors" => json!({
"fileName":"mentors.csv",
@@ -1901,6 +1905,12 @@ async fn recipient_edits_require_confirmation_only_for_approved_mail(db: SqliteP
.execute(&ctx.db)
.await
.unwrap();
+ // The safe edit changed the emails the first one began from.
+ let input = if op == "updateMentor" {
+ emails_edit(&ctx, mentor, &[&after]).await
+ } else {
+ input
+ };
call_agent(&ctx, op, input, |_| {}, pending())
.await
.unwrap();
diff --git a/crates/email/src/lib.rs b/crates/email/src/lib.rs
index 9f519233d..9e049dcdd 100644
--- a/crates/email/src/lib.rs
+++ b/crates/email/src/lib.rs
@@ -186,7 +186,7 @@ pub async fn attached(conn: &mut SqliteConnection, email_id: i64) -> Result Vec {
profile::update(
db,
&profile::ProfileForm {
- name: "学生".into(),
- research_interests: "视觉".into(),
- rewrite_preference: "".into(),
+ name: Some("学生".into()),
+ research_interests: Some("视觉".into()),
+ rewrite_preference: Some("".into()),
},
1,
)
@@ -413,9 +413,9 @@ async fn absent_model_consent_and_student_basis_are_explained_before_calling(db:
profile::update(
&db,
&profile::ProfileForm {
- name: "".into(),
- research_interests: "".into(),
- rewrite_preference: "".into(),
+ name: Some("".into()),
+ research_interests: Some("".into()),
+ rewrite_preference: Some("".into()),
},
30,
)
diff --git a/crates/mentor/src/lib.rs b/crates/mentor/src/lib.rs
index 4da752af1..2f7c932d4 100644
--- a/crates/mentor/src/lib.rs
+++ b/crates/mentor/src/lib.rs
@@ -956,6 +956,41 @@ pub async fn update<'c>(
Ok(true)
}
+/// What became of an edit made in a form.
+#[derive(Debug, PartialEq, Eq)]
+pub enum Edited {
+ Saved,
+ /// The fields are no longer those the form started from, as after a merge, an import or a
+ /// library update; saving would undo that.
+ Changed,
+ Gone,
+}
+
+/// Saves an edit of the fields `before` showed, unless they have changed since.
+pub async fn edit(
+ db: &SqlitePool,
+ id: i64,
+ before: &MentorFields,
+ after: &MentorFields,
+ now: i64,
+) -> Result {
+ let mut tx = db.begin().await?;
+ // Writing first holds the write lock from the comparison through the save.
+ let touched = sqlx::query!("UPDATE mentor SET updated_at = ? WHERE id = ?", now, id)
+ .execute(&mut *tx)
+ .await?;
+ if touched.rows_affected() == 0 {
+ return Ok(Edited::Gone);
+ }
+ let current = export::profiles(&mut tx, &[id]).await?;
+ if current.get(&id).map(|p| &p.fields) != Some(before) {
+ return Ok(Edited::Changed);
+ }
+ update(&mut *tx, id, after, now).await?;
+ tx.commit().await?;
+ Ok(Edited::Saved)
+}
+
/// Writes the mentor's lists in the given order. An address the mentor keeps keeps its row, so
/// that it is not looked up in the mailboxes again; one another mentor has fails the save.
async fn save_lists(
diff --git a/crates/mentor/src/merge.rs b/crates/mentor/src/merge.rs
index 4a4530573..7d28a2b6e 100644
--- a/crates/mentor/src/merge.rs
+++ b/crates/mentor/src/merge.rs
@@ -14,6 +14,8 @@ pub enum MergeError {
name: String,
count: i64,
},
+ /// The drafts the merge would discard are no longer those its preview showed.
+ Changed,
Db(sqlx::Error),
}
@@ -28,6 +30,7 @@ impl std::fmt::Display for MergeError {
match self {
MergeError::Gone => write!(f, "mentor gone"),
MergeError::Busy { .. } => write!(f, "mentor has emails still to go out"),
+ MergeError::Changed => write!(f, "drafts changed since the preview"),
MergeError::Db(e) => write!(f, "{e}"),
}
}
@@ -81,9 +84,15 @@ pub async fn preview(db: &SqlitePool, keep: i64, other: i64) -> Result Result<(), MergeError> {
+/// Merges `other` into `keep`, which takes its profile, mail and the rest, and deletes it.
+/// `discarded` is what the preview said goes of the drafts. `now` is UTC milliseconds.
+pub async fn merge(
+ db: &SqlitePool,
+ keep: i64,
+ other: i64,
+ discarded: &[email::DraftKind],
+ now: i64,
+) -> Result<(), MergeError> {
let mut tx = db.begin().await?;
let both = sqlx::query!(
"UPDATE mentor SET updated_at = ? WHERE id IN (?, ?) AND trashed_at IS NULL",
@@ -117,6 +126,11 @@ pub async fn merge(db: &SqlitePool, keep: i64, other: i64, now: i64) -> Result<(
count: b.count,
});
}
+ // A draft each can have only one: the kept mentor's stays.
+ let drafts = conflicting_drafts(&mut tx, keep, other).await?;
+ if drafts != discarded {
+ return Err(MergeError::Changed);
+ }
let (Some(a), Some(b)) = (detail(&mut tx, keep).await?, detail(&mut tx, other).await?) else {
return Err(MergeError::Gone);
};
@@ -203,8 +217,7 @@ pub async fn merge(db: &SqlitePool, keep: i64, other: i64, now: i64) -> Result<(
.await?;
}
- // A draft each can have only one: the kept mentor's stays.
- for kind in conflicting_drafts(&mut tx, keep, other).await? {
+ for kind in drafts {
sqlx::query!(
r#"DELETE
FROM email
diff --git a/crates/mentor/tests/edit.rs b/crates/mentor/tests/edit.rs
index 6590b3244..51d5956dc 100644
--- a/crates/mentor/tests/edit.rs
+++ b/crates/mentor/tests/edit.rs
@@ -4,7 +4,7 @@
mod common;
use common::names;
-use mentor::{Affiliation, MentorFields, MentorForm, SaveError};
+use mentor::{Affiliation, Edited, MentorFields, MentorForm, SaveError};
use sqlx::SqlitePool;
use test_fixtures::mail::mailbox;
@@ -140,6 +140,46 @@ fn lists_keep_their_order_without_blanks_or_repeats() {
assert_eq!(fields.homepages, ["https://x.cn"]);
}
+#[sqlx::test(migrator = "db::MIGRATOR")]
+async fn an_edit_does_not_undo_what_changed_since_it_began(db: SqlitePool) {
+ let shown = MentorFields {
+ name: "张明".into(),
+ emails: vec!["zhang@pku.edu.cn".into()],
+ ..MentorFields::default()
+ };
+ let id = mentor::create(&db, &shown, 0).await.unwrap();
+ // As a library update or a merge would, while the form was open.
+ assert!(
+ mentor::add_email(&db, id, "zhang@gmail.com", 1)
+ .await
+ .unwrap()
+ );
+ let renamed = |fields: &MentorFields| MentorFields {
+ name: "张明明".into(),
+ ..fields.clone()
+ };
+ assert_eq!(
+ mentor::edit(&db, id, &shown, &renamed(&shown), 2)
+ .await
+ .unwrap(),
+ Edited::Changed
+ );
+ let saved = mentor::get(&db, id).await.unwrap().unwrap();
+ assert_eq!(saved.name, "张明");
+ assert_eq!(saved.emails, ["zhang@pku.edu.cn", "zhang@gmail.com"]);
+ let shown = MentorFields {
+ emails: saved.emails,
+ ..shown
+ };
+ assert_eq!(
+ mentor::edit(&db, id, &shown, &renamed(&shown), 3)
+ .await
+ .unwrap(),
+ Edited::Saved
+ );
+ assert_eq!(mentor::get(&db, id).await.unwrap().unwrap().name, "张明明");
+}
+
#[sqlx::test(migrator = "db::MIGRATOR")]
async fn editing_updates_the_keyword_index(db: SqlitePool) {
let mut fields = form("张伟", "", "").check().unwrap();
diff --git a/crates/mentor/tests/matching.rs b/crates/mentor/tests/matching.rs
index e5257057e..1b222425f 100644
--- a/crates/mentor/tests/matching.rs
+++ b/crates/mentor/tests/matching.rs
@@ -102,9 +102,9 @@ async fn results_survive_queue_failure_retry_and_cancellation(db: SqlitePool) {
profile::update(
&db,
&profile::ProfileForm {
- name: "".into(),
- research_interests: "视觉".into(),
- rewrite_preference: "".into(),
+ name: Some("".into()),
+ research_interests: Some("视觉".into()),
+ rewrite_preference: Some("".into()),
},
20,
)
@@ -183,9 +183,9 @@ async fn results_survive_queue_failure_retry_and_cancellation(db: SqlitePool) {
#[sqlx::test(migrator = "db::MIGRATOR")]
async fn unchanged_interests_do_not_invalidate_a_result(db: SqlitePool) {
let form = profile::ProfileForm {
- name: "甲".into(),
- research_interests: " 视觉 ".into(),
- rewrite_preference: "".into(),
+ name: Some("甲".into()),
+ research_interests: Some(" 视觉 ".into()),
+ rewrite_preference: Some("".into()),
};
profile::update(&db, &form, 10).await.unwrap();
let id = add(&db, Some("视觉")).await;
@@ -193,7 +193,7 @@ async fn unchanged_interests_do_not_invalidate_a_result(db: SqlitePool) {
profile::update(
&db,
&profile::ProfileForm {
- name: "乙".into(),
+ name: Some("乙".into()),
..form
},
30,
@@ -229,9 +229,9 @@ async fn starting_single_or_batch_analysis_resumes_a_paused_queue(db: SqlitePool
#[sqlx::test(migrator = "db::MIGRATOR")]
async fn older_interest_updates_cannot_move_the_basis_time_back(db: SqlitePool) {
let form = |interests: &str| profile::ProfileForm {
- name: "".into(),
- research_interests: interests.into(),
- rewrite_preference: "".into(),
+ name: Some("".into()),
+ research_interests: Some(interests.into()),
+ rewrite_preference: Some("".into()),
};
profile::update(&db, &form("视觉"), 200).await.unwrap();
let id = add(&db, Some("视觉")).await;
diff --git a/crates/mentor/tests/merge.rs b/crates/mentor/tests/merge.rs
index 61d125fe4..06e0849a0 100644
--- a/crates/mentor/tests/merge.rs
+++ b/crates/mentor/tests/merge.rs
@@ -95,7 +95,9 @@ async fn a_mentor_listed_twice_becomes_one(db: SqlitePool) {
let preview = merge::preview(&db, li, ming).await.unwrap();
assert_eq!((preview.emails, preview.affiliations), (2, 1));
- merge::merge(&db, li, ming, 10).await.unwrap();
+ merge::merge(&db, li, ming, &preview.discarded_drafts, 10)
+ .await
+ .unwrap();
let merged = mentor::get(&db, li).await.unwrap().unwrap();
assert!(mentor::get(&db, ming).await.unwrap().is_none());
assert_eq!(merged.aliases, ["Ming Li"]);
@@ -140,13 +142,38 @@ async fn a_mentor_listed_twice_becomes_one(db: SqlitePool) {
reconciled(&mut db.acquire().await.unwrap()).await.unwrap();
}
+#[sqlx::test(migrator = "db::MIGRATOR")]
+async fn a_draft_written_after_the_preview_is_not_discarded(db: SqlitePool) {
+ let li = mentor(&db, "李明", &["liming@gmail.com"], "北京大学").await;
+ let ming = mentor(&db, "Ming Li", &["ming@pku.edu.cn"], "北京大学").await;
+ first_email(&db, li, "review", 0).await.unwrap();
+ let preview = merge::preview(&db, li, ming).await.unwrap();
+ assert!(preview.discarded_drafts.is_empty());
+ // Written in another window while the merge dialog was open.
+ let draft = first_email(&db, ming, "review", 0).await.unwrap();
+ assert!(matches!(
+ merge::merge(&db, li, ming, &preview.discarded_drafts, 10).await,
+ Err(MergeError::Changed)
+ ));
+ let state: String = sqlx::query_scalar("SELECT state FROM email WHERE id = ?")
+ .bind(draft)
+ .fetch_one(&db)
+ .await
+ .unwrap();
+ assert_eq!(state, "review");
+ let preview = merge::preview(&db, li, ming).await.unwrap();
+ merge::merge(&db, li, ming, &preview.discarded_drafts, 10)
+ .await
+ .unwrap();
+}
+
#[sqlx::test(migrator = "db::MIGRATOR")]
async fn mentors_with_mail_to_go_or_in_the_trash_are_not_merged(db: SqlitePool) {
let li = mentor(&db, "李明", &[], "北京大学").await;
let ming = mentor(&db, "李明", &[], "北京大学").await;
let approved = first_email(&db, ming, "approved", 0).await.unwrap();
assert!(matches!(
- merge::merge(&db, li, ming, 10).await,
+ merge::merge(&db, li, ming, &[], 10).await,
Err(MergeError::Busy { count: 1, .. })
));
exec(
@@ -157,11 +184,11 @@ async fn mentors_with_mail_to_go_or_in_the_trash_are_not_merged(db: SqlitePool)
.await;
exec(&db, "UPDATE mentor SET trashed_at = 1 WHERE id = ?", ming).await;
assert!(matches!(
- merge::merge(&db, li, ming, 10).await,
+ merge::merge(&db, li, ming, &[], 10).await,
Err(MergeError::Gone)
));
assert!(matches!(
- merge::merge(&db, li, li, 10).await,
+ merge::merge(&db, li, li, &[], 10).await,
Err(MergeError::Gone)
));
assert!(mentor::get(&db, ming).await.unwrap().is_some());
@@ -186,7 +213,7 @@ async fn compatible_affiliations_fill_blanks_and_keep_local_title(db: SqlitePool
let preview = merge::preview(&db, a, b).await.unwrap();
assert_eq!((preview.emails, preview.affiliations), (0, 1));
assert!(mentor::get(&db, b).await.unwrap().is_some());
- merge::merge(&db, a, b, 1).await.unwrap();
+ merge::merge(&db, a, b, &[], 1).await.unwrap();
let m = mentor::get(&db, a).await.unwrap().unwrap();
assert_eq!(
m.affiliations,
@@ -245,7 +272,9 @@ async fn task_pair(db: &SqlitePool, states: [&str; 2]) -> (i64, [i64; 2], [i64;
#[sqlx::test(migrator = "db::MIGRATOR")]
async fn merging_two_canceled_task_members_keeps_only_the_retained_mentors_email(db: SqlitePool) {
let (task, mentors, emails) = task_pair(&db, ["canceled", "canceled"]).await;
- merge::merge(&db, mentors[0], mentors[1], 10).await.unwrap();
+ merge::merge(&db, mentors[0], mentors[1], &[], 10)
+ .await
+ .unwrap();
let rows: Vec<(i64, i64, Option)> =
sqlx::query_as("SELECT id,mentor_id,task_id FROM email ORDER BY id")
.fetch_all(&db)
@@ -266,7 +295,9 @@ async fn merging_sent_and_canceled_task_members_keeps_the_sent_email_in_either_d
} else {
emails[1]
};
- merge::merge(&db, mentors[0], mentors[1], 10).await.unwrap();
+ merge::merge(&db, mentors[0], mentors[1], &[], 10)
+ .await
+ .unwrap();
let rows: Vec<(i64, i64, String)> =
sqlx::query_as("SELECT id,mentor_id,state FROM email WHERE task_id=?")
.bind(task)
@@ -295,7 +326,9 @@ async fn merging_two_sent_task_members_preserves_both_letters_and_only_one_membe
.fetch_all(&db)
.await
.unwrap();
- merge::merge(&db, mentors[0], mentors[1], 10).await.unwrap();
+ merge::merge(&db, mentors[0], mentors[1], &[], 10)
+ .await
+ .unwrap();
let after: Vec<(i64, String, String, i64)> =
sqlx::query_as("SELECT id,message_id,body_md,sent_at FROM email ORDER BY id")
.fetch_all(&db)
diff --git a/crates/profile/src/lib.rs b/crates/profile/src/lib.rs
index fc4f791ab..aedbb2310 100644
--- a/crates/profile/src/lib.rs
+++ b/crates/profile/src/lib.rs
@@ -14,13 +14,16 @@ pub struct Profile {
pub onboarded: bool,
}
-/// The profile as typed; blank fields are cleared.
-#[derive(Debug, Deserialize, TS)]
+/// The fields to change, as typed; the others stay, and blank ones are cleared.
+#[derive(Debug, Default, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct ProfileForm {
- pub name: String,
- pub research_interests: String,
- pub rewrite_preference: String,
+ #[ts(optional)]
+ pub name: Option,
+ #[ts(optional)]
+ pub research_interests: Option,
+ #[ts(optional)]
+ pub rewrite_preference: Option,
}
fn filled(text: &str) -> Option<&str> {
@@ -45,21 +48,20 @@ pub async fn finish_onboarding(db: &SqlitePool) -> Result<(), sqlx::Error> {
}
pub async fn update(db: &SqlitePool, form: &ProfileForm, now: i64) -> Result {
- let (name, research_interests, rewrite_preference) = (
- filled(&form.name),
- filled(&form.research_interests),
- filled(&form.rewrite_preference),
- );
sqlx::query_as!(
Profile,
r#"UPDATE profile
- SET name = ?1, research_interests = ?2, rewrite_preference = ?3,
- match_basis_changed_at = CASE WHEN research_interests IS NOT ?2 THEN max(
- coalesce(match_basis_changed_at, ?4), ?4) ELSE match_basis_changed_at END
+ SET name = iif(?1, ?2, name), research_interests = iif(?3, ?4, research_interests),
+ rewrite_preference = iif(?5, ?6, rewrite_preference),
+ match_basis_changed_at = CASE WHEN ?3 AND research_interests IS NOT ?4 THEN max(
+ coalesce(match_basis_changed_at, ?7), ?7) ELSE match_basis_changed_at END
RETURNING name, research_interests, rewrite_preference, onboarded AS "onboarded: bool""#,
- name,
- research_interests,
- rewrite_preference,
+ form.name.is_some(),
+ form.name.as_deref().and_then(filled),
+ form.research_interests.is_some(),
+ form.research_interests.as_deref().and_then(filled),
+ form.rewrite_preference.is_some(),
+ form.rewrite_preference.as_deref().and_then(filled),
now,
)
.fetch_one(db)
diff --git a/crates/profile/tests/profile.rs b/crates/profile/tests/profile.rs
index 7ad042a30..1a2bf8413 100644
--- a/crates/profile/tests/profile.rs
+++ b/crates/profile/tests/profile.rs
@@ -5,16 +5,24 @@ use profile::{LetterFont, LetterStyle, ProfileForm};
use sqlx::SqlitePool;
#[sqlx::test(migrator = "db::MIGRATOR")]
-async fn blank_fields_are_cleared(db: SqlitePool) {
- let form = |name: &str| ProfileForm {
- name: name.into(),
- research_interests: "自然语言处理\n".into(),
- rewrite_preference: " ".into(),
+async fn blank_fields_are_cleared_and_missing_ones_stay(db: SqlitePool) {
+ let form = ProfileForm {
+ name: Some(" 林晓 ".into()),
+ research_interests: Some("自然语言处理\n".into()),
+ rewrite_preference: Some(" ".into()),
};
- let saved = profile::update(&db, &form(" 林晓 "), 1).await.unwrap();
+ let saved = profile::update(&db, &form, 1).await.unwrap();
assert_eq!(saved.name.as_deref(), Some("林晓"));
assert_eq!(saved.research_interests.as_deref(), Some("自然语言处理"));
assert_eq!(saved.rewrite_preference, None);
+ // The page saves the name while an Agent's new research interests stand.
+ let renamed = ProfileForm {
+ name: Some("林晓晓".into()),
+ ..ProfileForm::default()
+ };
+ let saved = profile::update(&db, &renamed, 2).await.unwrap();
+ assert_eq!(saved.name.as_deref(), Some("林晓晓"));
+ assert_eq!(saved.research_interests.as_deref(), Some("自然语言处理"));
}
#[sqlx::test(migrator = "db::MIGRATOR")]
diff --git a/crates/receive/tests/outcome.rs b/crates/receive/tests/outcome.rs
index 80c79bd42..753bf9484 100644
--- a/crates/receive/tests/outcome.rs
+++ b/crates/receive/tests/outcome.rs
@@ -190,7 +190,7 @@ async fn newest_first_recovery_trash_merge_and_cascade_preserve_work(db: SqliteP
)
.await
.unwrap();
- mentor::merge::merge(&db, keep, m, 6).await.unwrap();
+ mentor::merge::merge(&db, keep, m, &[], 6).await.unwrap();
let claim = queue::next(&db).await.unwrap().unwrap();
assert_eq!(claim.id, b);
assert!(queue::complete(&db, claim, R::Interested).await.unwrap());
@@ -1064,7 +1064,7 @@ async fn shared_reply_todos_choose_the_sender_address_then_lowest_mentor_id(db:
.unwrap();
assert_eq!(actual, owner);
}
- mentor::merge::merge(&db, low, high, 5).await.unwrap();
+ mentor::merge::merge(&db, low, high, &[], 5).await.unwrap();
assert_eq!(email::todos::for_mentor(&db, low).await.unwrap().len(), 2);
mentor::trash::trash(&db, &[low], 6).await.unwrap();
mentor::trash::delete(&db, &[low]).await.unwrap();
diff --git a/crates/send/tests/send.rs b/crates/send/tests/send.rs
index d68c3fe24..5adb9f1d1 100644
--- a/crates/send/tests/send.rs
+++ b/crates/send/tests/send.rs
@@ -3358,7 +3358,7 @@ async fn merging_preserves_canceled_mail_for_late_bounces(db: SqlitePool) {
.unwrap();
empty.push(id);
}
- mentor::merge::merge(&db, keep, other, at + DAY)
+ mentor::merge::merge(&db, keep, other, &[], at + DAY)
.await
.unwrap();
assert!(
diff --git a/crates/task/tests/tasks.rs b/crates/task/tests/tasks.rs
index 4db8d729c..b5c27f145 100644
--- a/crates/task/tests/tasks.rs
+++ b/crates/task/tests/tasks.rs
@@ -2049,7 +2049,9 @@ async fn merging_first_and_follow_up_drafts_keeps_only_the_retained_mentors(db:
[email::DraftKind::FollowUp]
) && !keep_follow_up
);
- mentor::merge::merge(&db, keep, other, T + 1).await.unwrap();
+ mentor::merge::merge(&db, keep, other, &preview.discarded_drafts, T + 1)
+ .await
+ .unwrap();
let pending: Vec<(i64, String)> = sqlx::query_as(
r#"SELECT id, kind
FROM email
@@ -2104,7 +2106,9 @@ async fn merging_two_follow_up_drafts_keeps_only_the_retained_mentors(db: Sqlite
preview.discarded_drafts.as_slice(),
[email::DraftKind::FollowUp]
));
- mentor::merge::merge(&db, keep, other, T + 1).await.unwrap();
+ mentor::merge::merge(&db, keep, other, &preview.discarded_drafts, T + 1)
+ .await
+ .unwrap();
let drafts: Vec = sqlx::query_scalar(
r#"SELECT id
FROM email
@@ -2140,7 +2144,9 @@ async fn merging_replied_mentors_cancels_follow_up_in_either_direction(db: Sqlit
"From: m{replied}@example.com\nMessage-ID: \nSubject: 回复\n\n欢迎交流。"
));
receive::round(&db, &net, T + 2).await.unwrap();
- mentor::merge::merge(&db, keep, other, T + 3).await.unwrap();
+ mentor::merge::merge(&db, keep, other, &[], T + 3)
+ .await
+ .unwrap();
assert!(email::follow_up::draft(&db, keep).await.unwrap().is_none());
let canceled = email::list(&db, keep)
.await
diff --git a/crates/writing/tests/write.rs b/crates/writing/tests/write.rs
index 75ae6e7a3..97ce074a2 100644
--- a/crates/writing/tests/write.rs
+++ b/crates/writing/tests/write.rs
@@ -17,9 +17,9 @@ const T: i64 = 1_800_000_000_000;
/// returns the mentor and the material for writing.
async fn setup(db: &SqlitePool) -> (i64, i64) {
let me = profile::ProfileForm {
- name: "林晓".into(),
- research_interests: "多模态学习".into(),
- rewrite_preference: "语气再正式一些".into(),
+ name: Some("林晓".into()),
+ research_interests: Some("多模态学习".into()),
+ rewrite_preference: Some("语气再正式一些".into()),
};
profile::update(db, &me, T).await.unwrap();
let material = async |name: &str, for_writing: bool, text: &str| {
diff --git a/docs/data-model.md b/docs/data-model.md
index 93499d7fe..bf04154ed 100644
--- a/docs/data-model.md
+++ b/docs/data-model.md
@@ -231,7 +231,7 @@
| 研究方向、来源网址、负责邮箱、匹配结果 | 保留的导师有就用它的,没有用另一位的 |
| 备注 | 两位都有时接在一起 |
| 邮件、来往邮件、待办 | 都归到保留的导师,联系状态按合并后的重算;同一封来往邮件两位都有的,只留一份 |
-| 没写完的首封、单独追问和回复 | 首封和单独追问算同一类,回复另算一类;同一类两位都有时留保留的导师的,删掉另一位的(合并预览说明删掉哪位导师的哪种草稿) |
+| 没写完的首封、单独追问和回复 | 首封和单独追问算同一类,回复另算一类;同一类两位都有时留保留的导师的,删掉另一位的(合并预览说明删掉哪位导师的哪种草稿;合并时重新算一遍,和预览说的不同就不合并,重新预览后再确认) |
| 同一个任务里两位都有邮件 | 这个任务里只留一封:发出过的优先,都发出过或都没发出时留保留的导师的。另一封取消了的删掉(取消前交出过一次的除外);发出过的照样归到保留的导师,只是不再算在这个任务里 |
| 候选导师、导师库的待处理更新 | 改为对应保留的导师 |
diff --git a/docs/ui.md b/docs/ui.md
index 53891c3ad..620f97151 100644
--- a/docs/ui.md
+++ b/docs/ui.md
@@ -68,7 +68,7 @@
- 右上角"跳过引导",最后一步下面"完成":都会结束引导,去导师列表,以后不再显示。
- 标签:导师详情的"标签"旁点"编辑",弹出所有标签逐个勾选,下面的输入框按回车新建并直接加上,新标签按预设颜色依次取色。"管理标签"里改名、换颜色、调顺序和删除,删除要再点一次确认;标签在各处都按这里的顺序排。
- 勾选:表格每行前面有复选框,表头的复选框勾选或取消本页全部。翻页保留勾选,换联系状态或关键词时清空。批量操作条浮在内容页底部中间:取消选择、已选几位和操作。
-- 个人资料(设置 › 个人 › 个人资料):姓名、意向研究方向和草稿改写偏好,输入后自动保存。意向研究方向在分析匹配度时和材料一起作为依据,改写偏好在 AI 写草稿和改写时遵循。
+- 个人资料(设置 › 个人 › 个人资料):姓名、意向研究方向和草稿改写偏好,输入后自动保存,只保存改的那一项;Agent 同时改了别的项,页面照新的显示,不会被写回旧的。意向研究方向在分析匹配度时和材料一起作为依据,改写偏好在 AI 写草稿和改写时遵循。
- 材料(设置 › 个人 › 材料):列出各份材料的文件名和大小(文字超过 1 万字的注明"AI 只读前 1 万字"),每份下面勾选用途:匹配分析、写信参考、默认附件,改了马上保存。"添加材料"选 PDF、DOCX 或 DOC 文件(不超过 20 MB),提取完文字后出现在列表里。新材料默认用于匹配分析和写信参考,不默认作附件:附件会改变导师收到的内容,由用户自己决定。没提取到文字的(比如扫描件)标出来,只能勾选默认附件。"查看文字"显示提取出的文字,供核对;删除前先确认,待发的邮件用它作附件时不能删,说明原因。
- 模板(设置 › 个人 › 模板):列出各个模板的名称和主题,可以添加、编辑和删除。可以把一个模板"设为默认",标上"默认",再按一次"取消默认";添加的第一个模板自动设为默认。编辑框里的名称、主题和正文都要填;正文上方的工具栏有加粗、斜体、链接、无序和有序列表,以及"插入占位符"。认得的占位符在正文里高亮显示,打错的不高亮。粘贴进来的内容只保留这几种格式。编辑框上方有"从 Word 导入":选 DOCX 或 DOC 文件(不超过 20 MB),正文换成文件里的文字,和粘贴一样只保留段落、换行、加粗、斜体、链接和列表;标题当普通段落,表格的每个格子一段,页眉页脚、脚注、批注和图片不导入(DOC 文件里的文本框也不导入)。名称空着时填文件名;正文已经有内容时先确认替换;主题自己填。链接只能指向网页和邮箱地址,没写协议的按网页处理,带 @ 的按邮箱地址处理。
- 信件样式(设置 › 个人 › 信件样式):字体(跟随收件人的邮箱、宋体、黑体)、字号(五号、小四、四号)、行距、段距和段首空两格,改了马上保存。下面用一封示例信按当前样式预览,背景始终是白色。
@@ -99,7 +99,7 @@
- 赞助到期后不显示开关和"立即备份",写"赞助已到期,云端备份已停止。现有的备份还能恢复,将于3月1日删除。",旁边是"续赞助"。
- 从云端恢复:按钮换成"正在下载…",下载、检查好后弹出和本地恢复一样的确认框。云端那份用旧密码加密、解不开时不显示"从云端恢复"。
- 删除云端备份:确认框写"删除后云端不再有备份,自动备份也会关闭。这台电脑上的数据不动。",按"删除"。
-- 导师资料(添加导师、编辑资料):姓名、别名、邮箱、单位、主页和研究方向。邮箱、别名和主页每个一行,"添加"多一行,行尾的"×"去掉一行;别名多数导师没有,没有时只显示"添加别名";第一个邮箱标"发信",别的邮箱可以"设为发信地址",移到第一个。单位一组是学校、学院、系所和职称四栏,第一组标"主要",别的可以"设为主要";手机上四栏上下排。只有一个的时候和普通的输入框一样,不显示这些标记。
+- 导师资料(添加导师、编辑资料):姓名、别名、邮箱、单位、主页和研究方向。邮箱、别名和主页每个一行,"添加"多一行,行尾的"×"去掉一行;别名多数导师没有,没有时只显示"添加别名";第一个邮箱标"发信",别的邮箱可以"设为发信地址",移到第一个。单位一组是学校、学院、系所和职称四栏,第一组标"主要",别的可以"设为主要";手机上四栏上下排。只有一个的时候和普通的输入框一样,不显示这些标记。编辑资料时,如果打开以后这位导师的资料在别处变了(合并、导入、导师库更新),保存时不覆盖,提示关掉重新打开再改。
- 发件邮箱(导师详情的资料里,自己有两个以上邮箱时显示):下拉框里是"自动"和各个邮箱,选了马上保存。"自动"按轮转选,第一次发出后记下,之后显示记下的那个;选了一个邮箱,首封、追问和回复都从它发,它暂停时等它恢复。联系过的导师换了邮箱,下面说明导师之后看到的发件地址会变。
- 合并导师:在导师列表勾选两位,批量操作条上点"合并"。弹窗里并排是两位的姓名、主要单位、邮箱和联系状态,选保留哪一位(默认先勾选的那位),下面说明合并后的结果:邮箱和单位各有几个、另一位的姓名记为别名,两位都有没写完的信时删掉哪位导师的哪种草稿:首封和单独追问算同一类,回复另算一类,同一类留保留的导师的。"合并"后进入保留的导师的详情。有还会发出的邮件时不合并,说明是哪位导师的几封、怎么处理。
- 匹配度(导师列表的一栏):分数;在排队的显示"排队中",正在分析的显示"分析中",有旧分数的旧分数照常显示在旁边;失败的显示"失败",悬停看原因;依据有变化的分数旁边标一个灰点,悬停说明"材料或意向研究方向改过,可以重新分析"。
diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts
index ecf3b1643..884e8d4b2 100644
--- a/ui/src/api/types.ts
+++ b/ui/src/api/types.ts
@@ -441,7 +441,11 @@ affiliations: Array, homepages: Array,
*/
sourceUrl: string | null, research: string | null, papers: Array, note: string | null, analysis: MatchAnalysis, contact: Contact, tags: Array, trashed: boolean, library: Array, };
-export type MentorEdit = { id: number, name: string, aliases: Array, emails: Array, affiliations: Array, homepages: Array, research: string, };
+export type MentorEdit = { id: number,
+/**
+ * The fields as they were when editing began.
+ */
+before: MentorFields, name: string, aliases: Array, emails: Array, affiliations: Array, homepages: Array, research: string, };
export type MentorFields = { name: string, aliases: Array, emails: Array, affiliations: Array, homepages: Array, research: string | null, };
@@ -663,6 +667,8 @@ export type Preview = { sheets: Array, sheet: number, columns: Array, keep: number, other: number, };
+
export type PreviousLetter = { subject: string | null, sentAt: number, };
export type PreviousPreview = { subject: string | null, sentAt: number, attribution: string, bodyText: string | null, };
@@ -671,7 +677,7 @@ export type Problem = { "kind": "placeholder", name: string, } | { "kind": "no_s
export type Profile = { name: string | null, researchInterests: string | null, rewritePreference: string | null, onboarded: boolean, };
-export type ProfileForm = { name: string, researchInterests: string, rewritePreference: string, };
+export type ProfileForm = { name?: string, researchInterests?: string, rewritePreference?: string, };
export type Progress = { generating: number, review: number, generationFailed: number, approved: number, sent: number, failed: number, canceled: number, };
@@ -1044,7 +1050,7 @@ export interface Operations {
setMentorMailbox: { input: MentorMailbox; output: MentorDetail };
addMentorEmail: { input: MentorAddress; output: MentorDetail };
previewMerge: { input: MentorMerge; output: MergePreview };
- mergeMentors: { input: MentorMerge; output: null };
+ mergeMentors: { input: PreviewedMerge; output: null };
listTrash: { input: TrashQuery; output: MentorPage };
trashMentors: { input: MentorIds; output: null };
restoreMentors: { input: MentorIds; output: null };
diff --git a/ui/src/components/mentor-dialog.tsx b/ui/src/components/mentor-dialog.tsx
index e8725cb6f..fa60d3eba 100644
--- a/ui/src/components/mentor-dialog.tsx
+++ b/ui/src/components/mentor-dialog.tsx
@@ -3,7 +3,7 @@ import { Plus, X } from "lucide-react";
import { type ReactNode, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { errorMessage } from "@/api/call";
-import type { Affiliation, MentorDetail, MentorForm } from "@/api/types";
+import type { Affiliation, MentorDetail, MentorFields, MentorForm } from "@/api/types";
import { Field, formClose } from "@/components/form";
import { Button } from "@/components/ui/button";
import { Dialog, DialogBody, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@/components/ui/dialog";
@@ -32,18 +32,23 @@ const text = (label: string, value: string, onChange: (value: string) => void) =
onChange(e.target.value)} />
);
-/** The form for a mentor's existing fields; blank ones stay blank. */
-export function formOf(m: MentorDetail): MentorForm {
+/** A mentor's own fields as shown, which an edit starts from. */
+export function fieldsOf(m: MentorDetail): MentorFields {
return {
name: m.name,
aliases: m.aliases,
emails: m.emails,
affiliations: m.affiliations,
homepages: m.homepages,
- research: m.research ?? "",
+ research: m.research,
};
}
+/** The form for a mentor's existing fields; blank ones stay blank. */
+export function formOf(m: MentorDetail): MentorForm {
+ return { ...fieldsOf(m), research: m.research ?? "" };
+}
+
/**
* Adding or editing a mentor's own fields. The core trims and checks them, leaves out blank rows and
* says what to fix. Mounted while open, so every opening starts from `initial`.
diff --git a/ui/src/components/merge-dialog.tsx b/ui/src/components/merge-dialog.tsx
index fa05bfb34..324e7dc44 100644
--- a/ui/src/components/merge-dialog.tsx
+++ b/ui/src/components/merge-dialog.tsx
@@ -2,7 +2,7 @@ import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/rea
import { useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { call, errorMessage } from "@/api/call";
-import type { MentorDetail, MergePreview } from "@/api/types";
+import type { DraftKind, MentorDetail, MergePreview } from "@/api/types";
import { ContactLabel, dropDrafts, placeOf, refreshMentors } from "@/components/mentor";
import { Button } from "@/components/ui/button";
import { Dialog, DialogBody, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@/components/ui/dialog";
@@ -24,7 +24,9 @@ export function MergeDialog({ ids, onClose }: { ids: [number, number]; onClose:
queryFn: () => call("previewMerge", { keep, other }),
});
const merge = useMutation({
- mutationFn: (pair: { keep: number; other: number }) => call("mergeMentors", pair),
+ mutationFn: (pair: { keep: number; other: number; discardedDrafts: DraftKind[] }) => call("mergeMentors", pair),
+ // Drafts changed since the preview: show the new one before merging.
+ onError: () => void queryClient.invalidateQueries({ queryKey: ["previewMerge"] }),
onSuccess: async (_, pair) => {
queryClient.removeQueries({ queryKey: ["getMentor", pair.other] });
dropDrafts(queryClient, [pair.keep, pair.other]);
@@ -78,7 +80,10 @@ export function MergeDialog({ ids, onClose }: { ids: [number, number]; onClose:
{merge.isError && errorMessage(merge.error)}
}>取消
-