Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/PRODUCT_READINESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,18 @@
passed(3 個 QR/journal 字型像素為容器缺 Noto CJK 假象,CI 權威)。**前端 `LocalizeMenu` 傳
`project_id`** 屬後續前端 slice(route 欄位選填、不破壞現況)。**自動建議術語**(掃教材抽術語)
碰 Gemini 額度 = GATE,另寫 proposal 再做。F9-2 offline slice 至此到齊。
- ✅ 2026-06-14 **F9-2j:前端 `LocalizeMenu` 傳 `project_id` 完成(offline,F9-2i 前端收尾)**。
把 F9-2i 落地的選填 `project_id` 接上前端在地化入口:`frontend/edustudio/app.jsx` 的
`LocalizeMenu` 新增 `projectId` prop,呼叫 `POST /localization/translate` 時**有作用中課程才**
帶 `project_id`(`...(projectId ? { project_id: projectId } : {})`),讓後端套該課 glossary 的
固定譯名、術語前後一致。兩個呼叫端各以最精準的課程來源接線:① **影片任務卡**(`TaskCard`)——
`esJobToTask` 補帶 `rec.project_id`(F9-2g 已落 `JobRecord.project_id`),LocalizeMenu 收
`task.project_id`=**該 job 自己所屬的課**;② **視覺成品卡**(`VisualCard`)——`VisualStation`
把作用中課程 `projectId` 透傳下去。守紀律:route 欄位選填=**沒作用中課程沿用現行行為、零破壞**
(fail-soft 在後端,前端只是「給或不給」),**完全不碰 review gate / 狀態機**(只影響「術語怎麼
譯」)。純前端,未動 server/core/schemas/runner(route F9-2i 已落地且有測),故不需跑 pytest。
`npm run build`(vite, node22)編譯通過;**視覺驗收待人工**(此環境無瀏覽器,依既定「前端 build
為準、人後視覺驗收」)。F9-2 連同前端在地化入口至此端到端到齊(自動建議術語仍 = GATE 待額度)。
- [~] 🟡 **F9-3 本機可插拔模型後端**(GATE,= M 軸 Option B 的本機 provider)— 支援
**Ollama 等本機 LLM** 跑文字(大綱/旁白/翻譯),老師可零雲端成本跑(翻譯已用本機
translategemma 驗過路子)。**依賴 M-4 provider 介面就緒**後加 ollama adapter + 設定頁可選
Expand Down
18 changes: 12 additions & 6 deletions frontend/edustudio/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,7 @@ function LangChip({ code, removable, onRemove }) {
// 前端短碼 → 後端 canonical 連字號碼(/localization 邊界再轉底線)。
const ES_LANG_API = { "en": "en-US", "ja": "ja-JP", "ko": "ko-KR", "zh-CN": "zh-CN", "vi": "vi-VN", "zh-TW": "zh-TW" };

function LocalizeMenu({ localized = [], onChange, size = "sm", label = "一鍵在地化", text = "" }) {
function LocalizeMenu({ localized = [], onChange, size = "sm", label = "一鍵在地化", text = "", projectId = "" }) {
const [open, setOpen] = useState(false);
const [picked, setPicked] = useState([]);
const [phase, setPhase] = useState("idle"); // idle | running | done
Expand All @@ -390,6 +390,8 @@ function LocalizeMenu({ localized = [], onChange, size = "sm", label = "一鍵
setPicked(p => p.includes(code) ? p.filter(c => c !== code) : [...p, code]);

// 接 /localization/translate:對每個選的語言真的翻譯(傳成品標題作示範)。
// F9-2j:有作用中課程(projectId)→ 帶 project_id,讓後端套該課 glossary 固定譯名
//(route 欄位選填、fail-soft;沒給/查無沿用現行行為)。
const run = async () => {
if (!picked.length) return;
setPhase("running");
Expand All @@ -399,7 +401,10 @@ function LocalizeMenu({ localized = [], onChange, size = "sm", label = "一鍵
try {
const r = await fetch("/localization/translate", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: src, target_lang: ES_LANG_API[code] || code, source_lang: "zh-TW" }),
body: JSON.stringify({
text: src, target_lang: ES_LANG_API[code] || code, source_lang: "zh-TW",
...(projectId ? { project_id: projectId } : {}),
}),
});
const d = await r.json();
out[code] = d.translated_text || "";
Expand Down Expand Up @@ -1146,7 +1151,7 @@ function TaskCard({ task, onReview, onLocalize, onRetry, onCancel, onPublish, on
<div className="es-task-actions">
{isReview && <Button variant="primary" size="sm" icon="eye" onClick={() => onReview(task)}>開始審查</Button>}
{isApproved && <>
<LocalizeMenu localized={task.localized || []} onChange={(l) => onLocalize(task.id, l)} text={task.title} />
<LocalizeMenu localized={task.localized || []} onChange={(l) => onLocalize(task.id, l)} text={task.title} projectId={task.project_id} />
<Button variant="default" size="sm" icon="upload" onClick={() => onPublish && onPublish(task)}>發布</Button>
</>}
{isFailed && <Button variant="default" size="sm" icon="refresh-cw" onClick={() => onRetry && onRetry(task)}>重試</Button>}
Expand Down Expand Up @@ -1263,6 +1268,7 @@ function esJobToTask(rec) {
source: esBasename(src.path) || src.url || rec.source_type || "—",
lang: "zh-TW", updated: rec.updated_at ? new Date(rec.updated_at).toLocaleString("zh-TW") : "",
cost: 0, model: "Gemini", error: rec.error || "來源或生成發生錯誤", _job: true,
project_id: rec.project_id || "", // F9-2g:job 所屬課程,在地化套該課 glossary(F9-2j)
_stype: rec.source_type, _src: rec.source || {}, // 重試用:保留原始來源
rawState: rec.state, progress: prog.percent, progLabel: prog.label, stage: prog.stage,
_stages: rec.stages || [],
Expand Down Expand Up @@ -2508,7 +2514,7 @@ function VisualComposer({ projectId }) {
);
}

function VisualCard({ o, onLocalize }) {
function VisualCard({ o, onLocalize, projectId = "" }) {
const m = VISUAL_MODES[o.mode];
return (
<Card state={o.status === "queued" ? null : o.status} interactive className="es-vcard">
Expand All @@ -2521,7 +2527,7 @@ function VisualCard({ o, onLocalize }) {
<div className="es-vcard-title">{o.title}</div>
<div className="es-cap es-mut">{o.meta}{o.localized.length ? ` · ${o.localized.length} 種語言` : ""}</div>
<div className="es-vcard-foot">
<LocalizeMenu localized={o.localized} onChange={(l) => onLocalize(o.id, l)} text={o.title} />
<LocalizeMenu localized={o.localized} onChange={(l) => onLocalize(o.id, l)} text={o.title} projectId={projectId} />
<IconButton icon="more-horizontal" />
</div>
</div>
Expand All @@ -2543,7 +2549,7 @@ function VisualStation({ projectId }) {
<VisualComposer projectId={projectId} />
<div className="es-list-head"><h2 className="es-h2">視覺成品</h2></div>
<div className="es-vgrid">
{outputs.map(o => <VisualCard key={o.id} o={o} onLocalize={localize} />)}
{outputs.map(o => <VisualCard key={o.id} o={o} onLocalize={localize} projectId={projectId} />)}
</div>
</div>
);
Expand Down
Loading