Coding4Fun - 也來寫個「刪除五秒內可反悔」功能
| | | 0 | |
前幾天試了更多確認刪除玩法,讀者 Chi-Kung Wen 留言再提到「刪除信件後,五秒內可以按【復原】取消刪除」的介面設計。
直覺要做不難,心中也約略有譜,想到最近沒什麼機會寫 ASP.NET Minimal API 都快生疏了,便撿起題目來個 Vue3 輕前端整合 ASP.NET Core 伸展一下。
「刪除五秒內可反悔」換個角度可想成「延遲五秒再執行刪除,在延遲期間允許取消」,這可以從前端下手也可由後端實現:
- 從前端下手
用 setTimeout 等技巧延遲 5 秒才送出刪除 API,延遲期間如要取消呼叫 clearTimeout 即可,後端完全不用動。 - 由後端實現
後端收到刪除 API 請求不馬上執行,而是將「刪除該筆資料」任務放入 Queue,等預設時間到再執行,等待期間如收到取消 API 則將該任務從 Queue 移除。
註:系統設計成「刪除後先放資源回收桶」也能實現類似效果,但複雜度會高很多。
相較之下,用前端來做簡單許多,但有一些缺點。
首先,setTimeout 成功運作的前題是使用者按下刪除後沒有關掉網頁、網路未斷線,很難避免「使用者以為資料已刪但實際還在」的狀況,容易引發爭議。第二,就「刪除」跟「復原」用語,使用者的理解偏向按下「刪除」資料就已刪除,之後的「復原」是將刪除的東西救回來,以此標準 setTimeout 延遲送出刪除 API 是偷吃步,做個簡單檢測便會穿幫:同時開兩個瀏覽器檢視同一頁面,從 A 瀏覽器刪除項目 X 後 B 瀏覽器重新載入,此時項目 X 應顯示為「已刪除但還有機會復原」才合理,用 setTimeout 完全無法做到這一點。
因此,即便工程較大較麻煩,這個需求還是要從後端實現才能符合規格,確保狀態的一致性。
要在 C# 實作延遲刪除,最直覺簡便的做法是另跑 Task 先 Task.Delay() 後刪資料,要還原可用 CancellationTokenSource.Cancel 取消任務,但如此會依賴記億體的物件個體及狀態資料,遇到程序重啟會遺失資料、該刪未刪,跟 JavaScript setTimeout 缺點相似。
評估之後,我決定回歸用資料物件記錄狀態,範例程式用 List<EnittyObject> 模擬,真實世界則可應用資料庫、Message Queue、背景排程機制實現相同邏輯,較具可行性。
這個小範例主要靠一個 Program.cs 加一個 index.html,二者程式碼不多,都約 100 行左右。

最後完成的效果如下,刪除後介面會倒數五秒時間讓你有機會按鈕復原,期間重新整理讀取清單,該筆資料接續顯示倒數證明狀態不會遺失,而等待五秒後復原鈕消失,資料正式被刪除。
附上程式,Program.cs:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.UseFileServer(); // 前端使用 wwwroot/index.html 實作
app.UseDefaultFiles();
app.MapGet("/api/mails", () => SimuMailStore.Mails);
app.MapPost("/api/del/{id}", (Guid id) =>
{
var mail = SimuMailStore.Mails.FirstOrDefault(m => m.Id == id);
if (mail == null) return Results.NotFound();
return Results.Ok(SimuMailStore.Delete(mail));
});
app.MapPost("/api/restore/{id}", (Guid id) =>
{
var mail = SimuMailStore.Restore(id);
if (mail == null) return Results.NotFound();
return Results.Ok(mail);
});
app.Run();
public class MailItem
{
public Guid Id { get; set; } = Guid.NewGuid();
public DateTime RecvTime { get; set; } = DateTime.Now;
public string Subject { get; set; } = string.Empty;
public Guid? DelTaskId { get; set; } // 延遲刪除任務 ID
public DateTime? UndoDueTime { get; set; } // 取消刪除截止時間
}
public class DelTask
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid MailId { get; set; }
public DateTime SchExecTime { get; set; } = DateTime.Now.AddSeconds(5); // 排定刪除時間,預設 5 秒後
}
// 延遲刪除有很多種不同做法,這裡不用 .NET Task 而是用資料物件 List 模擬,對應實際應用後端多以資料庫為中心
// 註:範例為求簡化,未考慮多執行緒安全性
public class SimuMailStore
{
static List<MailItem> _items { get; set; } =
[
new() { RecvTime = DateTime.Parse("2025-07-01 16:30:00"), Subject="是芥末日要來了,就在 7/5!" },
new() { RecvTime = DateTime.Parse("2025-07-05 03:20:00"), Subject="喵的,根本不是末日,我們被騙了~" }
];
public static IEnumerable<MailItem> Mails => _items;
static List<DelTask> _delTasks { get; set; } = [];
static SimuMailStore()
{
// 批次執行到期的延遲刪除指令,此處用 Task.Run + while(true) + Task.Delay() 模擬之
Task.Run(() =>
{
while (true)
{
var tasksToRemove = _delTasks.Where(t => t.SchExecTime.CompareTo(DateTime.Now) <= 0).ToList();
foreach (var task in tasksToRemove)
{
var mail = _items.FirstOrDefault(m => m.Id == task.MailId);
if (mail != null)
{
_items.Remove(mail);
}
_delTasks.Remove(task);
}
Task.Delay(1000).Wait(); // 每秒檢查一次
}
});
}
public static MailItem Delete(MailItem mail)
{
var task = new DelTask
{
MailId = mail.Id
};
_delTasks.Add(task);
mail.DelTaskId = task.Id;
mail.UndoDueTime = task.SchExecTime;
return mail;
}
public static MailItem Restore(Guid taskId)
{
var task = _delTasks.FirstOrDefault(t => t.Id == taskId);
if (task == null) return null!;
_delTasks.Remove(task);
var mail = _items.FirstOrDefault(m => m.Id == task.MailId);
if (mail == null) return null!;
mail.DelTaskId = null;
mail.UndoDueTime = null;
return mail;
}
}
index.html
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>刪除後反悔</title>
<style>
html,body { font-size: 10pt; }
table {
width: 600px; border-collapse: collapse; margin-top: 6px;
thead { background-color: #f2f2f2; }
td, th {
border: 1px solid #ddd;
padding: 3px 6px;
}
tr.deleted, tr.pending {
td { text-decoration: line-through;}
}
.actions { text-align: center;}
.deleted .actions button { display: none; }
}
</style>
</head>
<body>
<div id="app">
<button @click="fetchMails">重新查詢</button> 資料時間: {{ dataTime }}
<table>
<thead>
<tr>
<th style="width: 120px;">收件時間</th>
<th style="width: 350px;">信件主旨</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(mail, index) in mails" :key="index"
:class="{ deleted: mail.deleted, pending: mail.delTaskId }">
<td>{{ new Date(mail.recvTime).toLocaleString('sv') }}</td>
<td>{{ mail.subject }}</td>
<td class="actions">
<button @click="deleteMail(index)" v-show="!mail.delTaskId">刪除</button>
<button @click="restoreMail(index)" v-show="mail.delTaskId">
復原
({{ mail.undoCountDown }})
</button>
</td>
</tr>
</tbody>
</table>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script>
const app = Vue.createApp({
data() {
return {
mails: [],
dataTime: ''
}
},
methods: {
async fetchMails() {
const response = await fetch('/api/mails');
this.mails = await response.json();
this.mails.forEach(mail => this.setDueCountDown(mail));
this.dataTime = new Date().toLocaleString('sv');
},
setDueCountDown(mail) {
if (mail.delTaskId) {
mail.undoCountDown = Math.max(0, Math.floor((new Date(mail.undoDueTime) - new Date()) / 1000));
}
return mail;
},
async deleteMail(index) {
const mail = this.mails[index];
const resMail = await fetch(`/api/del/${mail.id}`, { method: 'POST' }).then(res => res.json());
this.mails[index] = this.setDueCountDown(resMail);
},
async restoreMail(index) {
const mail = this.mails[index];
const resMail = await fetch(`/api/restore/${mail.delTaskId}`, { method: 'POST' }).then(res => res.json());
if (resMail) {
this.mails[index] = resMail;
} else {
alert('無法復原,可能已超過時限或已被永久刪除。');
this.fetchMails();
}
}
}
});
const vm = app.mount('#app');
vm.fetchMails();
setInterval(() => {
vm.mails.forEach(mail => {
if (mail.delTaskId) {
if (mail.undoCountDown > 0) {
mail.undoCountDown--;
}
else {
mail.delTaskId = null; // 清除刪除任務 ID
mail.undoDueTime = null; // 清除復原截止時間
mail.undoCountDown = undefined; // 重置倒數計時
mail.deleted = true; // 標記為已刪除
}
}
});
}, 1000);
</script>
</body>
</html>
I implemented a “delete with undo” feature using Vue3 and ASP.NET Core. After deleting, users have a five-second window to undo the action. The system ensures data consistency by handling the delay server-side, avoiding issues like unsynced states across different browsers or sessions.
Comments
Be the first to post a comment