|
| 1 | +/** |
| 2 | + * ModelCardCreateDialog — 组侧「+ 概念卡」创建弹窗(v0.13.2 §五)。 |
| 3 | + * |
| 4 | + * @ai-context: 双面体单向升格(§一)——model 卡(记忆面)只在组内创建,概念 |
| 5 | + * (思辨面)在体系;本弹窗只在选定组内建卡,不建体系侧入口 |
| 6 | + * (体系只引用、不收纳)。create_model_card 幂等:同组同名已有卡 |
| 7 | + * 返回既有卡——成功提示明确说明"已存在则返回既有卡",不误导重复记账。 |
| 8 | + * @ai-context: 不预填内容(预填=假燃料)——name 必填,三问(本质/边界/联系) |
| 9 | + * 全部可空;card 背面由后端按卡面契约 compose(§三),前端只提交字段。 |
| 10 | + */ |
| 11 | +import { useState } from "react"; |
| 12 | +import { invoke } from "@tauri-apps/api/core"; |
| 13 | +import type { Flashcard } from "../types"; |
| 14 | + |
| 15 | +interface Props { |
| 16 | + /** 目标组 id(组仍是唯一容器——卡只在组内创建) */ |
| 17 | + groupId: number; |
| 18 | + /** 组名(标题呈现用) */ |
| 19 | + groupName: string; |
| 20 | + /** 关闭弹窗(遮罩/✕) */ |
| 21 | + onClose: () => void; |
| 22 | + /** 创建成功后回调(父级可刷新/关闭;无组卡列表时可为空) */ |
| 23 | + onCreated?: () => void; |
| 24 | +} |
| 25 | + |
| 26 | +export default function ModelCardCreateDialog({ groupId, groupName, onClose, onCreated }: Props) { |
| 27 | + const [name, setName] = useState(""); |
| 28 | + const [essence, setEssence] = useState(""); |
| 29 | + const [boundary, setBoundary] = useState(""); |
| 30 | + const [relation, setRelation] = useState(""); |
| 31 | + const [busy, setBusy] = useState(false); |
| 32 | + const [status, setStatus] = useState<{ text: string; error: boolean } | null>(null); |
| 33 | + |
| 34 | + const submit = async () => { |
| 35 | + const trimmed = name.trim(); |
| 36 | + // 前端仅做非空拦截;name 归一化/≤2000 由 command 层校验 |
| 37 | + if (!trimmed) { setStatus({ text: "概念名不能为空", error: true }); return; } |
| 38 | + setBusy(true); setStatus(null); |
| 39 | + try { |
| 40 | + await invoke<Flashcard>("create_model_card", { |
| 41 | + groupId, |
| 42 | + name: trimmed, |
| 43 | + essence: essence.trim() || null, |
| 44 | + boundary: boundary.trim() || null, |
| 45 | + relation: relation.trim() || null, |
| 46 | + }); |
| 47 | + setStatus({ text: `已创建概念卡「${trimmed}」(已存在则返回既有卡)`, error: false }); |
| 48 | + onCreated?.(); |
| 49 | + } catch (e) { |
| 50 | + setStatus({ text: `创建失败: ${e}`, error: true }); |
| 51 | + } finally { |
| 52 | + setBusy(false); |
| 53 | + } |
| 54 | + }; |
| 55 | + |
| 56 | + return ( |
| 57 | + <div |
| 58 | + style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", zIndex: 1000, display: "flex", alignItems: "center", justifyContent: "center" }} |
| 59 | + onClick={onClose} |
| 60 | + > |
| 61 | + <div |
| 62 | + data-testid="model-card-dialog" |
| 63 | + onClick={(e) => e.stopPropagation()} |
| 64 | + style={{ width: 380, maxWidth: "92vw", background: "#fff", borderRadius: 12, padding: 20, boxShadow: "0 12px 40px rgba(0,0,0,0.2)" }} |
| 65 | + > |
| 66 | + <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}> |
| 67 | + <span style={{ fontWeight: 600, fontSize: 14 }}>🧠 概念卡 · {groupName}</span> |
| 68 | + <button data-testid="model-card-cancel" onClick={onClose} style={{ marginLeft: "auto", cursor: "pointer", fontSize: 13 }}>✕ 关闭</button> |
| 69 | + </div> |
| 70 | + |
| 71 | + <label style={label}>概念名 *</label> |
| 72 | + <input data-testid="model-card-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="卡住你的词/概念" style={input} /> |
| 73 | + <label style={label}>本质(它"是"什么)</label> |
| 74 | + <textarea data-testid="model-card-essence" value={essence} onChange={(e) => setEssence(e.target.value)} rows={2} style={textarea} /> |
| 75 | + <label style={label}>边界(它"不是"什么)</label> |
| 76 | + <textarea data-testid="model-card-boundary" value={boundary} onChange={(e) => setBoundary(e.target.value)} rows={2} style={textarea} /> |
| 77 | + <label style={label}>联系(它和什么相关)</label> |
| 78 | + <textarea data-testid="model-card-relation" value={relation} onChange={(e) => setRelation(e.target.value)} rows={2} style={textarea} /> |
| 79 | + |
| 80 | + <button data-testid="model-card-submit" onClick={() => void submit()} disabled={busy} style={{ ...submitBtn, opacity: busy ? 0.6 : 1 }}> |
| 81 | + {busy ? "创建中…" : "创建概念卡"} |
| 82 | + </button> |
| 83 | + {status && <p data-testid="model-card-status" style={{ marginTop: 8, fontSize: 12, color: status.error ? "#dc2626" : "#0f766e" }}>{status.text}</p>} |
| 84 | + </div> |
| 85 | + </div> |
| 86 | + ); |
| 87 | +} |
| 88 | + |
| 89 | +/** 标签样式 */ |
| 90 | +const label: React.CSSProperties = { display: "block", fontSize: 11, fontWeight: 600, color: "#6b7280", margin: "8px 0 3px" }; |
| 91 | +/** 输入样式 */ |
| 92 | +const input: React.CSSProperties = { width: "100%", fontSize: 13, padding: "6px 8px", border: "1px solid #e5e7eb", borderRadius: 6, boxSizing: "border-box" }; |
| 93 | +/** 文本域样式 */ |
| 94 | +const textarea: React.CSSProperties = { ...input, resize: "vertical", fontFamily: "inherit" }; |
| 95 | +/** 提交按钮样式 */ |
| 96 | +const submitBtn: React.CSSProperties = { marginTop: 12, width: "100%", fontSize: 13, cursor: "pointer", padding: "8px 0", borderRadius: 6, border: "1px solid #0f766e", background: "#f0fdfa", color: "#0f766e", fontWeight: 600 }; |
0 commit comments