Skip to content

Commit f2f292d

Browse files
margropclaude
andcommitted
feat: SVG 动画讲解绑定界面语言,中英文版本分别生成并随语言自动切换
- ErrorItem 新增 svgAnimationEn 列(仅新增列),svgAnimation 保存中文版 - 生成接口按 language 生成对应语言的动画,提示词和用户消息要求动画文字使用该语言;删除只影响当前语言 - 详情页按界面语言显示对应版本,切换语言时自动切换;当前语言未生成而另一语言已有时给出提示 - 同步更新四个 AI 提供商、README 与 Wiki,并补充接口、提示词和组件测试 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent cea3fd3 commit f2f292d

19 files changed

Lines changed: 257 additions & 59 deletions

File tree

‎README.en.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ Practice statistics show total practice, accuracy and active months, with distri
122122

123123
### Explanations and demos
124124

125-
- **🎬 SVG animated explanations**: AI generates step-by-step SVG animations of the solution, with play/pause, seeking, replay and full screen. On by default; turn it off in **Settings → General**.
125+
- **🎬 SVG animated explanations**: AI generates step-by-step SVG animations of the solution, with play/pause, seeking, replay and full screen. Animation text follows the interface language: the English interface generates an English version and the Chinese interface a Chinese one. Both are kept, and switching the language shows the matching version automatically. On by default; turn it off in **Settings → General**.
126126
- **📐 GeoGebra demos**: for math and physics questions, AI can generate interactive GeoGebra figures. Off by default; turn it on in **Settings → General**.
127127
- Generated animations and GeoGebra demos can be deleted (with confirmation).
128128

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@
120120

121121
### 讲解与演示
122122

123-
- **🎬 SVG 动画讲解**:AI 按解题思路生成分步骤的 SVG 动画,支持播放/暂停、拖动进度、重播和全屏。默认开启,可在「设置 → 通用」中关闭。
123+
- **🎬 SVG 动画讲解**:AI 按解题思路生成分步骤的 SVG 动画,支持播放/暂停、拖动进度、重播和全屏。动画文字与界面语言一致:中文界面生成中文版、英文界面生成英文版,两种版本分别保存,切换语言时自动显示对应版本。默认开启,可在「设置 → 通用」中关闭。
124124
- **📐 GeoGebra 动态演示**:数学、物理题可由 AI 生成交互式 GeoGebra 图形。默认关闭,可在「设置 → 通用」中开启。
125125
- 已生成的动画讲解和 GeoGebra 演示都可删除(需二次确认)。
126126

‎doc/wiki/User-Guide.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
## 配图和动态讲解
5151

5252
- **SVG 配图**:由 AI 根据题目生成静态 SVG,供详情、练习和打印使用;原图可单独查看。
53-
- **SVG 动画讲解**:按解题思路分步骤演示,支持暂停、拖动进度、重播和全屏,个人设置默认开启。
53+
- **SVG 动画讲解**:按解题思路分步骤演示,支持暂停、拖动进度、重播和全屏,个人设置默认开启。动画文字跟随界面语言,中英文版本分别生成和保存,切换语言时自动显示对应版本。
5454
- **GeoGebra 动态演示**:供数学、物理题生成交互图形,个人设置默认关闭。
5555

5656
已生成的动画和 GeoGebra 演示可在确认后删除。生成的图形和解答需要人工核对,尤其是几何位置、计数过程和结论。
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- 仅新增列:英文版 SVG 动画讲解(svgAnimation 为中文版),不影响已有数据。
2+
ALTER TABLE "ErrorItem" ADD COLUMN "svgAnimationEn" TEXT;

‎prisma/schema.prisma‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,8 @@ model ErrorItem {
106106
mistakeStatus String? // not_attempted, wrong_attempt, unknown
107107
knowledgePoints String? // [DEPRECATED] JSON string - kept for migration only
108108
geogebraCommands String? // GeoGebra commands for interactive demo (JSON or newline-separated)
109-
svgAnimation String? // AI 生成的 SVG 动画讲解(已净化)
109+
svgAnimation String? // AI 生成的 SVG 动画讲解(已净化),中文版
110+
svgAnimationEn String? // 英文版 SVG 动画讲解(已净化),界面为英文时使用
110111
111112
// New: many-to-many relation with KnowledgeTag
112113
tags KnowledgeTag[]

‎src/__tests__/integration/svg-animation.test.ts‎

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ import { POST, DELETE } from '@/app/api/error-items/[id]/svg-animation/route';
2020
import { DELETE as DELETE_GEOGEBRA } from '@/app/api/error-items/[id]/geogebra/route';
2121

2222
const context = { params: Promise.resolve({ id: 'e1' }) };
23-
const generate = () => POST(new Request('http://localhost', { method: 'POST' }), context);
24-
const remove = () => DELETE(new Request('http://localhost', { method: 'DELETE' }), context);
23+
const generate = (body?: unknown) => POST(new Request('http://localhost', { method: 'POST', ...(body ? { body: JSON.stringify(body) } : {}) }), context);
24+
const remove = (query = '') => DELETE(new Request(`http://localhost${query}`, { method: 'DELETE' }), context);
2525
const removeGeogebra = () => DELETE_GEOGEBRA(new Request('http://localhost', { method: 'DELETE' }), context);
2626
const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><circle cx="5" cy="5" r="4"><animate attributeName="r" from="0" to="4" dur="1s"/></circle></svg>';
2727

@@ -34,15 +34,35 @@ describe('SVG 动画讲解接口', () => {
3434
mocks.errorItem.update.mockResolvedValue({});
3535
});
3636

37-
it('POST 把题目、答案、解析和配图交给 AI,保存生成的动画', async () => {
37+
it('POST 把题目、答案、解析和配图交给 AI,未指定语言时生成并保存中文版动画', async () => {
3838
mocks.generateSvgAnimation.mockResolvedValue(svg);
3939

4040
const response = await generate();
4141

4242
expect(response.status).toBe(200);
43-
expect(mocks.generateSvgAnimation).toHaveBeenCalledWith('求面积', '6', '底乘高', '<svg/>');
43+
expect(mocks.generateSvgAnimation).toHaveBeenCalledWith('求面积', '6', '底乘高', '<svg/>', 'zh');
44+
expect(mocks.errorItem.update).toHaveBeenCalledWith({ where: { id: 'e1' }, data: { svgAnimation: svg } });
45+
expect(await response.json()).toEqual({ svgAnimation: svg, language: 'zh' });
46+
});
47+
48+
it('POST language=en 时生成英文版动画,单独保存且不覆盖中文版', async () => {
49+
mocks.generateSvgAnimation.mockResolvedValue(svg);
50+
51+
const response = await generate({ language: 'en' });
52+
53+
expect(response.status).toBe(200);
54+
expect(mocks.generateSvgAnimation).toHaveBeenCalledWith('求面积', '6', '底乘高', '<svg/>', 'en');
55+
expect(mocks.errorItem.update).toHaveBeenCalledWith({ where: { id: 'e1' }, data: { svgAnimationEn: svg } });
56+
expect(await response.json()).toEqual({ svgAnimation: svg, language: 'en' });
57+
});
58+
59+
it('POST 不支持的语言按中文处理', async () => {
60+
mocks.generateSvgAnimation.mockResolvedValue(svg);
61+
62+
await generate({ language: 'fr' });
63+
64+
expect(mocks.generateSvgAnimation.mock.calls[0][4]).toBe('zh');
4465
expect(mocks.errorItem.update).toHaveBeenCalledWith({ where: { id: 'e1' }, data: { svgAnimation: svg } });
45-
expect(await response.json()).toEqual({ svgAnimation: svg });
4666
});
4767

4868
it('POST 题目文字为空时返回 NO_QUESTION_TEXT,不调用 AI', async () => {
@@ -74,13 +94,20 @@ describe('SVG 动画讲解接口', () => {
7494
expect((await response.json()).message).toBe('AI_TIMEOUT_ERROR');
7595
});
7696

77-
it('DELETE 清空已生成的动画', async () => {
97+
it('DELETE 未指定语言时清空中文版动画', async () => {
7898
const response = await remove();
7999

80100
expect(response.status).toBe(200);
81101
expect(mocks.errorItem.update).toHaveBeenCalledWith({ where: { id: 'e1' }, data: { svgAnimation: null } });
82102
});
83103

104+
it('DELETE language=en 只清空英文版动画', async () => {
105+
const response = await remove('?language=en');
106+
107+
expect(response.status).toBe(200);
108+
expect(mocks.errorItem.update).toHaveBeenCalledWith({ where: { id: 'e1' }, data: { svgAnimationEn: null } });
109+
});
110+
84111
it('未登录返回 401,不能操作他人的题目', async () => {
85112
mocks.session = null;
86113
expect((await generate()).status).toBe(401);

‎src/__tests__/unit/ai/openai-provider.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,17 @@ describe('OpenAI Provider SVG 动画讲解', () => {
392392
expect(svg).not.toContain('<script');
393393
});
394394

395+
it('language=en 时提示词和用户消息都要求动画文字使用英文', async () => {
396+
mockCompletionCreate.mockResolvedValue({ choices: [{ finish_reason: 'stop', message: { content: `<svg_animation>${animation}</svg_animation>` } }] });
397+
const provider = new OpenAIProvider({ apiKey: 'test-key', model: 'gpt-4o' });
398+
399+
await provider.generateSvgAnimation('求三角形面积', '6', '底乘高除以二', null, 'en');
400+
401+
const request = mockCompletionCreate.mock.calls[0][0];
402+
expect(request.messages[0].content).toContain('must be in English');
403+
expect(request.messages[1].content).toContain('in English');
404+
});
405+
395406
it('回复中没有有效 SVG 时返回 null', async () => {
396407
mockCompletionCreate.mockResolvedValue({ choices: [{ finish_reason: 'stop', message: { content: '<svg_animation>无法生成</svg_animation>' } }] });
397408
const provider = new OpenAIProvider({ apiKey: 'test-key', model: 'gpt-4o' });
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* SVG 动画讲解组件:按界面语言生成、删除和提示动画
3+
*/
4+
import { act } from 'react';
5+
import { createRoot } from 'react-dom/client';
6+
import type { Root } from 'react-dom/client';
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8+
import { SvgAnimationExplainer } from '@/components/svg-animation-explainer';
9+
import { LanguageProvider } from '@/contexts/LanguageContext';
10+
11+
const mocks = vi.hoisted(() => ({
12+
post: vi.fn(),
13+
delete: vi.fn(),
14+
confirmDialog: vi.fn(),
15+
}));
16+
17+
vi.mock('@/lib/api-client', () => ({ apiClient: { post: mocks.post, delete: mocks.delete } }));
18+
vi.mock('@/lib/feedback', () => ({ toast: { error: vi.fn() }, confirmDialog: mocks.confirmDialog }));
19+
vi.mock('@/components/svg-animation-player', () => ({ SvgAnimationPlayer: ({ svg }: { svg: string }) => <div data-testid="player">{svg}</div> }));
20+
21+
const svg = '<svg xmlns="http://www.w3.org/2000/svg"/>';
22+
let root: Root;
23+
let container: HTMLDivElement;
24+
25+
beforeEach(() => {
26+
vi.clearAllMocks();
27+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
28+
container = document.createElement('div');
29+
document.body.appendChild(container);
30+
root = createRoot(container);
31+
});
32+
33+
afterEach(async () => {
34+
await act(async () => root.unmount());
35+
container.remove();
36+
localStorage.clear();
37+
vi.unstubAllGlobals();
38+
});
39+
40+
async function render(props: { svgAnimation?: string | null; hasOtherLanguage?: boolean; onChange?: () => void }) {
41+
await act(async () => root.render(
42+
<LanguageProvider>
43+
<SvgAnimationExplainer errorItemId="e1" svgAnimation={props.svgAnimation} hasOtherLanguage={props.hasOtherLanguage} onChange={props.onChange ?? vi.fn()} />
44+
</LanguageProvider>,
45+
));
46+
}
47+
48+
const clickButton = (text: string) => act(async () => {
49+
const button = [...container.querySelectorAll('button')].find((b) => b.textContent?.includes(text));
50+
if (!button) throw new Error(`找不到按钮:${text}`);
51+
button.click();
52+
});
53+
54+
describe('SVG 动画讲解按界面语言生成', () => {
55+
it('中文界面生成中文版动画', async () => {
56+
mocks.post.mockResolvedValue({ svgAnimation: svg });
57+
const onChange = vi.fn();
58+
await render({ onChange });
59+
60+
await clickButton('生成动画讲解');
61+
62+
expect(mocks.post).toHaveBeenCalledWith('/api/error-items/e1/svg-animation', { language: 'zh' }, expect.anything());
63+
expect(onChange).toHaveBeenCalledWith('zh', svg);
64+
});
65+
66+
it('英文界面生成英文版动画', async () => {
67+
localStorage.setItem('app-language', 'en');
68+
mocks.post.mockResolvedValue({ svgAnimation: svg });
69+
const onChange = vi.fn();
70+
await render({ onChange });
71+
72+
await clickButton('Generate animation');
73+
74+
expect(mocks.post).toHaveBeenCalledWith('/api/error-items/e1/svg-animation', { language: 'en' }, expect.anything());
75+
expect(onChange).toHaveBeenCalledWith('en', svg);
76+
});
77+
78+
it('英文界面只删除英文版动画', async () => {
79+
localStorage.setItem('app-language', 'en');
80+
mocks.confirmDialog.mockResolvedValue(true);
81+
mocks.delete.mockResolvedValue({ success: true });
82+
const onChange = vi.fn();
83+
await render({ svgAnimation: svg, onChange });
84+
85+
await clickButton('Delete');
86+
87+
expect(mocks.delete).toHaveBeenCalledWith('/api/error-items/e1/svg-animation?language=en');
88+
expect(onChange).toHaveBeenCalledWith('en', null);
89+
});
90+
91+
it('当前语言未生成但另一语言已有动画时提示切换语言查看', async () => {
92+
await render({ hasOtherLanguage: true });
93+
expect(container.textContent).toContain('已有英文版动画讲解');
94+
});
95+
});

‎src/__tests__/unit/svg-animation.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,22 @@ describe('generateSvgAnimationPrompt', () => {
5454
it('没有配图时不输出配图段落', () => {
5555
expect(generateSvgAnimationPrompt('求面积', '6', '解析')).not.toContain('题目配图');
5656
});
57+
58+
it('默认要求动画文字使用简体中文', () => {
59+
const prompt = generateSvgAnimationPrompt('求面积', '6', '解析');
60+
expect(prompt).toContain('简体中文,每行不超过 24 个字');
61+
expect(prompt).not.toContain('must be in English');
62+
expect(prompt).not.toContain('{{');
63+
});
64+
65+
it('language=en 时要求动画中全部文字使用英文', () => {
66+
const prompt = generateSvgAnimationPrompt('求面积', '6', '解析', null, 'en');
67+
expect(prompt).toContain('All text in the animation');
68+
expect(prompt).toContain('must be in English');
69+
expect(prompt).toContain('英文,每行不超过 48 个字符');
70+
expect(prompt).not.toContain('简体中文,每行不超过 24 个字');
71+
expect(prompt).not.toContain('{{');
72+
});
5773
});
5874

5975
import { buildSvgAnimationDocument, getSvgAnimationDuration, getSvgAspectRatio, parseClockValue } from '@/lib/svg-animation';

‎src/app/api/error-items/[id]/svg-animation/route.ts‎

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@ import { describeQuestion, trackBackgroundTask } from "@/lib/background-tasks";
99
const logger = createLogger('api:error-items:svg-animation');
1010

1111
type RouteContext = { params: Promise<{ id: string }> };
12+
type AnimationLanguage = 'zh' | 'en';
13+
14+
/** 动画按语言分别保存:中文版存 svgAnimation,英文版存 svgAnimationEn;未指定时为中文 */
15+
function parseLanguage(value: unknown): AnimationLanguage {
16+
return value === 'en' ? 'en' : 'zh';
17+
}
18+
19+
function animationField(language: AnimationLanguage) {
20+
return language === 'en' ? 'svgAnimationEn' : 'svgAnimation';
21+
}
1222

1323
/** 当前用户拥有的错题;不存在或不属于该用户时返回 null */
1424
async function findOwnedItem(id: string, userId: string) {
@@ -21,10 +31,13 @@ async function findOwnedItem(id: string, userId: string) {
2131

2232
/**
2333
* POST /api/error-items/:id/svg-animation
24-
* 由 AI 根据题目、答案和解析生成 SVG 动画讲解并保存(覆盖已有的动画)
34+
* 由 AI 根据题目、答案和解析生成 SVG 动画讲解并保存(覆盖该语言已有的动画)
35+
* 请求体 { language: 'zh' | 'en' }:动画中的文字使用该语言,未指定时为中文
2536
*/
26-
export async function POST(_req: Request, { params }: RouteContext) {
37+
export async function POST(req: Request, { params }: RouteContext) {
2738
const { id } = await params;
39+
const body = await req.json().catch(() => ({})) as { language?: unknown };
40+
const language = parseLanguage(body.language);
2841

2942
try {
3043
const user = await getCurrentUser();
@@ -45,9 +58,9 @@ export async function POST(_req: Request, { params }: RouteContext) {
4558
const svgAnimation = await trackBackgroundTask(
4659
{ userId: user.id, kind: 'svg_animation', subject: describeQuestion(item), link: `/error-items/${id}` },
4760
async () => {
48-
const svg = await getAIService().generateSvgAnimation(questionText, item.answerText || "", item.analysis || "", item.figureSvg);
61+
const svg = await getAIService().generateSvgAnimation(questionText, item.answerText || "", item.analysis || "", item.figureSvg, language);
4962
if (!svg) throw new Error("NO_VALID_ANIMATION");
50-
await prisma.errorItem.update({ where: { id }, data: { svgAnimation: svg } });
63+
await prisma.errorItem.update({ where: { id }, data: { [animationField(language)]: svg } });
5164
return svg;
5265
}
5366
).catch((error: unknown) => {
@@ -58,8 +71,8 @@ export async function POST(_req: Request, { params }: RouteContext) {
5871
return badRequest("NO_VALID_ANIMATION");
5972
}
6073

61-
logger.info({ errorItemId: id, svgLength: svgAnimation.length }, 'SVG animation generated');
62-
return NextResponse.json({ svgAnimation });
74+
logger.info({ errorItemId: id, language, svgLength: svgAnimation.length }, 'SVG animation generated');
75+
return NextResponse.json({ svgAnimation, language });
6376
} catch (error) {
6477
const message = error instanceof Error ? error.message : String(error);
6578
logger.error({ error: message }, 'Error generating SVG animation');
@@ -69,11 +82,12 @@ export async function POST(_req: Request, { params }: RouteContext) {
6982
}
7083

7184
/**
72-
* DELETE /api/error-items/:id/svg-animation
73-
* 删除已生成的 SVG 动画讲解
85+
* DELETE /api/error-items/:id/svg-animation?language=zh|en
86+
* 删除指定语言已生成的 SVG 动画讲解,另一语言的动画保留;未指定时为中文
7487
*/
75-
export async function DELETE(_req: Request, { params }: RouteContext) {
88+
export async function DELETE(req: Request, { params }: RouteContext) {
7689
const { id } = await params;
90+
const language = parseLanguage(new URL(req.url).searchParams.get('language'));
7791

7892
try {
7993
const user = await getCurrentUser();
@@ -84,8 +98,8 @@ export async function DELETE(_req: Request, { params }: RouteContext) {
8498
return notFound("Item not found");
8599
}
86100

87-
await prisma.errorItem.update({ where: { id }, data: { svgAnimation: null } });
88-
logger.info({ errorItemId: id }, 'SVG animation deleted');
101+
await prisma.errorItem.update({ where: { id }, data: { [animationField(language)]: null } });
102+
logger.info({ errorItemId: id, language }, 'SVG animation deleted');
89103
return NextResponse.json({ success: true });
90104
} catch (error) {
91105
logger.error({ error }, 'Error deleting SVG animation');

0 commit comments

Comments
 (0)