学习概览日历可查看当日语法检测+跟读录音;修复跟读录音签名
- 学习概览选中某天展示:打卡任务+英语语法检测(分数)+单词跟读录音(回放) - 修复跟读录音无法听:Safari 录音为 audio/mp4,原写死 audio/webm 致签名 不匹配上传失败;改为按实际录音类型签名,扩展名 m4a/webm Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
cbe99e94f0
commit
b8db3ae707
@@ -16,6 +16,8 @@ export type DayCell = {
|
|||||||
grammar: number;
|
grammar: number;
|
||||||
weekly: number | null;
|
weekly: number | null;
|
||||||
tasks: { title: string; status: "pending" | "done"; subject: string }[];
|
tasks: { title: string; status: "pending" | "done"; subject: string }[];
|
||||||
|
recs: { word: string; url: string }[];
|
||||||
|
gtests: { title: string; score: number; passed: boolean }[];
|
||||||
};
|
};
|
||||||
export type OverviewData = {
|
export type OverviewData = {
|
||||||
kids: {
|
kids: {
|
||||||
|
|||||||
@@ -100,33 +100,72 @@ export function OverviewTab({ data }: { data: OverviewData }) {
|
|||||||
<span className="inline-flex items-center gap-1"><span className="size-2 rounded-full bg-violet-500" />周检测</span>
|
<span className="inline-flex items-center gap-1"><span className="size-2 rounded-full bg-violet-500" />周检测</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-4 rounded-lg border border-slate-100 bg-slate-50/60 p-3">
|
<div className="mt-4 space-y-3 rounded-lg border border-slate-100 bg-slate-50/60 p-3">
|
||||||
<p className="text-sm font-bold text-slate-900">
|
<p className="text-sm font-bold text-slate-900">
|
||||||
{selected} {selected === today ? "(今天)" : ""} 的具体任务
|
{selected} {selected === today ? "(今天)" : ""} 的学习详情
|
||||||
</p>
|
</p>
|
||||||
{(() => {
|
{(() => {
|
||||||
const cell = kid.days[selected];
|
const cell = kid.days[selected];
|
||||||
if (!cell || cell.tasks.length === 0)
|
if (!cell) return <p className="text-sm text-muted-foreground">这一天暂无记录。</p>;
|
||||||
return <p className="mt-1 text-sm text-muted-foreground">这一天没有打卡任务。</p>;
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 space-y-1.5">
|
<div className="space-y-3">
|
||||||
{cell.tasks.map((t, i) => {
|
<div>
|
||||||
const sm = subjectMeta(t.subject);
|
<p className="mb-1 text-xs font-medium text-muted-foreground">打卡任务</p>
|
||||||
return (
|
{cell.tasks.length === 0 ? (
|
||||||
<div key={i} className="flex items-center gap-2 rounded-full bg-white px-3 py-1 text-sm ring-1 ring-slate-100">
|
<p className="text-sm text-muted-foreground">无打卡任务</p>
|
||||||
<span className={cn("size-1.5 rounded-full", sm.dot)} />
|
) : (
|
||||||
<span className="text-slate-700">{t.title}</span>
|
<div className="space-y-1.5">
|
||||||
<span
|
{cell.tasks.map((t, i) => {
|
||||||
className={cn(
|
const sm = subjectMeta(t.subject);
|
||||||
"ml-auto rounded-full px-2 py-0.5 text-xs font-medium",
|
return (
|
||||||
t.status === "done" ? "bg-emerald-100 text-emerald-700" : "bg-slate-100 text-slate-500",
|
<div key={i} className="flex items-center gap-2 rounded-full bg-white px-3 py-1 text-sm ring-1 ring-slate-100">
|
||||||
)}
|
<span className={cn("size-1.5 rounded-full", sm.dot)} />
|
||||||
>
|
<span className="text-slate-700">{t.title}</span>
|
||||||
{t.status === "done" ? "已完成" : "未完成"}
|
<span
|
||||||
</span>
|
className={cn(
|
||||||
|
"ml-auto rounded-full px-2 py-0.5 text-xs font-medium",
|
||||||
|
t.status === "done" ? "bg-emerald-100 text-emerald-700" : "bg-slate-100 text-slate-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.status === "done" ? "已完成" : "未完成"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})}
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-xs font-medium text-muted-foreground">英语语法检测</p>
|
||||||
|
{cell.gtests.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">当天未做语法检测</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{cell.gtests.map((g, i) => (
|
||||||
|
<p key={i} className="truncate text-xs text-slate-600">
|
||||||
|
{g.title} · {g.score} 分 {g.passed ? "✅" : "❌"}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-xs font-medium text-muted-foreground">单词跟读录音</p>
|
||||||
|
{cell.recs.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">当天无跟读录音</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{cell.recs.map((r, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="w-20 shrink-0 font-medium text-slate-800">{r.word}</span>
|
||||||
|
<audio controls src={r.url} className="h-8 flex-1" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
+21
-1
@@ -50,7 +50,7 @@ export default async function ParentPage() {
|
|||||||
// ===== 学习概览(日历) =====
|
// ===== 学习概览(日历) =====
|
||||||
const overview: OverviewData = { kids: [] };
|
const overview: OverviewData = { kids: [] };
|
||||||
for (const kid of kids) {
|
for (const kid of kids) {
|
||||||
const [instRows, gRows, wRows, wrongRow, gDone] = await Promise.all([
|
const [instRows, gRows, wRows, wrongRow, gDone, gtestRows, recMonth] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
date: taskInstances.date,
|
date: taskInstances.date,
|
||||||
@@ -70,6 +70,20 @@ export default async function ParentPage() {
|
|||||||
.from(wrongBook)
|
.from(wrongBook)
|
||||||
.where(and(eq(wrongBook.childUserId, kid.id), eq(wrongBook.mastered, false))),
|
.where(and(eq(wrongBook.childUserId, kid.id), eq(wrongBook.mastered, false))),
|
||||||
passedCount(kid.id),
|
passedCount(kid.id),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
completedAt: grammarProgress.completedAt,
|
||||||
|
score: grammarProgress.score,
|
||||||
|
passed: grammarProgress.passed,
|
||||||
|
title: grammarPoints.title,
|
||||||
|
})
|
||||||
|
.from(grammarProgress)
|
||||||
|
.innerJoin(grammarPoints, eq(grammarPoints.pointId, grammarProgress.pointId))
|
||||||
|
.where(and(eq(grammarProgress.childUserId, kid.id), gte(grammarProgress.completedAt, new Date(`${mStart}T00:00:00`)))),
|
||||||
|
db
|
||||||
|
.select({ word: wordRecording.word, ossKey: wordRecording.ossKey, createdAt: wordRecording.createdAt })
|
||||||
|
.from(wordRecording)
|
||||||
|
.where(and(eq(wordRecording.childUserId, kid.id), gte(wordRecording.createdAt, new Date(`${mStart}T00:00:00`)))),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const days: OverviewData["kids"][number]["days"] = {};
|
const days: OverviewData["kids"][number]["days"] = {};
|
||||||
@@ -84,6 +98,12 @@ export default async function ParentPage() {
|
|||||||
grammar,
|
grammar,
|
||||||
weekly: w ? w.score : null,
|
weekly: w ? w.score : null,
|
||||||
tasks: inst.map((r) => ({ title: r.title, status: r.status, subject: r.subject })),
|
tasks: inst.map((r) => ({ title: r.title, status: r.status, subject: r.subject })),
|
||||||
|
recs: recMonth
|
||||||
|
.filter((r) => dstr(r.createdAt) === d)
|
||||||
|
.map((r) => ({ word: r.word, url: viewUrl(r.ossKey) })),
|
||||||
|
gtests: gtestRows
|
||||||
|
.filter((r) => r.completedAt && dstr(r.completedAt) === d)
|
||||||
|
.map((r) => ({ title: r.title, score: r.score ?? 0, passed: r.passed })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
overview.kids.push({
|
overview.kids.push({
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ import { buildWordTest } from "@/lib/words";
|
|||||||
import { uploadUrl } from "@/lib/oss";
|
import { uploadUrl } from "@/lib/oss";
|
||||||
import { todayStr } from "@/lib/dates";
|
import { todayStr } from "@/lib/dates";
|
||||||
|
|
||||||
// 跟读录音直传签名(浏览器直接 PUT 到对象存储)
|
// 跟读录音直传签名(按实际录音类型签名,避免签名不匹配)
|
||||||
export async function getRecUploadUrl(word: string): Promise<{ url: string; key: string }> {
|
export async function getRecUploadUrl(word: string, contentType: string): Promise<{ url: string; key: string }> {
|
||||||
const key = `recordings/${word}-${Date.now()}.webm`;
|
const ext = contentType.includes("mp4") ? "m4a" : "webm";
|
||||||
return { url: uploadUrl(key, "audio/webm"), key };
|
const key = `recordings/${word}-${Date.now()}.${ext}`;
|
||||||
|
return { url: uploadUrl(key, contentType), key };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function child() {
|
async function child() {
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ function RecordBtn({ day, word }: { day: number; word: string }) {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
stream.getTracks().forEach((t) => t.stop());
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
const blob = new Blob(chunks, { type: mr.mimeType || "audio/webm" });
|
const blob = new Blob(chunks, { type: mr.mimeType || "audio/webm" });
|
||||||
const { url, key } = await getRecUploadUrl(word);
|
const ct = blob.type || "audio/webm";
|
||||||
await fetch(url, { method: "PUT", headers: { "Content-Type": blob.type || "audio/webm" }, body: blob });
|
const { url, key } = await getRecUploadUrl(word, ct);
|
||||||
|
await fetch(url, { method: "PUT", headers: { "Content-Type": ct }, body: blob });
|
||||||
await saveRecording(day, word, key);
|
await saveRecording(day, word, key);
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user