智教助手平台:完整初始化

- 前端:Vue3 + TS + Element Plus,24 个页面路由(课件/组题/教案/动画/思维导图/作文批改/命题/课堂/资源/社区等)
- 后端:FastAPI + SQLAlchemy + SQLite,17 个路由模块,AI 服务层含降级模板
- AI:glm-5.x 推理模型已禁用思维链,确保输出真实内容
- 修复:ai_service 两处请求体注入 enable_thinking/thinking disabled
- 测试账号:13900999999 / test1234
This commit is contained in:
Zhang Jing Xuan
2026-07-29 16:29:10 +08:00
commit 0841b1a103
193 changed files with 44995 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+76
View File
@@ -0,0 +1,76 @@
const { chromium } = require("playwright-core");
(async () => {
const browser = await chromium.launch({
executablePath: "C:/Program Files/Google/Chrome/Application/chrome.exe",
args: ["--disable-blink-features=AutomationControlled"],
});
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
const errors = [];
page.on("console", msg => {
if (msg.type() === "error") errors.push(`CONSOLE: ${msg.text()}`);
});
page.on("pageerror", err => errors.push(`PAGEERROR: ${err.message}`));
const BASE = "http://127.0.0.1:5175";
// Login first via API
const loginResp = await page.request.post("http://127.0.0.1:8010/api/auth/login", {
data: { phone: "13943441149", password: "Test1234" },
headers: { "Content-Type": "application/json" },
});
const loginData = await loginResp.json();
const token = loginData.access_token;
// Inject token into localStorage
await page.goto(BASE + "/login", { waitUntil: "networkidle" });
await page.evaluate(t => {
localStorage.setItem("token", t);
localStorage.setItem("access_token", t);
localStorage.setItem("zj_token", t);
}, token);
const routes = [
"/", "/courseware", "/courseware/create", "/animation", "/exercise",
"/lesson-plan", "/exam", "/essay-grade", "/mindmap", "/materials",
"/resources", "/community", "/classroom", "/assistant", "/profile",
"/admin", "/search",
];
for (const route of routes) {
const routeErrors = [];
page.removeAllListeners("console");
page.removeAllListeners("pageerror");
page.on("console", msg => { if (msg.type() === "error") routeErrors.push(msg.text()); });
page.on("pageerror", err => routeErrors.push(err.message));
try {
await page.goto(BASE + route, { waitUntil: "networkidle", timeout: 15000 });
await page.waitForTimeout(1500);
// Check for empty/blank pages
const bodyText = await page.evaluate(() => document.body.innerText.trim());
const isEmpty = bodyText.length < 10;
const hasRouterView = await page.evaluate(() => {
const app = document.querySelector("#app");
return app && app.children.length > 0;
});
// Filter expected errors (401/403 for admin views)
const realErrors = routeErrors.filter(e =>
!e.includes("401") && !e.includes("403") &&
!e.includes("Failed to fetch") &&
!e.includes("NetworkError")
);
const status = realErrors.length === 0 && !isEmpty && hasRouterView ? "OK" : "CHECK";
const errSummary = realErrors.length > 0 ? ` ERR:[${realErrors.slice(0,2).join(" | ").substring(0,100)}]` : "";
const emptyFlag = isEmpty ? " BLANK" : "";
console.log(`${status} ${route.padEnd(20)} ${bodyText.length}chars${emptyFlag}${errSummary}`);
} catch (e) {
console.log(`FAIL ${route.padEnd(20)} ${e.message.substring(0, 80)}`);
}
}
await browser.close();
})();
+58
View File
@@ -0,0 +1,58 @@
import { chromium } from 'playwright-core';
import fs from 'fs';
import path from 'path';
const loginRes = await fetch('http://127.0.0.1:8010/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: '13943441149', password: 'Test1234' })
});
const loginData = await loginRes.json();
const accessToken = loginData.access_token;
const refreshToken = loginData.refresh_token;
const browser = await chromium.launch({
channel: 'chrome', headless: true,
executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
});
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
await ctx.addInitScript(([at, rt]) => {
localStorage.setItem('access_token', at);
localStorage.setItem('refresh_token', rt);
}, [accessToken, refreshToken]);
const page = await ctx.newPage();
const errors = [];
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
const shotDir = path.resolve('audit_shots');
if (!fs.existsSync(shotDir)) fs.mkdirSync(shotDir);
const pages = [
{ name: 'home', path: '/home' },
{ name: 'courseware_list', path: '/courseware' },
{ name: 'courseware_create', path: '/courseware/create' },
{ name: 'animation', path: '/animation' },
{ name: 'exercise', path: '/exercise' },
{ name: 'exam', path: '/exam' },
{ name: 'lesson_plan', path: '/lesson-plan' },
{ name: 'mindmap', path: '/mindmap' },
{ name: 'essay_grade', path: '/essay-grade' },
{ name: 'materials', path: '/materials' },
{ name: 'profile', path: '/profile' },
{ name: 'classroom', path: '/classroom' },
{ name: 'assistant', path: '/assistant' },
{ name: 'admin', path: '/admin' },
];
for (const p of pages) {
await page.goto('http://127.0.0.1:5175' + p.path, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(1500);
await page.screenshot({ path: path.join(shotDir, p.name + '.png'), fullPage: false });
const url = page.url();
const redirected = url.includes('login');
const h = await page.textContent('h1, h2').catch(() => '');
console.log('[' + p.name + '] ' + (redirected ? 'REDIRECT_LOGIN' : url.split('/').slice(-2).join('/')) + ' h="' + (h||'').trim().substring(0,30) + '"');
}
console.log('ERRORS:', errors.length);
errors.slice(0,10).forEach(e => console.log(' ', e.substring(0,140)));
await browser.close();
+73
View File
@@ -0,0 +1,73 @@
const { chromium } = require("playwright-core");
(async () => {
const browser = await chromium.launch({
executablePath: "C:/Program Files/Google/Chrome/Application/chrome.exe",
args: ["--disable-blink-features=AutomationControlled"],
});
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
const errors = [];
page.on("console", msg => { if (msg.type() === "error") errors.push(msg.text()); });
page.on("pageerror", err => errors.push(err.message));
const BASE = "http://127.0.0.1:5175";
const loginResp = await page.request.post("http://127.0.0.1:8010/api/auth/login", {
data: { phone: "13943441149", password: "Test1234" },
});
const token = (await loginResp.json()).access_token;
await page.goto(BASE + "/login", { waitUntil: "networkidle" });
await page.evaluate(t => { localStorage.setItem("token", t); localStorage.setItem("access_token", t); localStorage.setItem("zj_token", t); }, token);
// === TEST 1: Animation template gallery ===
console.log("=== ANIMATION TEMPLATES ===");
await page.goto(BASE + "/animation", { waitUntil: "networkidle" });
await page.waitForTimeout(2000);
const tplButtons = await page.$$(".tpl-btn");
console.log("template buttons found:", tplButtons.length);
// Click first template
if (tplButtons.length > 0) {
await tplButtons[0].click();
await page.waitForTimeout(2000);
const previewIframe = await page.$(".tg-preview-wrap iframe, .preview-wrap iframe, iframe");
if (previewIframe) {
console.log("preview iframe: FOUND");
} else {
console.log("preview iframe: NOT FOUND");
const allIframes = await page.$$("iframe");
console.log("total iframes:", allIframes.length);
}
const cards = await page.$$(".tg-card, .result-card");
console.log("result cards:", cards.length);
}
// === TEST 2: Exercise template gallery ===
console.log("\n=== EXERCISE TEMPLATES ===");
await page.goto(BASE + "/exercise", { waitUntil: "networkidle" });
await page.waitForTimeout(2000);
const exTplBtns = await page.$$(".tpl-btn");
console.log("exercise template buttons:", exTplBtns.length);
// === TEST 3: Courseware create page ===
console.log("\n=== COURSEWARE CREATE ===");
await page.goto(BASE + "/courseware/create", { waitUntil: "networkidle" });
await page.waitForTimeout(1500);
const textareas = await page.$$("textarea, input[type=text], .el-input__inner");
console.log("input fields:", textareas.length);
const genButtons = await page.$$("button:has-text('生成'), button:has-text('创建'), .gen-btn");
console.log("generate buttons:", genButtons.length);
// === TEST 4: Classroom ===
console.log("\n=== CLASSROOM ===");
await page.goto(BASE + "/classroom", { waitUntil: "networkidle" });
await page.waitForTimeout(1500);
const classroomText = await page.evaluate(() => document.body.innerText.substring(0, 500));
console.log("classroom has content:", classroomText.length > 50);
const realErrors = errors.filter(e => !e.includes("401") && !e.includes("403") && !e.includes("Failed to fetch"));
console.log("\n=== TOTAL ERRORS (filtered):", realErrors.length, "===");
realErrors.slice(0, 5).forEach(e => console.log(" ", e.substring(0, 120)));
await browser.close();
})();
+74
View File
@@ -0,0 +1,74 @@
const { chromium } = require("playwright-core");
(async () => {
const browser = await chromium.launch({
executablePath: "C:/Program Files/Google/Chrome/Application/chrome.exe",
args: ["--disable-blink-features=AutomationControlled"],
});
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
const errors = [];
page.on("console", msg => { if (msg.type() === "error") errors.push(msg.text()); });
page.on("pageerror", err => errors.push(err.message));
const BASE = "http://127.0.0.1:5175";
const loginResp = await page.request.post("http://127.0.0.1:8010/api/auth/login", {
data: { phone: "13943441149", password: "Test1234" },
});
const token = (await loginResp.json()).access_token;
await page.goto(BASE + "/login", { waitUntil: "networkidle" });
await page.evaluate(t => { localStorage.setItem("token", t); localStorage.setItem("access_token", t); localStorage.setItem("zj_token", t); }, token);
// Get existing courseware list
const cwResp = await page.request.get("http://127.0.0.1:8010/api/coursewares", {
headers: { Authorization: `Bearer ${token}` },
});
const coursewares = await cwResp.json();
console.log("existing coursewares:", coursewares.length);
if (coursewares.length > 0) {
console.log("first courseware id:", coursewares[0].id, "title:", coursewares[0].title?.substring(0, 30));
}
// Navigate to courseware list and check if preview works
console.log("\n=== COURSEWARE LIST ===");
await page.goto(BASE + "/courseware", { waitUntil: "networkidle" });
await page.waitForTimeout(2000);
// Count cards
const cards = await page.$$(".courseware-card, .cw-card, .el-card");
console.log("courseware cards on page:", cards.length);
// Check for action buttons
const previewBtns = await page.$$("button:has-text('预览'), button:has-text('查看'), a:has-text('预览')");
console.log("preview buttons:", previewBtns.length);
// Navigate to first courseware preview if exists
if (coursewares.length > 0) {
console.log("\n=== COURSEWARE PREVIEW ===");
await page.goto(`${BASE}/courseware/${coursewares[0].id}/preview`, { waitUntil: "networkidle" });
await page.waitForTimeout(3000);
// Check if slides rendered
const iframes = await page.$$("iframe");
console.log("iframes:", iframes.length);
const slideContent = await page.evaluate(() => {
const slide = document.querySelector(".slide-container, .preview-slide, .cw-slide");
return slide ? "slide found" : "no slide element";
});
console.log("slide element:", slideContent);
// Check page navigation
const navBtns = await page.$$(".page-nav, .nav-btn, button:has-text('下一页'), button:has-text('上一页')");
console.log("nav buttons:", navBtns.length);
// Take screenshot
await page.screenshot({ path: "_cw_preview.png" });
console.log("screenshot saved");
}
const realErrors = errors.filter(e => !e.includes("401") && !e.includes("403") && !e.includes("Failed to fetch"));
console.log("\nerrors (filtered):", realErrors.length);
realErrors.slice(0, 3).forEach(e => console.log(" ", e.substring(0,120)));
await browser.close();
})();
+66
View File
@@ -0,0 +1,66 @@
const { chromium } = require("playwright-core");
const fs = require("fs");
(async () => {
const cwId = fs.readFileSync("D:/AI/jiaoyu/_cw_id.txt","utf8").trim();
console.log("testing courseware preview for id:", cwId);
const browser = await chromium.launch({
executablePath: "C:/Program Files/Google/Chrome/Application/chrome.exe",
args: ["--disable-blink-features=AutomationControlled"],
});
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
const errors = [];
page.on("console", msg => { if (msg.type() === "error") errors.push(msg.text()); });
page.on("pageerror", err => errors.push(err.message));
const BASE = "http://127.0.0.1:5175";
const loginResp = await page.request.post("http://127.0.0.1:8010/api/auth/login", {
data: { phone: "13943441149", password: "Test1234" },
});
const token = (await loginResp.json()).access_token;
await page.goto(BASE + "/login", { waitUntil: "networkidle" });
await page.evaluate(t => { localStorage.setItem("token", t); localStorage.setItem("access_token", t); localStorage.setItem("zj_token", t); }, token);
// Go to preview
await page.goto(`${BASE}/courseware/${cwId}/preview`, { waitUntil: "networkidle", timeout: 20000 });
await page.waitForTimeout(3000);
// Check rendering
const iframes = await page.$$("iframe");
console.log("iframes:", iframes.length);
const bodyText = await page.evaluate(() => document.body.innerText.substring(0, 200));
console.log("body text preview:", bodyText.substring(0, 120));
// Check for slide/navigation elements
const allDivs = await page.$$("#app div");
console.log("total divs:", allDivs.length);
// Check for specific preview elements
const slideEls = await page.$$("[class*='slide'], [class*='preview'], [class*='page-nav'], [class*='cw-']");
console.log("slide/preview elements:", slideEls.length);
// Look for iframe content
for (let i = 0; i < Math.min(iframes.length, 3); i++) {
try {
const frame = iframes[i].contentFrame();
if (frame) {
const content = await frame.evaluate(() => document.body ? document.body.innerText.substring(0, 60) : "empty");
console.log(`iframe[${i}] content:`, content);
}
} catch(e) {
console.log(`iframe[${i}] error:`, e.message.substring(0, 60));
}
}
// Screenshot
await page.screenshot({ path: "_cw_preview.png" });
console.log("screenshot saved");
const realErrors = errors.filter(e => !e.includes("401") && !e.includes("403") && !e.includes("Failed to fetch"));
console.log("\nerrors (filtered):", realErrors.length);
realErrors.slice(0, 3).forEach(e => console.log(" ", e.substring(0,120)));
await browser.close();
})();
+67
View File
@@ -0,0 +1,67 @@
const { chromium } = require("playwright-core");
const fs = require("fs");
(async () => {
const cwId = fs.readFileSync("D:/AI/jiaoyu/_cw_id.txt","utf8").trim();
console.log("testing courseware preview at /preview/" + cwId);
const browser = await chromium.launch({
executablePath: "C:/Program Files/Google/Chrome/Application/chrome.exe",
args: ["--disable-blink-features=AutomationControlled"],
});
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
const errors = [];
page.on("console", msg => { if (msg.type() === "error") errors.push(msg.text()); });
page.on("pageerror", err => errors.push(err.message));
const BASE = "http://127.0.0.1:5175";
const loginResp = await page.request.post("http://127.0.0.1:8010/api/auth/login", {
data: { phone: "13943441149", password: "Test1234" },
});
const token = (await loginResp.json()).access_token;
await page.goto(BASE + "/login", { waitUntil: "networkidle" });
await page.evaluate(t => { localStorage.setItem("token", t); localStorage.setItem("access_token", t); localStorage.setItem("zj_token", t); }, token);
// Correct URL: /preview/:id
await page.goto(`${BASE}/preview/${cwId}`, { waitUntil: "networkidle", timeout: 20000 });
await page.waitForTimeout(4000);
const iframes = await page.$$("iframe");
console.log("iframes:", iframes.length);
// Check iframe content
for (let i = 0; i < Math.min(iframes.length, 2); i++) {
try {
const frame = iframes[i].contentFrame();
if (frame) {
const html = await frame.evaluate(() => document.documentElement.outerHTML.substring(0, 200));
console.log(`iframe[${i}] html:`, html.substring(0, 150));
}
} catch(e) {
console.log(`iframe[${i}] error:`, e.message.substring(0, 80));
}
}
// Check page navigation
const navBtns = await page.$$("button");
const navTexts = [];
for (const btn of navBtns) {
const text = await btn.textContent();
if (text.trim()) navTexts.push(text.trim().substring(0, 20));
}
console.log("buttons:", navTexts.slice(0, 10));
// Page counter
const bodyText = await page.evaluate(() => document.body.innerText);
const pageMatch = bodyText.match(/(\d+)\s*\/\s*(\d+)/);
if (pageMatch) console.log("page counter:", pageMatch[0]);
await page.screenshot({ path: "_cw_preview2.png" });
console.log("screenshot saved");
const realErrors = errors.filter(e => !e.includes("401") && !e.includes("403") && !e.includes("Failed to fetch"));
console.log("\nerrors (filtered):", realErrors.length);
realErrors.slice(0, 3).forEach(e => console.log(" ", e.substring(0,120)));
await browser.close();
})();
+55
View File
@@ -0,0 +1,55 @@
const { chromium } = require("playwright-core");
const fs = require("fs");
(async () => {
const cwId = fs.readFileSync("D:/AI/jiaoyu/_cw_id.txt","utf8").trim();
const browser = await chromium.launch({
executablePath: "C:/Program Files/Google/Chrome/Application/chrome.exe",
args: ["--disable-blink-features=AutomationControlled"],
});
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
const errors = [];
page.on("console", msg => { if (msg.type() === "error") errors.push(msg.text()); });
page.on("pageerror", err => errors.push(err.message));
const BASE = "http://127.0.0.1:5175";
const loginResp = await page.request.post("http://127.0.0.1:8010/api/auth/login", {
data: { phone: "13943441149", password: "Test1234" },
});
const token = (await loginResp.json()).access_token;
await page.goto(BASE + "/login", { waitUntil: "networkidle" });
await page.evaluate(t => { localStorage.setItem("token", t); localStorage.setItem("access_token", t); localStorage.setItem("zj_token", t); }, token);
await page.goto(`${BASE}/preview/${cwId}`, { waitUntil: "networkidle", timeout: 20000 });
await page.waitForTimeout(4000);
const iframes = await page.$$("iframe");
console.log("iframes:", iframes.length);
// Check main slide iframe content via srcdoc
const slideIframe = await page.$(".slide-raw-iframe");
if (slideIframe) {
const srcdoc = await slideIframe.getAttribute("srcdoc");
console.log("slide srcdoc length:", srcdoc ? srcdoc.length : 0);
if (srcdoc) console.log("srcdoc has content:", srcdoc.length > 100);
} else {
console.log("no .slide-raw-iframe found");
}
// Check page counter
const pageCounter = await page.$eval(".bottom-page-info, .ov-page", el => el.textContent).catch(() => "none");
console.log("page counter:", pageCounter);
// Check thumbnails
const thumbs = await page.$$(".thumb-raw-iframe, .thumb-scaler");
console.log("thumbnails:", thumbs.length);
await page.screenshot({ path: "_cw_preview3.png" });
console.log("screenshot saved");
const realErrors = errors.filter(e => !e.includes("401") && !e.includes("403") && !e.includes("Failed to fetch"));
console.log("errors (filtered):", realErrors.length);
realErrors.slice(0, 3).forEach(e => console.log(" ", e.substring(0,120)));
await browser.close();
})();
+35
View File
@@ -0,0 +1,35 @@
const { chromium } = require('playwright-core');
(async () => {
const browser = await chromium.launch({ channel: 'chrome', headless: true, executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' });
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
const errors = [];
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', e => errors.push('PAGEERR: ' + e.message));
// 登录
await page.goto('http://127.0.0.1:5173/login', { waitUntil: 'networkidle' });
await page.waitForTimeout(800);
await page.fill('input[placeholder="请输入手机号"]', '13943441149');
await page.fill('input[type="password"]', 'Test1234');
await page.click('button.gradient-btn');
await page.waitForTimeout(3000);
const url1 = page.url();
console.log('登录后URL:', url1);
// 思维导图
await page.goto('http://127.0.0.1:5173/mindmap', { waitUntil: 'networkidle' });
await page.waitForTimeout(1000);
await page.fill('input[placeholder*="光合作用"]', '中国历史朝代');
await page.waitForTimeout(400);
await page.click('button:has-text("生成导图")');
console.log('已点生成导图');
await page.waitForTimeout(9000);
await page.screenshot({ path: 'D:\\AI\\jiaoyu\\_shot_mindmap.png', fullPage: true });
const bodyText = await page.evaluate(() => document.body.innerText);
console.log('含先秦文明:', bodyText.includes('先秦文明'));
console.log('含秦汉大一统:', bodyText.includes('秦汉大一统'));
console.log('含隋唐盛世:', bodyText.includes('隋唐盛世'));
console.log('含概念定义(旧通用):', bodyText.includes('概念定义'));
console.log('含知识结构(旧通用):', bodyText.includes('知识结构'));
console.log('错误数:', errors.length);
if (errors.length) console.log('错误:', errors.slice(0,5).join(' | '));
await browser.close();
})();
+22
View File
@@ -0,0 +1,22 @@
const { chromium } = require('playwright-core');
(async () => {
const CHROME = String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`;
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage();
const errs = [];
page.on('console', m => errs.push(m.type()+': '+m.text()));
page.on('pageerror', e => errs.push('PAGEERR: '+e.message));
const base = 'http://127.0.0.1:8010';
const r = await fetch(base + '/api/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({phone:'13943441149',password:'Test1234'}) });
const tok = (await r.json()).data?.access_token;
await page.goto('http://127.0.0.1:5175/login');
await page.evaluate(t => { localStorage.setItem('access_token', t); localStorage.setItem('refresh_token','r'); }, tok);
await page.goto('http://127.0.0.1:5175/animation');
await page.waitForTimeout(3000);
console.log('URL:', page.url());
console.log('has tpl-btn:', await page.locator('.tpl-btn').count());
console.log('body text (first 200):', (await page.locator('body').innerText()).slice(0,200));
console.log('errors:', errs.slice(0,8));
await page.screenshot({ path: '../audit_shots/_debug_page.png' });
await browser.close();
})();
+29
View File
@@ -0,0 +1,29 @@
const { chromium } = require('playwright-core');
(async () => {
const CHROME = String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`;
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage();
page.on('pageerror', e => console.log('PAGEERROR:', e.message, '\nSTACK:', e.stack));
page.on('console', m => { if (m.type()==='error') console.log('CONSOLE_ERR:', m.text()); });
// Load the module and render probability template
await page.goto('http://127.0.0.1:5175/animation');
// we need the template render output; use the gallery instead but isolate
await page.waitForTimeout(1500);
// inject token
const base = 'http://127.0.0.1:8010';
const r = await fetch(base + '/api/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({phone:'13943441149',password:'Test1234'}) });
const tok = (await r.json()).data?.access_token;
await page.evaluate(t => { localStorage.setItem('access_token', t); localStorage.setItem('refresh_token','r'); }, tok);
await page.goto('http://127.0.0.1:5175/animation');
await page.waitForTimeout(2000);
// open gallery and jump directly to probability card
await page.locator('.tpl-btn').first().click();
await page.waitForTimeout(1200);
// search for probability
await page.locator('.tg-search input').fill('概率');
await page.waitForTimeout(600);
await page.locator('.tg-card').first().click();
await page.waitForTimeout(2000);
console.log('done');
await browser.close();
})();
+46
View File
@@ -0,0 +1,46 @@
import { chromium } from 'playwright-core';
import fs from 'fs';
import path from 'path';
const loginRes = await fetch('http://127.0.0.1:8010/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: '13943441149', password: 'Test1234' })
});
const loginData = await loginRes.json();
const browser = await chromium.launch({
channel: 'chrome', headless: true,
executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
});
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
await ctx.addInitScript(([at, rt]) => {
localStorage.setItem('access_token', at);
localStorage.setItem('refresh_token', rt);
}, [loginData.access_token, loginData.refresh_token]);
const page = await ctx.newPage();
const errors = [];
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
// Go to home and wait longer
await page.goto('http://127.0.0.1:5175/home', { waitUntil: 'networkidle', timeout: 20000 }).catch(e => console.log('goto err', e.message));
await page.waitForTimeout(4000);
// Diagnose
const info = await page.evaluate(() => {
const body = document.body;
return {
bodyTextLen: body.innerText.length,
bodyTextHead: body.innerText.substring(0, 300),
url: location.href,
hasH1: !!document.querySelector('h1'),
h1Text: document.querySelector('h1')?.textContent || '(none)',
mainContent: document.querySelector('.home-page, .home-container, .hero, main, #app')?.innerHTML?.length || 0,
childCount: document.querySelector('#app')?.children?.length || 0,
};
});
console.log(JSON.stringify(info, null, 2));
await page.screenshot({ path: path.resolve('audit_shots/home2.png') });
console.log('ERRORS:', errors.length);
errors.slice(0,8).forEach(e => console.log(' ', e.substring(0,160)));
await browser.close();
+15
View File
@@ -0,0 +1,15 @@
import { chromium } from 'playwright-core';
import fs from 'fs';
import path from 'path';
const loginRes = await fetch('http://127.0.0.1:8010/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone: '13943441149', password: 'Test1234' }) });
const loginData = await loginRes.json();
const browser = await chromium.launch({ channel: 'chrome', headless: true, executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' });
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
await ctx.addInitScript(([at, rt]) => { localStorage.setItem('access_token', at); localStorage.setItem('refresh_token', rt); }, [loginData.access_token, loginData.refresh_token]);
const page = await ctx.newPage();
await page.goto('http://127.0.0.1:5175/', { waitUntil: 'networkidle', timeout: 20000 }).catch(e => console.log('goto err', e.message));
await page.waitForTimeout(4000);
const info = await page.evaluate(() => ({ bodyTextLen: document.body.innerText.length, bodyTextHead: document.body.innerText.substring(0, 200), url: location.href, h1Text: document.querySelector('h1')?.textContent || '(none)', childCount: document.querySelector('#app')?.children?.length || 0 }));
console.log(JSON.stringify(info, null, 2));
await page.screenshot({ path: path.resolve('audit_shots/home_root.png') });
await browser.close();
+43
View File
@@ -0,0 +1,43 @@
const { chromium } = require('playwright-core');
(async () => {
const CHROME = String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`;
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage({ viewport: { width: 1366, height: 850 } });
const errors = [], warnings = [];
page.on('pageerror', e => errors.push(e.message));
page.on('console', m => { if (m.type()==='error') errors.push(m.text()); if (m.type()==='warning' && m.text().includes('resolve component')) warnings.push(m.text()); });
const base = 'http://127.0.0.1:8010';
const r = await fetch(base + '/api/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({phone:'13943441149',password:'Test1234'}) });
const j = await r.json();
const tok = j.access_token;
await page.goto('http://127.0.0.1:5175/login');
await page.evaluate(t => { localStorage.setItem('access_token', t); localStorage.setItem('refresh_token','r'); }, tok);
await page.goto('http://127.0.0.1:5175/animation');
await page.waitForTimeout(2500);
await page.locator('.tpl-btn').first().click();
await page.waitForTimeout(1500);
const cards = await page.locator('.tg-card').count();
const ours = ['勾股定理','单位圆','排序算法','DNA','牛顿第二','板块构造'];
const results = [];
for (let i = 0; i < cards; i++) {
await page.locator('.tg-card').nth(i).click();
await page.waitForTimeout(800);
const name = (await page.locator('.tg-main-head h3').textContent().catch(()=> '') || '').trim();
if (ours.some(k => name.includes(k))) {
const frame = page.frameLocator('.tg-preview-wrap iframe').first();
let ok = false, iconOk = false;
try { ok = (await frame.locator('body').innerHTML({ timeout: 3000 }).catch(()=> '')).length > 200; } catch(e) {}
// check the card icon rendered (svg present)
try { iconOk = await page.locator('.tg-card').nth(i).locator('svg').count() > 0; } catch(e) {}
results.push({ name, ok, iconOk });
}
}
await page.screenshot({ path: '../audit_shots/_tpl_final2.png' });
console.log('TOTAL_CARDS:', cards);
console.log('CONSOLE_ERRORS:', errors.length, 'ICON_WARNINGS:', warnings.length);
if (errors.length) console.log('ERROR_SAMPLES:', errors.slice(0,5));
if (warnings.length) console.log('WARN_SAMPLES:', warnings.slice(0,3));
console.log('NEW_TEMPLATES:', JSON.stringify(results));
console.log('ALL_NEW_OK:', results.length === 6 && results.every(r => r.ok && r.iconOk));
await browser.close();
})();
+30
View File
@@ -0,0 +1,30 @@
const { chromium } = require('playwright-core');
(async () => {
const CHROME = String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`;
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage({ viewport: { width: 1366, height: 850 } });
const base = 'http://127.0.0.1:8010';
const r = await fetch(base + '/api/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({phone:'13943441149',password:'Test1234'}) });
const tok = (await r.json()).access_token;
await page.goto('http://127.0.0.1:5175/login');
await page.evaluate(t => { localStorage.setItem('access_token', t); localStorage.setItem('refresh_token','r'); }, tok);
await page.goto('http://127.0.0.1:5175/exercise');
await page.waitForTimeout(2500);
await page.locator('.tpl-btn').first().click();
await page.waitForTimeout(1500);
const cards = await page.locator('.tg-card').count();
const ours = ['知识分类','序列记忆','成语接龙','定时炸弹','入门数独'];
const bad = [];
for (let i = 0; i < cards; i++) {
const errs = [];
const handler = e => errs.push(e.message);
page.on('pageerror', handler);
await page.locator('.tg-card').nth(i).click();
await page.waitForTimeout(1000);
const name = (await page.locator('.tg-main-head h3').textContent().catch(()=> '') || '').trim();
page.off('pageerror', handler);
if (errs.length) bad.push({ i, name, errs: errs.slice(0,2) });
}
console.log('BAD_GAMES:', JSON.stringify(bad, null, 2));
await browser.close();
})();
+32
View File
@@ -0,0 +1,32 @@
const { chromium } = require('playwright-core');
const fs = require('fs');
(async () => {
const CHROME = String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`;
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage({ viewport: { width: 1366, height: 850 } });
const base = 'http://127.0.0.1:8010';
const loginRes = await fetch(base + '/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone: '13943441149', password: 'Test1234' }) });
const loginData = await loginRes.json();
const token = loginData.data?.access_token || loginData.access_token;
await page.goto('http://127.0.0.1:5175/login');
await page.evaluate(t => { localStorage.setItem('access_token', t); localStorage.setItem('refresh_token', 'r'); }, token);
await page.goto('http://127.0.0.1:5175/animation');
await page.waitForTimeout(1800);
await page.locator('.tpl-btn').first().click();
await page.waitForTimeout(1200);
const cards = await page.locator('.tg-card').count();
const bad = [];
for (let i = 0; i < cards; i++) {
let beforeErr = 0;
const errs = [];
const handler = e => errs.push(e.message);
page.on('pageerror', handler);
await page.locator('.tg-card').nth(i).click();
await page.waitForTimeout(700);
const name = (await page.locator('.tg-main-head h3').textContent().catch(()=> '') || '').trim();
page.off('pageerror', handler);
if (errs.length) bad.push({ i, name, errs: errs.slice(0,2) });
}
console.log('BAD_TEMPLATES:', JSON.stringify(bad, null, 2));
await browser.close();
})();
+36
View File
@@ -0,0 +1,36 @@
const { chromium } = require('playwright-core');
const fs = require('fs');
(async () => {
const CHROME = String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`;
const src = fs.readFileSync('src/utils/animationTemplates.ts','utf8');
const start = src.indexOf('const probability');
const end = src.indexOf('// ===== 21', start);
const block = src.slice(start, end);
const m = block.match(/return SHELL\('概率模拟',\s*'',\s*`([\s\S]*?)`\s*\);\s*\}/);
if (!m) { console.log('no match'); process.exit(1); }
let script = m[1];
script = script.split('${typ}').join('coin');
script = script.split('${per}').join('20');
const html = '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><style>'+
'*{box-sizing:border-box;margin:0;padding:0;}'+
'body{font-family:sans-serif;padding:24px;}'+
'.tpl-stage{background:#fff;border-radius:16px;padding:20px;max-width:880px;margin:0 auto;}'+
'.tpl-controls{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px;margin-top:16px;padding:14px;background:#f8faff;border-radius:12px;}'+
'.ctrl{display:flex;flex-direction:column;gap:6px;}'+
'.tpl-actions{display:flex;gap:10px;justify-content:center;margin-top:14px;}'+
'.tpl-btn{padding:8px 18px;border:none;border-radius:8px;cursor:pointer;font-size:13px;font-weight:600;}'+
'.tpl-btn.primary{background:#4f46e5;color:#fff;}'+
'.tpl-btn.ghost{background:#fff;color:#4b5563;border:1px solid #e5e7eb;}'+
'canvas{display:block;width:100%;}'+
'</style></head><body><div class="tpl-stage" id="stage"></div><script>'+script+'</script></body></html>';
fs.writeFileSync('../audit_shots/_prob_isolated.html', html);
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage();
page.on('pageerror', e => console.log('PAGEERROR:', e.message));
page.on('console', msg => { if(msg.type()==='error') console.log('CONSOLE:', msg.text()); });
await page.goto('file:///D:/AI/jiaoyu/audit_shots/_prob_isolated.html');
await page.waitForTimeout(1500);
await page.screenshot({ path: '../audit_shots/_prob_isolated.png' });
console.log('isolated test done');
await browser.close();
})();
+43
View File
@@ -0,0 +1,43 @@
const { chromium } = require('playwright-core');
(async () => {
const CHROME = String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`;
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage({ viewport: { width: 1366, height: 850 } });
const errors = [];
page.on('pageerror', e => errors.push(e.message));
page.on('console', m => { if (m.type()==='error') errors.push(m.text()); });
const base = 'http://127.0.0.1:8010';
const r = await fetch(base + '/api/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({phone:'13943441149',password:'Test1234'}) });
const tok = (await r.json()).access_token;
await page.goto('http://127.0.0.1:5175/login');
await page.evaluate(t => { localStorage.setItem('access_token', t); localStorage.setItem('refresh_token','r'); }, tok);
await page.goto('http://127.0.0.1:5175/animation');
await page.waitForTimeout(2500);
await page.locator('.tpl-btn').first().click();
await page.waitForTimeout(1500);
// Click a setInterval template (solar-system), wait, then click probability
await page.locator('.tg-search input').fill('行星');
await page.waitForTimeout(800);
await page.locator('.tg-card').first().click();
await page.waitForTimeout(3000); // let its interval run
errors.length = 0; // reset
await page.locator('.tg-search input').fill('概率');
await page.waitForTimeout(800);
await page.locator('.tg-card').first().click();
await page.waitForTimeout(2500);
console.log('ERRORS_AFTER_SWITCH:', errors.length);
if (errors.length) console.log('ERRORS:', errors.slice(0,5));
// also test: DNA (mine, uses setInterval) then probability
errors.length = 0;
await page.locator('.tg-search input').fill('DNA');
await page.waitForTimeout(800);
await page.locator('.tg-card').first().click();
await page.waitForTimeout(3000);
await page.locator('.tg-search input').fill('概率');
await page.waitForTimeout(800);
await page.locator('.tg-card').first().click();
await page.waitForTimeout(2500);
console.log('ERRORS_AFTER_DNA_TO_PROB:', errors.length);
if (errors.length) console.log('ERRORS:', errors.slice(0,5));
await browser.close();
})();
+67
View File
@@ -0,0 +1,67 @@
const { chromium } = require('playwright-core');
const fs = require('fs');
const path = require('path');
const SHOTS = 'D:/AI/jiaoyu/_shots';
if (!fs.existsSync(SHOTS)) fs.mkdirSync(SHOTS, { recursive: true });
(async () => {
const browser = await chromium.launch({ channel: 'chrome', headless: true, executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' });
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
const allErrors = [];
page.on('pageerror', e => allErrors.push('PAGE: ' + e.message));
page.on('console', m => { if (m.type() === 'error') { const t = m.text(); if (!t.includes('favicon') && !t.includes('404')) allErrors.push('CON: ' + t.slice(0,150)); } });
// Login
const loginResp = await page.request.post('http://localhost:8000/api/auth/login', {
data: { phone: '13943441149', password: 'Test1234' }
});
const { access_token } = await loginResp.json();
const pages = [
{ name: 'home', url: 'http://localhost:5174/', wait: 2500 },
{ name: 'courseware-create', url: 'http://localhost:5174/courseware/create', wait: 2000 },
{ name: 'animation', url: 'http://localhost:5174/animation', wait: 2000 },
{ name: 'exercise', url: 'http://localhost:5174/exercise', wait: 2000 },
{ name: 'lesson-plan', url: 'http://localhost:5174/lesson-plan', wait: 2000 },
{ name: 'exam', url: 'http://localhost:5174/exam', wait: 2000 },
{ name: 'essay-grade', url: 'http://localhost:5174/essay-grade', wait: 2000 },
{ name: 'mindmap', url: 'http://localhost:5174/mindmap', wait: 2000 },
{ name: 'resources', url: 'http://localhost:5174/resources', wait: 2000 },
{ name: 'community', url: 'http://localhost:5174/community', wait: 2000 },
{ name: 'classroom', url: 'http://localhost:5174/classroom', wait: 2000 },
{ name: 'materials', url: 'http://localhost:5174/materials', wait: 2000 },
{ name: 'profile', url: 'http://localhost:5174/profile', wait: 2000 },
{ name: 'search', url: 'http://localhost:5174/search?q=数学', wait: 2000 },
{ name: 'assistant', url: 'http://localhost:5174/assistant', wait: 2000 },
];
const results = [];
for (const p of pages) {
const errorsBefore = allErrors.length;
await page.goto(p.url, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
// Inject token on first navigation
if (p.name === 'home') {
await page.evaluate((t) => { localStorage.setItem('access_token', t); localStorage.setItem('refresh_token', t); }, access_token);
await page.reload({ waitUntil: 'networkidle' });
}
await page.waitForTimeout(p.wait);
await page.screenshot({ path: path.join(SHOTS, p.name + '.png'), fullPage: false });
const bodyLen = await page.evaluate(() => document.body.innerText.length);
const h1 = await page.locator('h1, h2, .page-title, .tpl-title').first().textContent().catch(() => '');
const newErrors = allErrors.slice(errorsBefore);
results.push({ name: p.name, bodyLen, title: h1.trim().slice(0,40), errors: newErrors.length });
console.log('[' + p.name + '] bodyLen=' + bodyLen + ' title="' + h1.trim().slice(0,35) + '" errors=' + newErrors.length);
if (newErrors.length) newErrors.slice(0,2).forEach(e => console.log(' ' + e));
}
console.log('\n=== Summary ===');
console.log('Total pages:', results.length);
console.log('Total errors:', allErrors.length);
const small = results.filter(r => r.bodyLen < 200);
console.log('Pages with thin content (<200 chars):', small.length);
small.forEach(r => console.log(' ' + r.name + ': ' + r.bodyLen + ' chars'));
await browser.close();
})().catch(e => { console.error('FATAL', e.message); process.exit(1); });
+34
View File
@@ -0,0 +1,34 @@
import { chromium } from 'playwright-core';
const browser = await chromium.launch({
channel: 'chrome', headless: true,
executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
});
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
const errors = [];
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
// Login
await page.goto('http://127.0.0.1:5175/login', { waitUntil: 'networkidle' });
await page.fill('input[placeholder*="手机"], input[type="tel"]', '13943441149');
await page.fill('input[type="password"]', 'Test1234');
await page.click('button:has-text("登录")');
await page.waitForURL(/\/(home|courseware|exercise)/, { timeout: 8000 }).catch(() => {});
await page.waitForTimeout(1500);
// Visit AI generation pages, check they render
const pages = ['/exercise', '/exam', '/animation', '/lesson-plan', '/mindmap', '/essay-grade', '/courseware/create'];
for (const p of pages) {
await page.goto('http://127.0.0.1:5175' + p, { waitUntil: 'networkidle' }).catch(() => {});
await page.waitForTimeout(800);
const h = await page.textContent('h1, h2').catch(() => '(none)');
console.log(p, '->', h?.trim()?.substring(0, 40));
}
// Check exercise loading tip text exists in DOM (even if hidden)
const tipExists = await page.evaluate(() => document.body.innerHTML.includes('模型深度推理中'));
console.log('TIP_TEXT_PRESENT:', tipExists);
console.log('CONSOLE_ERRORS:', errors.length);
errors.slice(0, 8).forEach(e => console.log(' ERR:', e.substring(0, 120)));
await browser.close();
+30
View File
@@ -0,0 +1,30 @@
const { chromium } = require('playwright-core');
(async () => {
const CHROME = String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`;
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage({ viewport: { width: 1366, height: 850 } });
const errs = [];
page.on('pageerror', e => errs.push(e.message));
const base = 'http://127.0.0.1:8010';
const r = await fetch(base + '/api/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({phone:'13943441149',password:'Test1234'}) });
const tok = (await r.json()).access_token;
await page.goto('http://127.0.0.1:5175/login');
await page.evaluate(t => { localStorage.setItem('access_token', t); localStorage.setItem('refresh_token','r'); }, tok);
await page.goto('http://127.0.0.1:5175/exercise');
await page.waitForTimeout(2500);
await page.locator('.tpl-btn').first().click();
await page.waitForTimeout(1500);
await page.locator('.tg-search input').fill('成语接龙');
await page.waitForTimeout(800);
await page.locator('.tg-card').first().click();
await page.waitForTimeout(2000);
// type and play
const frame = page.frameLocator('.tg-preview-wrap iframe').first();
await frame.locator('#inp').fill('一心一意');
await page.keyboard.press('Enter');
await page.waitForTimeout(2500);
const chainCount = await frame.locator('#list > div').count().catch(()=>0);
console.log('ERRORS:', errs.length, 'CHAIN_ITEMS:', chainCount);
if (errs.length) console.log('ERR:', errs.slice(0,3));
await browser.close();
})();
+55
View File
@@ -0,0 +1,55 @@
const { chromium } = require('playwright-core');
(async () => {
const CHROME = String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`;
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
const errs = [];
page.on('pageerror', e => errs.push(e.message));
page.on('console', m => { if (m.type()==='error') errs.push(m.text()); });
// 1) Verify SEO meta tags on a normal page
await page.goto('http://127.0.0.1:5175/login');
await page.waitForTimeout(2000);
const desc = await page.locator('meta[name="description"]').getAttribute('content').catch(()=> '');
const ogTitle = await page.locator('meta[property="og:title"]').getAttribute('content').catch(()=> '');
const themeColor = await page.locator('meta[name="theme-color"]').getAttribute('content').catch(()=> '');
const noscript = await page.locator('noscript').count();
console.log('META description:', desc ? desc.slice(0,50)+'...' : 'MISSING');
console.log('META og:title:', ogTitle || 'MISSING');
console.log('META theme-color:', themeColor || 'MISSING');
console.log('noscript fallback:', noscript > 0 ? 'PRESENT' : 'MISSING');
// 2) Test shallow 404 (within AppLayout)
await page.goto('http://127.0.0.1:5175/nonexistent-page');
await page.waitForTimeout(1500);
const title404 = (await page.locator('.nf-title').textContent().catch(()=> '')) || '';
const code404 = (await page.locator('.nf-code').textContent().catch(()=> '')) || '';
console.log('\nSHALLOW 404 (/nonexistent-page):');
console.log(' nf-code:', code404.trim());
console.log(' nf-title:', title404.trim());
console.log(' has home btn:', await page.locator('.nf-btn.primary').count());
// 3) Test deep 404
await page.goto('http://127.0.0.1:5175/foo/bar/baz/deep');
await page.waitForTimeout(1500);
const deepTitle = (await page.locator('.nf-title').textContent().catch(()=> '')) || '';
console.log('\nDEEP 404 (/foo/bar/baz/deep):');
console.log(' nf-title:', deepTitle.trim());
// 4) Test that clicking "返回首页" works
await page.goto('http://127.0.0.1:5175/xyz');
await page.waitForTimeout(1500);
await page.locator('.nf-btn.primary').click();
await page.waitForTimeout(1500);
console.log('\nCLICK HOME -> url:', page.url());
console.log(' landed on home:', page.url().endsWith('/login') || page.url().endsWith('/5175/'));
// 5) screenshot
await page.goto('http://127.0.0.1:5175/nonexistent');
await page.waitForTimeout(1500);
await page.screenshot({ path: '../audit_shots/_404_page.png' });
console.log('\nERRORS:', errs.length);
if (errs.length) console.log('ERR_DETAIL:', [...new Set(errs)].slice(0,5));
await browser.close();
})();
+38
View File
@@ -0,0 +1,38 @@
const { chromium } = require('playwright-core');
(async () => {
const CHROME = String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`;
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage({ viewport: { width: 1366, height: 850 } });
const errors = [];
page.on('pageerror', e => errors.push(e.message));
page.on('console', m => { if (m.type()==='error') errors.push(m.text()); });
const base = 'http://127.0.0.1:8010';
const r = await fetch(base + '/api/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({phone:'13943441149',password:'Test1234'}) });
const tok = (await r.json()).access_token;
await page.goto('http://127.0.0.1:5175/login');
await page.evaluate(t => { localStorage.setItem('access_token', t); localStorage.setItem('refresh_token','r'); }, tok);
await page.goto('http://127.0.0.1:5175/exercise');
await page.waitForTimeout(2500);
await page.locator('.tpl-btn').first().click();
await page.waitForTimeout(1500);
const cards = await page.locator('.tg-card').count();
const ours = ['知识分类','序列记忆','成语接龙','定时炸弹','入门数独'];
const results = [];
for (let i = 0; i < cards; i++) {
await page.locator('.tg-card').nth(i).click();
await page.waitForTimeout(900);
const name = (await page.locator('.tg-main-head h3').textContent().catch(()=> '') || '').trim();
if (ours.some(k => name.includes(k))) {
const frame = page.frameLocator('.tg-preview-wrap iframe').first();
let ok = false;
try { ok = (await frame.locator('body').innerHTML({ timeout: 3000 }).catch(()=> '')).length > 200; } catch(e) {}
results.push({ name, ok });
}
}
console.log('TOTAL_GAME_CARDS:', cards);
console.log('NEW_GAMES:', JSON.stringify(results));
console.log('ALL_NEW_OK:', results.length === 5 && results.every(r => r.ok));
console.log('ERRORS_DURING_NEW:', errors.length);
await page.screenshot({ path: '../audit_shots/_games_final.png' });
await browser.close();
})();
+56
View File
@@ -0,0 +1,56 @@
const { chromium } = require('playwright-core');
const fs = require('fs');
(async () => {
const CHROME = String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`;
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage({ viewport: { width: 1366, height: 850 } });
const errors = [];
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
const base = 'http://127.0.0.1:8010';
const loginRes = await fetch(base + '/api/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: '13943441149', password: 'Test1234' })
});
const loginData = await loginRes.json();
const token = loginData.data?.access_token || loginData.access_token;
await page.goto('http://127.0.0.1:5175/login');
await page.evaluate(t => { localStorage.setItem('access_token', t); localStorage.setItem('refresh_token', 'r'); }, token);
await page.goto('http://127.0.0.1:5175/animation');
await page.waitForTimeout(2000);
// open gallery via the tpl-btn
await page.locator('.tpl-btn').first().click();
await page.waitForTimeout(1500);
const cards = await page.locator('.tg-card').count();
console.log('gallery cards:', cards);
const ours = ['勾股定理','单位圆','排序算法','DNA','牛顿第二','板块构造'];
const results = [];
const allCards = page.locator('.tg-card');
for (let i = 0; i < cards; i++) {
await allCards.nth(i).click();
await page.waitForTimeout(700);
const name = (await page.locator('.tg-main-head h3').textContent().catch(()=> '') || '').trim();
const isOurs = ours.some(k => name.includes(k));
if (isOurs) {
const beforeErr = errors.length;
const frame = page.frameLocator('.tg-preview-wrap iframe').first();
let stageOk = false;
try { stageOk = (await frame.locator('body').innerHTML({ timeout: 3000 }).catch(()=> '')).length > 200; } catch(e) {}
await page.waitForTimeout(300);
const newErr = errors.length - beforeErr;
results.push({ name, stageOk, newErr });
console.log(`[${name}] rendered=${stageOk} newErr=${newErr}`);
}
}
await page.screenshot({ path: '../audit_shots/_tpl_gallery.png' });
console.log('TOTAL_CONSOLE_ERRORS:', errors.length);
console.log('OURS_CHECKED:', results.length, 'ALL_RENDERED:', results.every(r => r.stageOk));
if (errors.length) console.log('ERR_SAMPLES:', errors.slice(0,5));
fs.writeFileSync('../audit_shots/_tpl_results.json', JSON.stringify({ totalCards: cards, errors: errors.slice(0,15), results }, null, 2));
await browser.close();
})();
+49
View File
@@ -0,0 +1,49 @@
const { chromium } = require('playwright-core');
const http = require('http');
const BASE = 'http://127.0.0.1:8010';
const UI = 'http://127.0.0.1:5175';
function apiPost(path, body) {
return new Promise((resolve, reject) => {
const data = JSON.stringify(body);
const req = http.request(BASE + path, { method:'POST', headers:{'Content-Type':'application/json','Content-Length':Buffer.byteLength(data)} }, res => {
let b=''; res.on('data',c=>b+=c); res.on('end',()=>resolve({status:res.statusCode, body:b}));
});
req.on('error', reject); req.write(data); req.end();
});
}
(async () => {
const login = await apiPost('/api/auth/login', {phone:'13943441149', password:'Test1234'});
const tok = JSON.parse(login.body).access_token;
console.log('token:', tok ? 'yes' : 'NO');
const browser = await chromium.launch({ executablePath:'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', headless:true });
const ctx = await browser.newContext({ viewport:{width:1440,height:900} });
const page = await ctx.newPage();
const errors = [];
page.on('console', m => { if (m.type()==='error') errors.push('CONSOLE: '+m.text().slice(0,150)); });
page.on('pageerror', e => errors.push('PAGEERR: '+e.message.slice(0,150)));
// inject token
await page.goto(UI + '/', { waitUntil:'networkidle' });
await page.evaluate((t) => { localStorage.setItem('access_token', t); localStorage.setItem('token', t); }, tok);
const views = ['/', '/animation', '/exercise', '/mindmap', '/lesson-plan', '/exam', '/essay-grade', '/courseware/create', '/community', '/resources', '/classroom', '/assistant', '/profile', '/search'];
for (let i=0;i<views.length;i++){
const v = views[i];
try {
await page.goto(UI + v, { waitUntil:'networkidle', timeout:15000 });
await page.waitForTimeout(800);
const h1 = await page.locator('h1').first().textContent().catch(()=>null);
const cnt = await page.locator('h1').count();
console.log(v, '| h1:', (h1||'').trim().slice(0,40), '| h1count:', cnt);
await page.screenshot({ path:'audit_shots/v'+i+'.png', fullPage:false });
} catch(e){ console.log(v, 'NAV-ERR', e.message.slice(0,100)); }
}
console.log('=== ERRORS ('+errors.length+') ===');
errors.slice(0,15).forEach(e=>console.log(e));
await browser.close();
})();
+39
View File
@@ -0,0 +1,39 @@
const { chromium } = require('playwright-core');
(async () => {
const CHROME = String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`;
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage({ viewport: { width: 1366, height: 850 } });
const errs = [];
page.on('pageerror', e => errs.push(e.message));
page.on('console', m => { if (m.type()==='error' && !m.text().includes('401')) errs.push(m.text()); });
const base = 'http://127.0.0.1:8010';
const r = await fetch(base + '/api/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({phone:'13943441149',password:'Test1234'}) });
const tok = (await r.json()).access_token;
await page.goto('http://127.0.0.1:5175/login');
await page.evaluate(t => { localStorage.setItem('access_token', t); localStorage.setItem('refresh_token','r'); }, tok);
const views = [
{p:'/', n:'Home'},
{p:'/courseware/create', n:'CoursewareCreate'},
{p:'/courseware', n:'CoursewareList'},
{p:'/materials', n:'Materials'},
{p:'/resources', n:'Resources'},
{p:'/classroom', n:'Classroom'},
{p:'/community', n:'Community'},
{p:'/admin', n:'Admin'},
{p:'/profile', n:'Profile'},
];
const results = [];
for (const v of views) {
const before = errs.length;
await page.goto('http://127.0.0.1:5175' + v.p);
await page.waitForTimeout(1800);
const after = errs.length - before;
const h1 = (await page.locator('h1').first().textContent().catch(()=> '')) || '';
const empty = await page.locator('.el-empty').count().catch(()=>0);
results.push({ view: v.n, path: v.p, newErrs: after, h1: (h1||'').slice(0,40), emptyState: empty });
}
console.log(JSON.stringify(results, null, 1));
console.log('TOTAL_NEW_ERRORS:', errs.length);
if (errs.length) console.log('ERRORS:', [...new Set(errs)].slice(0,6));
await browser.close();
})();
+40
View File
@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0f766e" />
<meta name="color-scheme" content="light" />
<title>智教助手 - 教师专属AI备课工具</title>
<meta name="description" content="智教助手是面向 K12 一线教师的 AI 备课与教学创作平台。一句话生成互动课件、教学动画、课堂游戏、智能命题、教案与思维导图,沉淀可复用的资源与模板社区。" />
<meta name="keywords" content="智教助手,AI备课,互动课件,教学动画,课堂游戏,智能命题,AI教案,思维导图,教师工具,K12,教学资源" />
<meta name="author" content="智教助手" />
<meta name="robots" content="index, follow" />
<!-- Open Graph / 社交分享 -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="智教助手" />
<meta property="og:title" content="智教助手 - 教师专属AI备课工具" />
<meta property="og:description" content="一句话生成互动课件、教学动画、课堂游戏与智能测练,覆盖全学科备课、授课、练习与评价。" />
<meta property="og:locale" content="zh_CN" />
<!-- 移动端优化 -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="智教助手" />
<meta name="format-detection" content="telephone=no" />
</head>
<body>
<div id="app"></div>
<noscript>
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:#f5f7fa;color:#14213d;text-align:center;padding:24px;">
<div>
<h1 style="font-size:24px;margin-bottom:12px;">智教助手需要启用 JavaScript</h1>
<p style="font-size:15px;color:#6b7280;line-height:1.6;">请 在浏览器设置中开启 JavaScript 后刷新本页,以使用 AI 备课、课件生成等全部功能。</p>
</div>
</div>
</noscript>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 120s;
}
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
}
+3874
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "ai-teaching-platform",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.7.9",
"echarts": "^5.5.1",
"element-plus": "^2.9.1",
"lottie-web": "^5.12.2",
"marked": "^18.0.4",
"pinia": "^2.3.0",
"vue": "^3.5.13",
"vue-echarts": "^7.0.3",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"sass": "^1.83.4",
"typescript": "^5.7.2",
"unplugin-auto-import": "^0.18.6",
"unplugin-vue-components": "^0.27.5",
"vite": "^6.0.7",
"vue-tsc": "^2.2.0"
}
}
+107
View File
@@ -0,0 +1,107 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>API调试工具</title>
<style>
body{font-family:monospace;padding:20px;background:#1e1e1e;color:#d4d4d4;font-size:13px;}
button{padding:8px 16px;margin:4px;border:none;border-radius:4px;cursor:pointer;font-size:13px;}
.btn-login{background:#4F46E5;color:#fff;}
.btn-gen{background:#10B981;color:#fff;}
#log{background:#000;padding:16px;border-radius:8px;white-space:pre-wrap;max-height:600px;overflow:auto;margin-top:16px;line-height:1.6;}
.ok{color:#4ADE80;} .err{color:#F87171;} .info{color:#60A5FA;} .warn{color:#FBBF24;}
</style>
</head>
<body>
<h2>AI课件生成 - 调试工具</h2>
<button class="btn-login" onclick="doLogin()">1. 登录获取Token</button>
<button class="btn-gen" onclick="doGenerate()">2. 调用AI生成课件</button>
<div id="log"></div>
<script>
let token = '';
const log = document.getElementById('log');
function addLog(msg, cls='info') {
log.innerHTML += `<span class="${cls}">${msg}</span>\n`;
log.scrollTop = log.scrollHeight;
}
async function doLogin() {
log.innerHTML = '';
addLog('正在登录...', 'info');
try {
const r = await fetch('/api/auth/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({phone: '13800001111', password: 'Test1234'})
});
const data = await r.json();
if (data.access_token) {
token = data.access_token;
addLog(`登录成功! Token: ${token.substring(0,30)}...`, 'ok');
} else {
addLog(`登录失败: ${JSON.stringify(data)}`, 'err');
}
} catch(e) {
addLog(`登录异常: ${e.message}`, 'err');
}
}
async function doGenerate() {
if (!token) { addLog('请先登录!', 'warn'); return; }
addLog('正在调用AI生成课件(可能需要1-3分钟)...', 'info');
try {
const r = await fetch('/api/coursewares/ai-generate', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token},
body: JSON.stringify({prompt:'生成一元一次方程课件',subject:'数学',grade:'初一',page_count:3})
});
addLog(`HTTP状态: ${r.status} ${r.statusText}`, r.ok ? 'ok' : 'err');
const text = await r.text();
addLog(`响应长度: ${text.length} 字符`, 'info');
// Try parse
let data;
try {
data = JSON.parse(text);
} catch(e) {
addLog(`JSON解析失败: ${e.message}`, 'err');
addLog(`前500字符: ${text.substring(0,500)}`, 'warn');
return;
}
addLog(`顶层keys: ${Object.keys(data).join(', ')}`, 'info');
addLog(`success: ${data.success}`, 'info');
if (data.data) {
addLog(`data.data类型: ${typeof data.data}`, 'info');
if (typeof data.data === 'object') {
addLog(`data.data keys: ${Object.keys(data.data).join(', ')}`, 'info');
const pages = data.data.pages;
addLog(`pages: ${pages ? pages.length : 'undefined/null'}`, pages?.length ? 'ok' : 'err');
if (pages && pages.length > 0) {
for (let i = 0; i < pages.length; i++) {
const p = pages[i];
const content = p.content || '';
addLog(` Page ${i+1}: type=${p.type} title=${p.title} content_len=${content.length} has_html=${content.includes('<div')}`, 'info');
}
addLog(`\nPage 1 content前200字符:\n${pages[0].content.substring(0,200)}`, 'ok');
} else {
addLog(`\n⚠️ pages为空或undefined!`, 'err');
addLog(`完整data.data: ${JSON.stringify(data.data).substring(0,1000)}`, 'warn');
}
} else {
addLog(`⚠️ data.data不是对象: ${typeof data.data}`, 'err');
addLog(`data.data值: ${String(data.data).substring(0,500)}`, 'warn');
}
} else {
addLog(`⚠️ 没有data字段!`, 'err');
addLog(`完整响应: ${text.substring(0,1000)}`, 'warn');
}
} catch(e) {
addLog(`请求异常: ${e.message}`, 'err');
}
}
</script>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#4F46E5"/>
<stop offset="100%" stop-color="#8B5CF6"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="8" fill="url(#g)"/>
<text x="16" y="23" text-anchor="middle" fill="white" font-family="system-ui" font-weight="700" font-size="18">AI</text>
</svg>

After

Width:  |  Height:  |  Size: 447 B

+32
View File
@@ -0,0 +1,32 @@
<!DOCTYPE html><html><head><meta charset='UTF-8'></head><body style='margin:0;background:#eee;display:flex;justify-content:center;padding:20px;'><div style="width:800px;height:600px;margin:0 auto;background:linear-gradient(135deg,#0f2027,#203a43,#2c5364);display:flex;flex-direction:column;align-items:center;justify-content:center;position:relative;overflow:hidden;font-family:'Microsoft YaHei',sans-serif;">
<div style="position:absolute;top:-80px;left:-80px;width:260px;height:260px;border-radius:50%;background:rgba(255,165,0,0.08);"></div>
<div style="position:absolute;bottom:-60px;right:-60px;width:220px;height:220px;border-radius:50%;background:rgba(0,200,150,0.08);"></div>
<div style="position:absolute;top:40px;right:60px;width:100px;height:100px;border:3px solid rgba(255,255,255,0.06);border-radius:50%;"></div>
<div style="position:absolute;bottom:60px;left:50px;width:60px;height:60px;border:2px solid rgba(255,255,255,0.05);border-radius:50%;"></div>
<div style="position:relative;z-index:2;text-align:center;">
<div style="display:inline-block;background:linear-gradient(135deg,#ff6b35,#f7931e);padding:8px 32px;border-radius:30px;margin-bottom:20px;">
<span style="color:#fff;font-size:15px;font-weight:bold;letter-spacing:4px;">初一数学</span>
</div>
<h1 style="font-size:52px;color:#ffffff;margin:0 0 12px 0;letter-spacing:6px;text-shadow:0 4px 20px rgba(0,0,0,0.3);">一元一次方程</h1>
<div style="width:80px;height:3px;background:linear-gradient(90deg,#ff6b35,#00c896);margin:0 auto 20px auto;border-radius:2px;"></div>
<p style="color:rgba(255,255,255,0.7);font-size:18px;margin:0 0 30px 0;letter-spacing:2px;">从生活问题到代数思维的桥梁</p>
<div style="display:flex;gap:20px;justify-content:center;">
<div style="background:rgba(255,255,255,0.08);backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,0.1);border-radius:16px;padding:20px 24px;text-align:center;width:160px;">
<div style="font-size:36px;margin-bottom:8px;">📖</div>
<div style="color:#fff;font-size:14px;font-weight:bold;">定义与性质</div>
<div style="color:rgba(255,255,255,0.5);font-size:12px;margin-top:4px;">概念理解</div>
</div>
<div style="background:rgba(255,255,255,0.08);backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,0.1);border-radius:16px;padding:20px 24px;text-align:center;width:160px;">
<div style="font-size:36px;margin-bottom:8px;">✏️</div>
<div style="color:#fff;font-size:14px;font-weight:bold;">解题步骤</div>
<div style="color:rgba(255,255,255,0.5);font-size:12px;margin-top:4px;">方法掌握</div>
</div>
<div style="background:rgba(255,255,255,0.08);backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,0.1);border-radius:16px;padding:20px 24px;text-align:center;width:160px;">
<div style="font-size:36px;margin-bottom:8px;">🎯</div>
<div style="color:#fff;font-size:14px;font-weight:bold;">实战练习</div>
<div style="color:rgba(255,255,255,0.5);font-size:12px;margin-top:4px;">巩固提升</div>
</div>
</div>
</div>
<div style="position:absolute;bottom:20px;color:rgba(255,255,255,0.3);font-size:12px;letter-spacing:1px;">点击下方箭头开始学习 →</div>
</div></body></html>
+51
View File
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><title>Test</title></head>
<body style="background:#eee;padding:20px;">
<h2>Test: iframe srcdoc rendering</h2>
<div style="margin-bottom:10px;">
<button onclick="loadContent()">Load Content into iframe</button>
</div>
<iframe id="test-iframe" style="width:820px;height:620px;border:1px solid #ccc;"></iframe>
<textarea id="raw" style="width:100%;height:200px;margin-top:10px;"><div style="width:800px;height:600px;margin:0 auto;background:linear-gradient(135deg,#0f2027,#203a43,#2c5364);display:flex;flex-direction:column;align-items:center;justify-content:center;position:relative;overflow:hidden;font-family:'Microsoft YaHei',sans-serif;">
<div style="position:absolute;top:-80px;left:-80px;width:260px;height:260px;border-radius:50%;background:rgba(255,165,0,0.08);"></div>
<div style="position:absolute;bottom:-60px;right:-60px;width:220px;height:220px;border-radius:50%;background:rgba(0,200,150,0.08);"></div>
<div style="position:absolute;top:40px;right:60px;width:100px;height:100px;border:3px solid rgba(255,255,255,0.06);border-radius:50%;"></div>
<div style="position:absolute;bottom:60px;left:50px;width:60px;height:60px;border:2px solid rgba(255,255,255,0.05);border-radius:50%;"></div>
<div style="position:relative;z-index:2;text-align:center;">
<div style="display:inline-block;background:linear-gradient(135deg,#ff6b35,#f7931e);padding:8px 32px;border-radius:30px;margin-bottom:20px;">
<span style="color:#fff;font-size:15px;font-weight:bold;letter-spacing:4px;">初一数学</span>
</div>
<h1 style="font-size:52px;color:#ffffff;margin:0 0 12px 0;letter-spacing:6px;text-shadow:0 4px 20px rgba(0,0,0,0.3);">一元一次方程</h1>
<div style="width:80px;height:3px;background:linear-gradient(90deg,#ff6b35,#00c896);margin:0 auto 20px auto;border-radius:2px;"></div>
<p style="color:rgba(255,255,255,0.7);font-size:18px;margin:0 0 30px 0;letter-spacing:2px;">从生活问题到代数思维的桥梁</p>
<div style="display:flex;gap:20px;justify-content:center;">
<div style="background:rgba(255,255,255,0.08);backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,0.1);border-radius:16px;padding:20px 24px;text-align:center;width:160px;">
<div style="font-size:36px;margin-bottom:8px;">📖</div>
<div style="color:#fff;font-size:14px;font-weight:bold;">定义与性质</div>
<div style="color:rgba(255,255,255,0.5);font-size:12px;margin-top:4px;">概念理解</div>
</div>
<div style="background:rgba(255,255,255,0.08);backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,0.1);border-radius:16px;padding:20px 24px;text-align:center;width:160px;">
<div style="font-size:36px;margin-bottom:8px;">✏️</div>
<div style="color:#fff;font-size:14px;font-weight:bold;">解题步骤</div>
<div style="color:rgba(255,255,255,0.5);font-size:12px;margin-top:4px;">方法掌握</div>
</div>
<div style="background:rgba(255,255,255,0.08);backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,0.1);border-radius:16px;padding:20px 24px;text-align:center;width:160px;">
<div style="font-size:36px;margin-bottom:8px;">🎯</div>
<div style="color:#fff;font-size:14px;font-weight:bold;">实战练习</div>
<div style="color:rgba(255,255,255,0.5);font-size:12px;margin-top:4px;">巩固提升</div>
</div>
</div>
</div>
<div style="position:absolute;bottom:20px;color:rgba(255,255,255,0.3);font-size:12px;letter-spacing:1px;">点击下方箭头开始学习 →</div>
</div></textarea>
<script>
function loadContent() {
const iframe = document.getElementById('test-iframe');
const raw = document.getElementById('raw').value;
iframe.srcdoc = raw;
}
</script>
</body>
</html>
+46
View File
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>Vue Iframe Test</title></head>
<body style="padding:20px;background:#f5f5f5;font-family:sans-serif;">
<h2>Test: Does srcdoc work?</h2>
<div id="app"></div>
<script>
const html = `<div style="width:800px;height:600px;margin:0 auto;background:linear-gradient(135deg,#0f2027,#203a43,#2c5364);display:flex;flex-direction:column;align-items:center;justify-content:center;position:relative;overflow:hidden;font-family:'Microsoft YaHei',sans-serif;">
<div style="position:absolute;top:-80px;left:-80px;width:260px;height:260px;border-radius:50%;background:rgba(255,165,0,0.08);"></div>
<div style="position:absolute;bottom:-60px;right:-60px;width:220px;height:220px;border-radius:50%;background:rgba(0,200,150,0.08);"></div>
<div style="position:absolute;top:40px;right:60px;width:100px;height:100px;border:3px solid rgba(255,255,255,0.06);border-radius:50%;"></div>
<div style="position:absolute;bottom:60px;left:50px;width:60px;height:60px;border:2px solid rgba(255,255,255,0.05);border-radius:50%;"></div>
<div style="position:relative;z-index:2;text-align:center;">
<div style="display:inline-block;background:linear-gradient(135deg,#ff6b35,#f7931e);padding:8px 32px;border-radius:30px;margin-bottom:20px;">
<span style="color:#fff;font-size:15px;font-weight:bold;letter-spacing:4px;">初一数学</span>
</div>
<h1 style="font-size:52px;color:#ffffff;margin:0 0 12px 0;letter-spacing:6px;text-shadow:0 4px 20px rgba(0,0,0,0.3);">一元一次方程</h1>
<div style="width:80px;height:3px;background:linear-gradient(90deg,#ff6b35,#00c896);margin:0 auto 20px auto;border-radius:2px;"></div>
<p style="color:rgba(255,255,255,0.7);font-size:18px;margin:0 0 30px 0;letter-spacing:2px;">从生活问题到代数思维的桥梁</p>
<div style="display:flex;gap:20px;justify-content:center;">
<div style="background:rgba(255,255,255,0.08);backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,0.1);border-radius:16px;padding:20px 24px;text-align:center;width:160px;">
<div style="font-size:36px;margin-bottom:8px;">📖</div>
<div style="color:#fff;font-size:14px;font-weight:bold;">定义与性质</div>
<div style="color:rgba(255,255,255,0.5);font-size:12px;margin-top:4px;">概念理解</div>
</div>
<div style="background:rgba(255,255,255,0.08);backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,0.1);border-radius:16px;padding:20px 24px;text-align:center;width:160px;">
<div style="font-size:36px;margin-bottom:8px;">✏️</div>
<div style="color:#fff;font-size:14px;font-weight:bold;">解题步骤</div>
<div style="color:rgba(255,255,255,0.5);font-size:12px;margin-top:4px;">方法掌握</div>
</div>
<div style="background:rgba(255,255,255,0.08);backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,0.1);border-radius:16px;padding:20px 24px;text-align:center;width:160px;">
<div style="font-size:36px;margin-bottom:8px;">🎯</div>
<div style="color:#fff;font-size:14px;font-weight:bold;">实战练习</div>
<div style="color:rgba(255,255,255,0.5);font-size:12px;margin-top:4px;">巩固提升</div>
</div>
</div>
</div>
<div style="position:absolute;bottom:20px;color:rgba(255,255,255,0.3);font-size:12px;letter-spacing:1px;">点击下方箭头开始学习 →</div>
</div>`;
const div = document.getElementById('app');
const iframe = document.createElement('iframe');
iframe.style.cssText = 'width:820px;height:620px;border:2px solid blue;display:block;';
div.appendChild(iframe);
iframe.srcdoc = html;
document.body.innerHTML += '<p style="color:green;">iframe created, srcdoc set, length=' + html.length + '</p>';
</script>
</body></html>
+40
View File
@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>Iframe Test</title></head>
<body style="background:#eee;padding:20px;font-family:sans-serif;">
<h2>Iframe srcdoc Test</h2>
<p>Below should show the courseware page:</p>
<div style="border:2px solid red;width:820px;height:620px;">
<iframe style="width:100%;height:100%;border:none;" srcdoc="&lt;div style=&quot;width:800px;height:600px;margin:0 auto;background:linear-gradient(135deg,#0f2027,#203a43,#2c5364);display:flex;flex-direction:column;align-items:center;justify-content:center;position:relative;overflow:hidden;font-family:&#x27;Microsoft YaHei&#x27;,sans-serif;&quot;&gt;
&lt;div style=&quot;position:absolute;top:-80px;left:-80px;width:260px;height:260px;border-radius:50%;background:rgba(255,165,0,0.08);&quot;&gt;&lt;/div&gt;
&lt;div style=&quot;position:absolute;bottom:-60px;right:-60px;width:220px;height:220px;border-radius:50%;background:rgba(0,200,150,0.08);&quot;&gt;&lt;/div&gt;
&lt;div style=&quot;position:absolute;top:40px;right:60px;width:100px;height:100px;border:3px solid rgba(255,255,255,0.06);border-radius:50%;&quot;&gt;&lt;/div&gt;
&lt;div style=&quot;position:absolute;bottom:60px;left:50px;width:60px;height:60px;border:2px solid rgba(255,255,255,0.05);border-radius:50%;&quot;&gt;&lt;/div&gt;
&lt;div style=&quot;position:relative;z-index:2;text-align:center;&quot;&gt;
&lt;div style=&quot;display:inline-block;background:linear-gradient(135deg,#ff6b35,#f7931e);padding:8px 32px;border-radius:30px;margin-bottom:20px;&quot;&gt;
&lt;span style=&quot;color:#fff;font-size:15px;font-weight:bold;letter-spacing:4px;&quot;&gt;初一数学&lt;/span&gt;
&lt;/div&gt;
&lt;h1 style=&quot;font-size:52px;color:#ffffff;margin:0 0 12px 0;letter-spacing:6px;text-shadow:0 4px 20px rgba(0,0,0,0.3);&quot;&gt;一元一次方程&lt;/h1&gt;
&lt;div style=&quot;width:80px;height:3px;background:linear-gradient(90deg,#ff6b35,#00c896);margin:0 auto 20px auto;border-radius:2px;&quot;&gt;&lt;/div&gt;
&lt;p style=&quot;color:rgba(255,255,255,0.7);font-size:18px;margin:0 0 30px 0;letter-spacing:2px;&quot;&gt;从生活问题到代数思维的桥梁&lt;/p&gt;
&lt;div style=&quot;display:flex;gap:20px;justify-content:center;&quot;&gt;
&lt;div style=&quot;background:rgba(255,255,255,0.08);backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,0.1);border-radius:16px;padding:20px 24px;text-align:center;width:160px;&quot;&gt;
&lt;div style=&quot;font-size:36px;margin-bottom:8px;&quot;&gt;📖&lt;/div&gt;
&lt;div style=&quot;color:#fff;font-size:14px;font-weight:bold;&quot;&gt;定义与性质&lt;/div&gt;
&lt;div style=&quot;color:rgba(255,255,255,0.5);font-size:12px;margin-top:4px;&quot;&gt;概念理解&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;background:rgba(255,255,255,0.08);backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,0.1);border-radius:16px;padding:20px 24px;text-align:center;width:160px;&quot;&gt;
&lt;div style=&quot;font-size:36px;margin-bottom:8px;&quot;&gt;✏️&lt;/div&gt;
&lt;div style=&quot;color:#fff;font-size:14px;font-weight:bold;&quot;&gt;解题步骤&lt;/div&gt;
&lt;div style=&quot;color:rgba(255,255,255,0.5);font-size:12px;margin-top:4px;&quot;&gt;方法掌握&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;background:rgba(255,255,255,0.08);backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,0.1);border-radius:16px;padding:20px 24px;text-align:center;width:160px;&quot;&gt;
&lt;div style=&quot;font-size:36px;margin-bottom:8px;&quot;&gt;🎯&lt;/div&gt;
&lt;div style=&quot;color:#fff;font-size:14px;font-weight:bold;&quot;&gt;实战练习&lt;/div&gt;
&lt;div style=&quot;color:rgba(255,255,255,0.5);font-size:12px;margin-top:4px;&quot;&gt;巩固提升&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div style=&quot;position:absolute;bottom:20px;color:rgba(255,255,255,0.3);font-size:12px;letter-spacing:1px;&quot;&gt;点击下方箭头开始学习 →&lt;/div&gt;
&lt;/div&gt;"></iframe>
</div>
</body></html>
+17
View File
@@ -0,0 +1,17 @@
const { chromium } = require('playwright');
(async () => {
const tok = require('fs').readFileSync('D:/AI/jiaoyu/_tok.txt','utf8').trim();
const browser = await chromium.launch({ channel: 'chrome' });
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
await page.goto('http://localhost:5173/login', { waitUntil: 'domcontentloaded' });
await page.evaluate((t) => { localStorage.setItem('access_token', t); localStorage.setItem('refresh_token', t); }, tok);
await page.goto('http://localhost:5173/assistant', { waitUntil: 'networkidle' });
await page.waitForTimeout(1500);
await page.screenshot({ path: 'D:/AI/jiaoyu/_assistant_empty.png' });
await page.fill('textarea', '帮我设计一节小学三年级分数初步认识的导入活动');
await page.waitForTimeout(400);
await page.screenshot({ path: 'D:/AI/jiaoyu/_assistant_compose.png' });
await browser.close();
console.log('OK');
})().catch(e => { console.error('FAIL', e.message); process.exit(1); });
+6
View File
@@ -0,0 +1,6 @@
<template>
<router-view />
</template>
<script setup lang="ts">
</script>
+22
View File
@@ -0,0 +1,22 @@
import request from '@/utils/request'
export const getAdminDashboard = () =>
request.get('/api/admin/dashboard') as unknown as Promise<any>
export const getAdminUsers = (params?: any) =>
request.get('/api/admin/users', { params }) as unknown as Promise<any[]>
export const updateAdminUser = (id: number, data: any) =>
request.put(`/api/admin/users/${id}`, data) as unknown as Promise<any>
export const grantAdminCredits = (id: number, amount: number) =>
request.post(`/api/admin/users/${id}/grant-credits`, { amount }) as unknown as Promise<any>
export const getAdminResources = (params?: any) =>
request.get('/api/admin/resources', { params }) as unknown as Promise<any[]>
export const moderateResource = (id: number, status: string) =>
request.put(`/api/admin/resources/${id}/moderate?status=${status}`) as unknown as Promise<any>
export const getAdminAuditLogs = (params?: any) =>
request.get('/api/admin/audit-logs', { params }) as unknown as Promise<any[]>
+21
View File
@@ -0,0 +1,21 @@
import request from '@/utils/request'
export const gradeEssay = (data: { essay_text: string; grade_level?: string; essay_type?: string; total_score?: number }) =>
request.post('/api/ai/essay-grade', data)
export const generateExam = (data: { subject: string; grade?: string; knowledge_points?: string[]; difficulty?: string; question_types?: string[]; count?: number; total_score?: number }) =>
request.post('/api/ai/exam-generate', data)
export const parseMaterial = (file: File) => {
const formData = new FormData()
formData.append('file', file)
return request.post('/api/ai/materials/parse', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}) as unknown as Promise<{ success: boolean; data: { material_id: number; filename: string; title: string; material_type: string; size: number; summary: string; char_count: number } }>
}
export const exportHtml = (data: { title: string; html: string }): Promise<Blob> =>
request.post('/api/ai/export/html', data, { responseType: 'blob' }) as unknown as Promise<Blob>
export const exportExamDocx = (data: { title: string; subject?: string; grade?: string; questions: any[]; answers?: any[] }): Promise<Blob> =>
request.post('/api/ai/export/exam-docx', data, { responseType: 'blob' }) as unknown as Promise<Blob>
+27
View File
@@ -0,0 +1,27 @@
import request from '@/utils/request'
export const getAnimations = (params?: any) =>
request.get('/api/animations/', { params })
export const getAnimation = (id: number) =>
request.get(`/api/animations/${id}`)
export const createAnimation = (data: any) =>
request.post('/api/animations/', data)
export const updateAnimation = (id: number, data: any) =>
request.put(`/api/animations/${id}`, data)
export const remixAnimation = (id: number) =>
request.post(`/api/animations/${id}/remix`)
export const aiGenerateAnimation = (data: { prompt: string; anim_type?: string; subject?: string }) =>
request.post('/api/animations/ai-generate', data, {
headers: { 'Content-Type': 'application/json; charset=utf-8' },
})
export const deleteAnimation = (id: number) =>
request.delete(`/api/animations/${id}`)
export const exportAnimationHtml = (id: number): Promise<Blob> =>
request.get(`/api/animations/${id}/export-html`, { responseType: 'blob' }) as unknown as Promise<Blob>
+44
View File
@@ -0,0 +1,44 @@
import request from '@/utils/request'
export interface ChatMessage {
id?: number
role: 'user' | 'assistant' | 'system'
content: string
created_at?: string
}
export interface ChatConversation {
id: number
title: string
subject?: string
grade?: string
pinned?: boolean
created_at?: string
updated_at?: string
messages: ChatMessage[]
}
export interface SendMessageResult {
success: boolean
data: ChatMessage
title?: string
credits?: { balance: number; cost: number; action: string }
}
export const listConversations = (params?: { keyword?: string }) =>
request.get('/api/chat/conversations', { params }) as unknown as Promise<ChatConversation[]>
export const createConversation = (data: { title?: string; subject?: string; grade?: string }) =>
request.post('/api/chat/conversations', data) as unknown as Promise<ChatConversation>
export const getConversation = (id: number) =>
request.get(`/api/chat/conversations/${id}`) as unknown as Promise<ChatConversation>
export const renameConversation = (id: number, data: { title: string; pinned?: boolean }) =>
request.put(`/api/chat/conversations/${id}`, data) as unknown as Promise<ChatConversation>
export const deleteConversation = (id: number) =>
request.delete(`/api/chat/conversations/${id}`) as unknown as Promise<void>
export const sendMessage = (id: number, data: { content: string; subject?: string; grade?: string }) =>
request.post(`/api/chat/conversations/${id}/messages`, data) as unknown as Promise<SendMessageResult>
+28
View File
@@ -0,0 +1,28 @@
import request from '@/utils/request'
export const getClassroomActivities = (params?: any) =>
request.get('/api/classroom/activities', { params }) as unknown as Promise<any[]>
export const getClassroomAnalysis = (id: number) =>
request.get(`/api/classroom/activities/${id}/analysis`) as unknown as Promise<any>
export const exportClassroomAnalysis = (id: number): Promise<Blob> =>
request.get(`/api/classroom/activities/${id}/analysis/export`, { responseType: 'blob' }) as unknown as Promise<Blob>
export const exportClassroomResponses = (id: number): Promise<Blob> =>
request.get(`/api/classroom/activities/${id}/responses/export`, { responseType: 'blob' }) as unknown as Promise<Blob>
export const getPublicClassroomActivity = (id: number) =>
request.get(`/api/classroom/public/activities/${id}`) as unknown as Promise<any>
export const createClassroomActivity = (data: any) =>
request.post('/api/classroom/activities', data) as unknown as Promise<any>
export const updateClassroomActivity = (id: number, data: any) =>
request.put(`/api/classroom/activities/${id}`, data) as unknown as Promise<any>
export const submitClassroomResponse = (id: number, data: any) =>
request.post(`/api/classroom/activities/${id}/responses`, data) as unknown as Promise<any>
export const deleteClassroomActivity = (id: number) =>
request.delete(`/api/classroom/activities/${id}`)
+37
View File
@@ -0,0 +1,37 @@
import request from '@/utils/request'
export const getPosts = (params?: any) =>
request.get('/api/community/posts', { params })
export const getPostRanking = (params?: any) =>
request.get('/api/community/posts/ranking', { params })
export const createPost = (data: any) =>
request.post('/api/community/posts', data)
export const getPost = (id: number) =>
request.get(`/api/community/posts/${id}`)
export const updatePost = (id: number, data: any) =>
request.put(`/api/community/posts/${id}`, data)
export const deletePost = (id: number) =>
request.delete(`/api/community/posts/${id}`)
export const likePost = (id: number) =>
request.post(`/api/community/posts/${id}/like`)
export const unlikePost = (id: number) =>
request.delete(`/api/community/posts/${id}/like`)
export const getComments = (postId: number) =>
request.get(`/api/community/posts/${postId}/comments`)
export const createComment = (postId: number, data: { content: string; parent_id?: number }) =>
request.post(`/api/community/posts/${postId}/comments`, data)
export const deleteComment = (postId: number, commentId: number) =>
request.delete(`/api/community/posts/${postId}/comments/${commentId}`)
export const getFavoritePosts = (params?: any) =>
request.get('/api/community/posts', { params: { ...(params || {}), scope: 'favorite' } })
+37
View File
@@ -0,0 +1,37 @@
import request from '@/utils/request'
export const getCoursewares = (params?: any) =>
request.get('/api/coursewares/', { params })
export const getCourseware = (id: number) =>
request.get(`/api/coursewares/${id}`)
export const createCourseware = (data: any) =>
request.post('/api/coursewares/', data)
export const updateCourseware = (id: number, data: any) =>
request.put(`/api/coursewares/${id}`, data)
export const remixCourseware = (id: number) =>
request.post(`/api/coursewares/${id}/remix`)
export const createCoursewareShare = (id: number) =>
request.post(`/api/coursewares/${id}/share`) as unknown as Promise<any>
export const getCoursewareShare = (token: string) =>
request.get(`/api/coursewares/shares/${token}`) as unknown as Promise<any>
export const getMyCoursewareShares = (params?: any) =>
request.get('/api/coursewares/shares/mine/list', { params }) as unknown as Promise<any[]>
export const revokeCoursewareShare = (id: number) =>
request.delete(`/api/coursewares/shares/mine/${id}`)
export const deleteCourseware = (id: number) =>
request.delete(`/api/coursewares/${id}`)
export const exportCoursewareDocx = (id: number): Promise<Blob> =>
request.get(`/api/coursewares/${id}/export-docx`, { responseType: 'blob' }) as unknown as Promise<Blob>
export const aiGenerateCourseware = (data: { prompt: string; subject?: string; grade?: string; page_count?: number; aspect_ratio?: string }) =>
request.post('/api/coursewares/ai-generate', data)
+13
View File
@@ -0,0 +1,13 @@
import request from '@/utils/request'
export const getEssayGrades = (params?: any) =>
request.get('/api/essay-grades/', { params })
export const getEssayGrade = (id: number) =>
request.get(`/api/essay-grades/${id}`)
export const createEssayGrade = (data: any) =>
request.post('/api/essay-grades/', data)
export const deleteEssayGrade = (id: number) =>
request.delete(`/api/essay-grades/${id}`)
+22
View File
@@ -0,0 +1,22 @@
import request from '@/utils/request'
export const getExams = (params?: any) =>
request.get('/api/exams/', { params })
export const getExam = (id: number) =>
request.get(`/api/exams/${id}`)
export const createExam = (data: any) =>
request.post('/api/exams/', data)
export const updateExam = (id: number, data: any) =>
request.put(`/api/exams/${id}`, data)
export const remixExam = (id: number) =>
request.post(`/api/exams/${id}/remix`)
export const deleteExam = (id: number) =>
request.delete(`/api/exams/${id}`)
export const exportSavedExamDocx = (id: number): Promise<Blob> =>
request.get(`/api/exams/${id}/export-docx`, { responseType: 'blob' }) as unknown as Promise<Blob>
+40
View File
@@ -0,0 +1,40 @@
import request from '@/utils/request'
export const getExercises = (params?: any) =>
request.get('/api/exercises/', { params })
export const getExercise = (id: number) =>
request.get(`/api/exercises/${id}`)
export const createExercise = (data: any) =>
request.post('/api/exercises/', data)
export const updateExercise = (id: number, data: any) =>
request.put(`/api/exercises/${id}`, data)
export const remixExercise = (id: number) =>
request.post(`/api/exercises/${id}/remix`)
export const aiGenerateExercise = (data: { prompt: string; exercise_type?: string; subject?: string; knowledge_points?: string[]; count?: number }) =>
request.post('/api/exercises/ai-generate', data)
export const deleteExercise = (id: number) =>
request.delete(`/api/exercises/${id}`)
export const exportExerciseDocx = (id: number): Promise<Blob> =>
request.get(`/api/exercises/${id}/export-docx`, { responseType: 'blob' }) as unknown as Promise<Blob>
export interface ExerciseAttemptData {
student_name?: string
answers: any[]
score: number
total: number
duration?: number
}
export const submitExerciseAttempt = (id: number, data: ExerciseAttemptData) =>
request.post(`/api/exercises/${id}/attempt`, data)
export const getExerciseAttempts = (id: number) =>
request.get(`/api/exercises/${id}/attempts`)
+39
View File
@@ -0,0 +1,39 @@
import request from '@/utils/request'
export const login = (data: { phone: string; password: string }) =>
request.post('/api/auth/login', data)
export const register = (data: { phone: string; password: string; name: string; subject?: string; school?: string }) =>
request.post('/api/auth/register', data)
export const getProfile = () =>
request.get('/api/auth/me')
export const updateProfile = (data: any) =>
request.put('/api/auth/me', data)
export const getProfileStats = () =>
request.get('/api/auth/stats')
export const getCreditBenefits = () =>
request.get('/api/auth/credits/benefits')
export const claimDailyCheckin = () =>
request.post('/api/auth/credits/daily-checkin')
export const changePassword = (data: { old_password: string; new_password: string }) =>
request.post('/api/auth/change-password', data)
export const uploadAvatar = (file: File) => {
const fd = new FormData()
fd.append('file', file)
return request.post('/api/auth/avatar', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
}
export const refreshAccessToken = (refreshToken: string) =>
request.post('/api/auth/refresh', null, { params: { refresh_token: refreshToken } })
export const sendResetCode = (phone: string) =>
request.post('/api/auth/send-code', { phone })
export const resetPassword = (data: { phone: string; code: string; new_password: string }) =>
request.post('/api/auth/reset-password', data)
+25
View File
@@ -0,0 +1,25 @@
import request from '@/utils/request'
export const getLessonPlans = (params?: any) =>
request.get('/api/lesson-plans/', { params })
export const getLessonPlan = (id: number) =>
request.get(`/api/lesson-plans/${id}`)
export const createLessonPlan = (data: any) =>
request.post('/api/lesson-plans/', data)
export const updateLessonPlan = (id: number, data: any) =>
request.put(`/api/lesson-plans/${id}`, data)
export const remixLessonPlan = (id: number) =>
request.post(`/api/lesson-plans/${id}/remix`)
export const aiGenerateLessonPlan = (data: { title: string; subject?: string; grade?: string; objectives?: string[]; duration?: number; extra_requirements?: string }) =>
request.post('/api/lesson-plans/ai-generate', data)
export const deleteLessonPlan = (id: number) =>
request.delete(`/api/lesson-plans/${id}`)
export const exportLessonPlanDocx = (id: number): Promise<Blob> =>
request.get(`/api/lesson-plans/${id}/export-docx`, { responseType: 'blob' }) as unknown as Promise<Blob>
+16
View File
@@ -0,0 +1,16 @@
import request from '@/utils/request'
export const getMaterials = (params?: any) =>
request.get('/api/materials/', { params })
export const getMaterial = (id: number) =>
request.get(`/api/materials/${id}`)
export const createMaterial = (data: any) =>
request.post('/api/materials/', data)
export const updateMaterial = (id: number, data: any) =>
request.put(`/api/materials/${id}`, data)
export const deleteMaterial = (id: number) =>
request.delete(`/api/materials/${id}`)
+30
View File
@@ -0,0 +1,30 @@
import request from '@/utils/request'
export interface MindMapNode {
title: string
color?: string
children?: MindMapNode[]
}
export interface MindMapData {
title: string
nodes: MindMapNode[]
}
export const aiGenerateMindMap = (data: { topic: string; subject?: string; grade?: string }) =>
request.post('/api/mind-maps/ai-generate', data)
export const getMindMaps = (params?: any) =>
request.get('/api/mind-maps/', { params })
export const getMindMap = (id: number) =>
request.get(`/api/mind-maps/${id}`)
export const createMindMap = (data: { title: string; subject?: string; nodes: MindMapNode[] }) =>
request.post('/api/mind-maps/', data)
export const updateMindMap = (id: number, data: { title?: string; nodes?: MindMapNode[] }) =>
request.put(`/api/mind-maps/${id}`, data)
export const deleteMindMap = (id: number) =>
request.delete(`/api/mind-maps/${id}`)
+28
View File
@@ -0,0 +1,28 @@
import request from '@/utils/request'
export interface NotificationItem {
id: number
user_id: number
actor_id: number | null
ntype: string
title: string
content: string
link: string
is_read: boolean
created_at: string | null
}
export const getNotifications = (params?: { unread_only?: boolean; limit?: number }) =>
request.get('/api/notifications', { params })
export const getUnreadCount = () =>
request.get('/api/notifications/unread-count')
export const markRead = (id: number) =>
request.put(`/api/notifications/${id}/read`)
export const markAllRead = () =>
request.put('/api/notifications/read-all')
export const deleteNotification = (id: number) =>
request.delete(`/api/notifications/${id}`)
+34
View File
@@ -0,0 +1,34 @@
import request from '@/utils/request'
export const getResources = (params?: any) =>
request.get('/api/resources/', { params })
export const createResource = (data: any) =>
request.post('/api/resources/', data)
export const publishResource = (data: any) =>
request.post('/api/resources/publish', data)
export const getResource = (id: number) =>
request.get(`/api/resources/${id}`)
export const updateResource = (id: number, data: any) =>
request.put(`/api/resources/${id}`, data)
export const likeResource = (id: number) =>
request.post(`/api/resources/${id}/like`)
export const unlikeResource = (id: number) =>
request.delete(`/api/resources/${id}/like`)
export const getFavoriteResources = (params?: any) =>
request.get('/api/resources/favorites/me', { params })
export const getMyResources = (params?: any) =>
request.get('/api/resources/mine', { params })
export const deleteResource = (id: number) =>
request.delete(`/api/resources/${id}`)
export const downloadResourceStat = (id: number) =>
request.post(`/api/resources/${id}/download`)
+4
View File
@@ -0,0 +1,4 @@
import request from '@/utils/request'
export const globalSearch = (params: { keyword?: string; limit?: number }) =>
request.get('/api/search', { params }) as unknown as Promise<any>
+88
View File
@@ -0,0 +1,88 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const createPinia: typeof import('pinia')['createPinia']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const effectScope: typeof import('vue')['effectScope']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router')['useLink']
const useModel: typeof import('vue')['useModel']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
+47
View File
@@ -0,0 +1,47 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AppHeader: typeof import('./components/layout/AppHeader.vue')['default']
AppLayout: typeof import('./components/layout/AppLayout.vue')['default']
AppSidebar: typeof import('./components/layout/AppSidebar.vue')['default']
ElAside: typeof import('element-plus/es')['ElAside']
ElButton: typeof import('element-plus/es')['ElButton']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMain: typeof import('element-plus/es')['ElMain']
ElOption: typeof import('element-plus/es')['ElOption']
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTag: typeof import('element-plus/es')['ElTag']
HtmlPreview: typeof import('./components/common/HtmlPreview.vue')['default']
MarkdownContent: typeof import('./components/common/MarkdownContent.vue')['default']
MaterialPicker: typeof import('./components/common/MaterialPicker.vue')['default']
MindNode: typeof import('./components/common/MindNode.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
TemplateGallery: typeof import('./components/common/TemplateGallery.vue')['default']
}
export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}
@@ -0,0 +1,649 @@
<template>
<div class="html-preview" :class="{ 'edit-mode': isEditing }" :style="{ height: height }">
<!-- Edit toolbar -->
<div class="edit-toolbar" v-if="isEditing">
<button class="tb-btn" @click="execCmd('bold')" title="加粗"><b>B</b></button>
<button class="tb-btn" @click="execCmd('italic')" title="斜体"><i>I</i></button>
<button class="tb-btn" @click="execCmd('underline')" title="下划线"><u>U</u></button>
<span class="tb-sep"></span>
<button class="tb-btn" @click="execCmd('justifyLeft')" title="左对齐">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="15" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<button class="tb-btn" @click="execCmd('justifyCenter')" title="居中">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="6" y1="12" x2="18" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<span class="tb-sep"></span>
<button class="tb-btn" @click="showColorPicker = !showColorPicker" title="文字颜色">
<span style="font-size:13px">A</span>
<span class="color-indicator" :style="{ background: selectedColor }"></span>
</button>
<div class="color-panel" v-if="showColorPicker">
<div v-for="c in colors" :key="c" class="color-swatch" :style="{ background: c }" @click="applyColor(c)"></div>
</div>
<span class="tb-sep"></span>
<button class="tb-btn" @click="openInsertImageDialog(false)" title="插入图片">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>
</button>
<button class="tb-btn" @click="execCmd('insertUnorderedList')" title="无序列表">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="9" y1="6" x2="20" y2="6"/><line x1="9" y1="12" x2="20" y2="12"/><line x1="9" y1="18" x2="20" y2="18"/><circle cx="5" cy="6" r="1" fill="currentColor"/><circle cx="5" cy="12" r="1" fill="currentColor"/><circle cx="5" cy="18" r="1" fill="currentColor"/></svg>
</button>
<span class="tb-sep"></span>
<button class="tb-btn" @click="showAnimDialog = true" title="插入动画">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</button>
</div>
<iframe ref="iframeRef" class="preview-iframe" frameborder="0" />
<!-- Resize handles (fixed position, overlay on iframe) -->
<template v-if="isEditing && selectedImg && !insertImageDialog">
<div v-for="h in handles" :key="h.id" class="rh" :class="h.id"
:style="{ left: hPos[h.id]?.left, top: hPos[h.id]?.top, cursor: h.cursor }"
@mousedown.prevent="startResize($event, h.id)" />
<!-- Dimmed overlay outside image -->
<!-- Image mini toolbar -->
<div class="img-bar" v-if="hPos.toolbar" :style="{ left: hPos.toolbar.left, top: hPos.toolbar.top }">
<button class="bar-btn" @click="setImgSize('small')" :class="{ active: currentImgSize === 'small' }"></button>
<button class="bar-btn" @click="setImgSize('medium')" :class="{ active: currentImgSize === 'medium' }"></button>
<button class="bar-btn" @click="setImgSize('large')" :class="{ active: currentImgSize === 'large' }"></button>
<span class="bar-sep"></span>
<button class="bar-btn" @click="setImgAlign('left')" :class="{ active: currentImgAlign === 'left' }" title="左浮动"></button>
<button class="bar-btn" @click="setImgAlign('center')" :class="{ active: currentImgAlign === 'center' }" title="居中"></button>
<button class="bar-btn" @click="setImgAlign('right')" :class="{ active: currentImgAlign === 'right' }" title="右浮动"></button>
<span class="bar-sep"></span>
<button class="bar-btn" @click="openInsertImageDialog(true)">替换</button>
<button class="bar-btn del" @click="deleteSelectedImg">删除</button>
</div>
</template>
<!-- Insert / Replace image dialog -->
<div class="img-dialog-overlay" v-if="insertImageDialog" @click.self="closeImgDialog">
<div class="img-dialog">
<h4>{{ replacingImg ? '替换图片' : '插入图片' }}</h4>
<div class="img-dialog-body">
<label>图片链接</label>
<el-input v-model="imageUrl" placeholder="粘贴图片URL地址" size="default" />
<label style="margin-top:10px">或上传本地图片</label>
<button type="button" class="upload-img-btn" @click="triggerImageUpload">
<span>上传图片</span>
</button>
<input ref="imageFileInput" type="file" accept="image/*" class="hidden-file-input" @change="handleFileSelect" />
<div class="img-preview-box" v-if="imagePreviewUrl">
<img :src="imagePreviewUrl" />
</div>
</div>
<div class="img-dialog-footer">
<button class="dialog-btn cancel" @click="closeImgDialog">取消</button>
<button class="dialog-btn confirm" @click="doInsertImage" :disabled="!imageUrl">{{ replacingImg ? '替换' : '插入' }}</button>
</div>
</div>
</div>
<!-- Insert animation dialog -->
<div class="img-dialog-overlay" v-if="showAnimDialog" @click.self="showAnimDialog = false">
<div class="img-dialog" style="max-width:520px">
<h4>插入动画</h4>
<div class="img-dialog-body">
<div v-if="animLoading" style="text-align:center;padding:20px;color:#999;">加载中...</div>
<div v-else-if="!animList.length" style="text-align:center;padding:20px;color:#999;">暂无已保存的动画请先到"教学动画"页面生成并保存</div>
<div v-else class="anim-list">
<div v-for="a in animList" :key="a.id" class="anim-item" @click="insertAnimation(a)">
<div class="anim-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#6366F1" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</div>
<div class="anim-info">
<div class="anim-title">{{ a.title }}</div>
<div class="anim-desc">{{ a.description || a.anim_type }}</div>
</div>
<span class="anim-insert-btn">插入</span>
</div>
</div>
</div>
<div class="img-dialog-footer">
<button class="dialog-btn cancel" @click="showAnimDialog = false">关闭</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, nextTick, onUnmounted } from 'vue'
import { getAnimations } from '@/api/animation'
const props = withDefaults(defineProps<{
html: string
height?: string
editable?: boolean
}>(), { height: '480px', editable: false })
const emit = defineEmits<{ (e: 'update:html', val: string): void }>()
const iframeRef = ref<HTMLIFrameElement>()
const isEditing = ref(false)
const showColorPicker = ref(false)
const selectedColor = ref('#000000')
const insertImageDialog = ref(false)
const imageUrl = ref('')
const imagePreviewUrl = ref('')
const replacingImg = ref(false)
const imageFileInput = ref<HTMLInputElement>()
// --- Animation dialog ---
const showAnimDialog = ref(false)
const animList = ref<any[]>([])
const animLoading = ref(false)
// --- Image selection & resize ---
const selectedImg = ref<HTMLImageElement | null>(null)
const currentImgSize = ref('medium')
const currentImgAlign = ref('none')
const hPos = ref<Record<string, { left: string; top: string }>>({})
const handles = [
{ id: 'nw', cursor: 'nw-resize' },
{ id: 'ne', cursor: 'ne-resize' },
{ id: 'sw', cursor: 'sw-resize' },
{ id: 'se', cursor: 'se-resize' },
{ id: 'n', cursor: 'n-resize' },
{ id: 's', cursor: 's-resize' },
{ id: 'w', cursor: 'w-resize' },
{ id: 'e', cursor: 'e-resize' },
]
const sizeMap: Record<string, string> = { small: '120px', medium: '260px', large: '420px', full: '100%' }
const colors = [
'#000000', '#434343', '#666666', '#999999',
'#EF4444', '#F97316', '#F59E0B', '#EAB308',
'#22C55E', '#10B981', '#06B6D4', '#3B82F6',
'#6366F1', '#8B5CF6', '#EC4899', '#F43F5E',
]
// Resize drag state
let resizeCorner = ''
let startX = 0
let startY = 0
let startW = 0
let startH = 0
let aspectRatio = 1
function calcImgScreenRect() {
const iframe = iframeRef.value
const img = selectedImg.value
if (!iframe || !img) return null
const iRect = iframe.getBoundingClientRect()
const iDoc = iframe.contentDocument
if (!iDoc) return null
// Image rect relative to iframe viewport
const r = img.getBoundingClientRect()
return {
left: iRect.left + r.left,
top: iRect.top + r.top,
right: iRect.left + r.right,
bottom: iRect.top + r.bottom,
width: r.width,
height: r.height,
}
}
function syncHandlePos() {
const r = calcImgScreenRect()
if (!r) { hPos.value = {}; return }
const hs = 5 // half handle size
hPos.value = {
nw: { left: (r.left - hs) + 'px', top: (r.top - hs) + 'px' },
ne: { left: (r.right - hs) + 'px', top: (r.top - hs) + 'px' },
sw: { left: (r.left - hs) + 'px', top: (r.bottom - hs) + 'px' },
se: { left: (r.right - hs) + 'px', top: (r.bottom - hs) + 'px' },
n: { left: ((r.left + r.right) / 2 - hs) + 'px', top: (r.top - hs) + 'px' },
s: { left: ((r.left + r.right) / 2 - hs) + 'px', top: (r.bottom - hs) + 'px' },
w: { left: (r.left - hs) + 'px', top: ((r.top + r.bottom) / 2 - hs) + 'px' },
e: { left: (r.right - hs) + 'px', top: ((r.top + r.bottom) / 2 - hs) + 'px' },
toolbar: {
left: r.left + 'px',
top: (r.top - 40) + 'px',
},
}
}
function startResize(e: MouseEvent, corner: string) {
if (!selectedImg.value) return
resizeCorner = corner
startX = e.clientX
startY = e.clientY
const img = selectedImg.value
startW = img.getBoundingClientRect().width
startH = img.getBoundingClientRect().height
aspectRatio = startW / startH || 1
document.addEventListener('mousemove', onDragMove)
document.addEventListener('mouseup', onDragEnd)
}
function onDragMove(e: MouseEvent) {
if (!selectedImg.value) return
const dx = e.clientX - startX
const dy = e.clientY - startY
const img = selectedImg.value
let newW = startW
let newH = startH
switch (resizeCorner) {
case 'se': newW = startW + dx; break
case 'sw': newW = startW - dx; break
case 'ne': newW = startW + dx; break
case 'nw': newW = startW - dx; break
case 'e': newW = startW + dx; break
case 'w': newW = startW - dx; break
case 'n': newH = startH - dy; newW = newH * aspectRatio; break
case 's': newH = startH + dy; newW = newH * aspectRatio; break
}
// Clamp
newW = Math.max(30, Math.min(newW, 1200))
img.style.width = newW + 'px'
img.style.height = 'auto'
img.style.maxWidth = newW + 'px'
syncHandlePos()
}
function onDragEnd() {
resizeCorner = ''
document.removeEventListener('mousemove', onDragMove)
document.removeEventListener('mouseup', onDragEnd)
}
function handleIframeClick(e: Event) {
if (!isEditing.value) return
const target = e.target as HTMLElement
if (selectedImg.value) {
selectedImg.value.style.outline = ''
selectedImg.value = null
}
if (target.tagName === 'IMG') {
selectedImg.value = target as HTMLImageElement
selectedImg.value.style.outline = '2px solid #4F46E5'
selectedImg.value.style.cursor = 'pointer'
// Read current state
const w = selectedImg.value.style.width
if (w === '120px') currentImgSize.value = 'small'
else if (w === '260px') currentImgSize.value = 'medium'
else if (w === '420px') currentImgSize.value = 'large'
else if (w === '100%') currentImgSize.value = 'full'
else currentImgSize.value = ''
const f = selectedImg.value.style.float
if (f === 'left') currentImgAlign.value = 'left'
else if (f === 'right') currentImgAlign.value = 'right'
else currentImgAlign.value = 'center'
nextTick(syncHandlePos)
} else {
hPos.value = {}
}
}
function setImgSize(size: string) {
if (!selectedImg.value) return
currentImgSize.value = size
const img = selectedImg.value
img.style.width = sizeMap[size] || '260px'
img.style.maxWidth = size === 'full' ? '100%' : sizeMap[size]
img.style.height = 'auto'
nextTick(syncHandlePos)
}
function setImgAlign(align: string) {
if (!selectedImg.value) return
currentImgAlign.value = align
const img = selectedImg.value
img.style.float = ''
img.style.display = ''
img.style.margin = ''
if (align === 'left') { img.style.float = 'left'; img.style.margin = '0 12px 8px 0' }
else if (align === 'right') { img.style.float = 'right'; img.style.margin = '0 0 8px 12px' }
else { img.style.display = 'block'; img.style.margin = '8px auto' }
nextTick(syncHandlePos)
}
function deleteSelectedImg() {
if (!selectedImg.value) return
selectedImg.value.remove()
selectedImg.value = null
hPos.value = {}
}
function openInsertImageDialog(replace: boolean) {
replacingImg.value = replace
imageUrl.value = ''
imagePreviewUrl.value = ''
insertImageDialog.value = true
}
function closeImgDialog() {
insertImageDialog.value = false
replacingImg.value = false
imageUrl.value = ''
imagePreviewUrl.value = ''
nextTick(syncHandlePos)
}
// --- Animation ---
watch(showAnimDialog, async (v) => {
if (!v) return
if (!localStorage.getItem('access_token')) {
animList.value = []
animLoading.value = false
return
}
animLoading.value = true
try {
const res: any = await getAnimations({ limit: 50 })
animList.value = res.data || res || []
} catch { animList.value = [] }
finally { animLoading.value = false }
})
function insertAnimation(anim: any) {
const html = anim.config?.html
if (!html) return
const doc = iframeRef.value?.contentDocument
if (!doc) return
// Wrap animation in a container div
const wrapper = doc.createElement('div')
wrapper.style.cssText = 'width:100%;max-width:800px;margin:16px auto;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.1);'
wrapper.innerHTML = html
const sel = doc.getSelection()
if (sel && sel.rangeCount) {
const range = sel.getRangeAt(0)
range.deleteContents()
range.insertNode(wrapper)
} else {
doc.body.appendChild(wrapper)
}
showAnimDialog.value = false
const edited = doc.body.innerHTML
if (edited !== props.html) emit('update:html', edited)
}
// --- Core iframe logic ---
function writeContent() {
const iframe = iframeRef.value
if (!iframe) return
// Clear lingering setInterval/setTimeout from the previously rendered template
// so animation loops don't fire after the DOM is wiped (causes null-ref errors).
try {
const cw = iframe.contentWindow as any
if (cw) { for (let i = 1; i < 10000; i++) { cw.clearInterval(i); cw.clearTimeout(i) } }
} catch { /* iframe not ready */ }
let content = props.html || ''
if (!content) return
content = content.replace(/100vh/g, '100%').replace(/100vw/g, '100%')
const isFullDoc = /<html/i.test(content)
const finalHtml = isFullDoc
? content
: `<!DOCTYPE html><html><head><meta charset="UTF-8"><style>html,body{margin:0;padding:0;width:100%;height:100%;min-height:100%;overflow:auto;background:#fff;}body{font-family:"Microsoft YaHei","PingFang SC",sans-serif;}body>div:first-child{min-height:100%;}img{max-width:100%;height:auto;}</style></head><body>${content}</body></html>`
const doc = iframe.contentDocument
if (doc) {
doc.open()
doc.write(finalHtml)
doc.close()
if (isEditing.value) enableEditing()
}
}
function enableEditing() {
const iframe = iframeRef.value
if (!iframe) return
const doc = iframe.contentDocument
if (!doc) return
doc.designMode = 'on'
doc.body.style.outline = 'none'
doc.body.style.cursor = 'text'
doc.execCommand('enableObjectResizing', false, 'false')
doc.addEventListener('click', handleIframeClick)
doc.addEventListener('scroll', syncHandlePos)
}
function disableEditing() {
const iframe = iframeRef.value
if (!iframe) return
const doc = iframe.contentDocument
if (!doc) return
doc.removeEventListener('click', handleIframeClick)
doc.removeEventListener('scroll', syncHandlePos)
if (selectedImg.value) {
selectedImg.value.style.outline = ''
selectedImg.value = null
}
hPos.value = {}
doc.designMode = 'off'
const edited = doc.body.innerHTML
if (edited !== props.html) emit('update:html', edited)
}
function getHtml(): string {
const iframe = iframeRef.value
if (!iframe) return props.html
const doc = iframe.contentDocument
return doc ? doc.body.innerHTML : props.html
}
function execCmd(cmd: string, value?: string) {
iframeRef.value?.contentDocument?.execCommand(cmd, false, value)
iframeRef.value?.contentWindow?.focus()
}
function applyColor(color: string) {
selectedColor.value = color
showColorPicker.value = false
execCmd('foreColor', color)
}
function handleFileSelect(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => {
imageUrl.value = reader.result as string
imagePreviewUrl.value = reader.result as string
}
reader.readAsDataURL(file)
}
function triggerImageUpload() {
imageFileInput.value?.click()
}
function doInsertImage() {
if (!imageUrl.value) return
if (replacingImg.value && selectedImg.value) {
selectedImg.value.src = imageUrl.value
} else {
execCmd('insertImage', imageUrl.value)
}
closeImgDialog()
}
function handleGlobalClick(e: MouseEvent) {
const t = e.target as HTMLElement
if (showColorPicker.value && !t.closest('.color-panel') && !t.closest('.tb-btn')) {
showColorPicker.value = false
}
}
watch(() => props.html, () => nextTick(writeContent))
watch(() => props.editable, (val) => {
if (val) { isEditing.value = true; nextTick(enableEditing) }
else { if (isEditing.value) disableEditing(); isEditing.value = false }
})
onMounted(() => {
nextTick(writeContent)
document.addEventListener('click', handleGlobalClick)
window.addEventListener('resize', syncHandlePos)
window.addEventListener('scroll', syncHandlePos, true)
})
onUnmounted(() => {
document.removeEventListener('click', handleGlobalClick)
window.removeEventListener('resize', syncHandlePos)
window.removeEventListener('scroll', syncHandlePos, true)
const doc = iframeRef.value?.contentDocument
if (doc) { doc.removeEventListener('click', handleIframeClick); doc.removeEventListener('scroll', syncHandlePos) }
document.removeEventListener('mousemove', onDragMove)
document.removeEventListener('mouseup', onDragEnd)
})
defineExpose({ getHtml })
</script>
<style scoped>
.html-preview {
width: 100%;
border-radius: var(--radius-md);
overflow: visible;
background: #fff;
position: relative;
display: flex;
flex-direction: column;
}
.html-preview.edit-mode {
outline: 2px solid var(--primary);
outline-offset: -1px;
}
/* Toolbar */
.edit-toolbar {
display: flex; align-items: center; gap: 2px;
padding: 6px 10px;
background: #F8FAFC;
border-bottom: 1px solid #E2E8F0;
flex-shrink: 0;
position: relative;
flex-wrap: wrap;
z-index: 10;
}
.tb-btn {
width: 30px; height: 30px;
display: flex; align-items: center; justify-content: center;
border: none; background: none; border-radius: 6px;
cursor: pointer; font-size: 14px; color: #475569;
transition: all 0.15s; position: relative;
}
.tb-btn:hover { background: #E2E8F0; color: #1E293B; }
.tb-sep { width: 1px; height: 20px; background: #E2E8F0; margin: 0 4px; }
.color-indicator { position: absolute; bottom: 3px; left: 50%; transform: translateX(-50%); width: 14px; height: 3px; border-radius: 1px; }
.color-panel {
position: absolute; top: 42px; left: 10px;
background: #fff; border: 1px solid #E2E8F0; border-radius: 10px; padding: 10px;
display: grid; grid-template-columns: repeat(8, 1fr); gap: 4px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); z-index: 100;
}
.color-swatch { width: 24px; height: 24px; border-radius: 6px; cursor: pointer; transition: transform 0.15s; border: 2px solid transparent; }
.color-swatch:hover { transform: scale(1.2); border-color: #fff; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); }
.preview-iframe { width: 100%; height: 100%; flex: 1; border: none; display: block; min-height: 0; background: #fff; }
/* Resize handles */
.rh {
position: fixed;
width: 10px;
height: 10px;
background: #fff;
border: 2px solid #4F46E5;
border-radius: 2px;
z-index: 500;
transition: transform 0.1s;
}
.rh:hover {
transform: scale(1.4);
background: #4F46E5;
}
/* Image toolbar */
.img-bar {
position: fixed;
display: flex;
align-items: center;
gap: 2px;
padding: 4px 8px;
background: #fff;
border: 1px solid #E2E8F0;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
z-index: 500;
white-space: nowrap;
}
.bar-btn {
padding: 3px 8px;
border: none;
background: none;
border-radius: 4px;
font-size: 12px;
color: #64748B;
cursor: pointer;
transition: all 0.15s;
}
.bar-btn:hover { background: #F1F5F9; color: #4F46E5; }
.bar-btn.active { background: #4F46E5; color: #fff; }
.bar-btn.del { color: #EF4444; }
.bar-btn.del:hover { background: #FEF2F2; }
.bar-sep { width: 1px; height: 16px; background: #E2E8F0; margin: 0 2px; }
/* Image dialog */
.img-dialog-overlay {
position: absolute; inset: 0;
background: rgba(0, 0, 0, 0.3);
display: flex; align-items: center; justify-content: center;
z-index: 600;
}
.img-dialog { background: #fff; border-radius: 12px; width: 380px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2); overflow: hidden; }
.img-dialog h4 { padding: 16px 20px; font-size: 15px; font-weight: 600; color: #1E293B; border-bottom: 1px solid #E2E8F0; }
.img-dialog-body { padding: 16px 20px; }
.img-dialog-body label { display: block; font-size: 13px; font-weight: 500; color: #64748B; margin-bottom: 6px; }
.hidden-file-input { display: none; }
.upload-img-btn {
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 14px;
border: 1px solid #C7D2FE;
border-radius: 8px;
background: #EEF2FF;
color: #4F46E5;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.upload-img-btn:hover { background: #E0E7FF; }
.img-preview-box { margin-top: 10px; border-radius: 8px; overflow: hidden; border: 1px solid #E2E8F0; }
.img-preview-box img { max-width: 100%; max-height: 120px; display: block; margin: 0 auto; }
.img-dialog-footer { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 20px; border-top: 1px solid #E2E8F0; }
.dialog-btn { padding: 7px 18px; border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer; border: none; transition: all 0.2s; }
.dialog-btn.cancel { background: #F1F5F9; color: #64748B; }
.dialog-btn.cancel:hover { background: #E2E8F0; }
.dialog-btn.confirm { background: var(--primary); color: #fff; }
.dialog-btn.confirm:hover:not(:disabled) { opacity: 0.9; }
.dialog-btn.confirm:disabled { opacity: 0.4; cursor: not-allowed; }
/* Animation list */
.anim-list { display: flex; flex-direction: column; gap: 8px; max-height: 360px; overflow-y: auto; }
.anim-item { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: #F8FAFC; border: 1px solid #E2E8F0; border-radius: 8px; cursor: pointer; transition: all 0.2s; }
.anim-item:hover { border-color: #6366F1; background: #EEF2FF; }
.anim-icon { width: 32px; height: 32px; background: #EEF2FF; border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.anim-info { flex: 1; min-width: 0; }
.anim-title { font-size: 13px; font-weight: 600; color: #1F2937; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.anim-desc { font-size: 11px; color: #9CA3AF; margin-top: 2px; }
.anim-insert-btn { padding: 4px 12px; background: #6366F1; color: #fff; border-radius: 6px; font-size: 11px; font-weight: 500; flex-shrink: 0; }
.anim-item:hover .anim-insert-btn { background: #4F46E5; }
</style>
@@ -0,0 +1,128 @@
<template>
<div class="md-content" v-html="rendered"></div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { marked } from 'marked'
marked.setOptions({
breaks: true,
gfm: true,
})
const props = defineProps<{ content: string }>()
const rendered = computed(() => {
if (!props.content) return ''
return marked.parse(props.content) as string
})
</script>
<style scoped>
.md-content {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.8;
word-break: break-word;
}
.md-content :deep(h1),
.md-content :deep(h2),
.md-content :deep(h3),
.md-content :deep(h4) {
color: var(--text-primary);
font-weight: 600;
margin: 16px 0 8px;
}
.md-content :deep(h3) { font-size: 16px; }
.md-content :deep(h4) { font-size: 15px; }
.md-content :deep(p) {
margin-bottom: 8px;
}
.md-content :deep(strong) {
color: var(--text-primary);
font-weight: 600;
}
.md-content :deep(em) {
color: var(--primary);
font-style: normal;
font-weight: 500;
}
.md-content :deep(ul),
.md-content :deep(ol) {
padding-left: 20px;
margin-bottom: 8px;
}
.md-content :deep(li) {
margin-bottom: 4px;
}
.md-content :deep(code) {
background: #F1F5F9;
padding: 2px 6px;
border-radius: 4px;
font-size: 13px;
color: #E11D48;
}
.md-content :deep(pre) {
background: #1E293B;
color: #E2E8F0;
padding: 16px;
border-radius: var(--radius-sm);
overflow-x: auto;
margin: 12px 0;
}
.md-content :deep(pre code) {
background: none;
color: inherit;
padding: 0;
}
.md-content :deep(blockquote) {
border-left: 3px solid var(--primary-light);
padding: 8px 16px;
margin: 12px 0;
background: var(--primary-bg);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
color: var(--text-secondary);
}
.md-content :deep(hr) {
border: none;
border-top: 1px solid var(--border-color);
margin: 16px 0;
}
.md-content :deep(table) {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
}
.md-content :deep(th),
.md-content :deep(td) {
border: 1px solid var(--border-color);
padding: 8px 12px;
text-align: left;
}
.md-content :deep(th) {
background: #F8FAFC;
font-weight: 600;
color: var(--text-primary);
}
.md-content :deep(img) {
max-width: 100%;
border-radius: var(--radius-sm);
}
</style>
@@ -0,0 +1,282 @@
<template>
<div class="material-picker">
<button class="material-btn" type="button" :disabled="loading" @click="openLibrary">
<el-icon><FolderOpened /></el-icon>
素材库
</button>
<div v-if="showSelected && modelValue.length" class="materials-row">
<span v-for="item in modelValue" :key="item.id">
<el-icon><component :is="item.icon" /></el-icon>
{{ item.name }}
<em>{{ item.typeLabel }} · {{ item.sizeLabel }}</em>
<button type="button" @click="removeMaterial(item.id)">×</button>
</span>
</div>
<el-dialog v-model="visible" title="选择素材库材料" width="760px">
<div class="material-library-toolbar">
<div class="library-search">
<el-icon><Search /></el-icon>
<input v-model="keyword" placeholder="搜索素材标题、文件名或摘要" @keyup.enter="loadLibrary" />
<button v-if="keyword" type="button" @click="clearKeyword">清除</button>
</div>
<el-button :loading="loading" @click="loadLibrary">刷新</el-button>
</div>
<div v-loading="loading" class="material-library-list">
<article v-for="item in materials" :key="item.id" class="library-item">
<div>
<strong>{{ item.title || item.filename }}</strong>
<span>{{ materialTypeLabel(item.material_type, item) }} · {{ item.subject || '通用' }} · {{ formatMaterialFileSize(item.size || 0) }}</span>
<p>{{ item.summary || '暂无摘要' }}</p>
</div>
<button type="button" @click="addMaterial(item)">加入任务</button>
</article>
<div v-if="!loading && materials.length === 0" class="library-empty">
<strong>素材库暂无匹配材料</strong>
<span>可以先到素材库上传解析或换个关键词再试</span>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getMaterials } from '@/api/material'
import { formatMaterialFileSize, materialToContext, materialTypeLabel, type MaterialContextItem } from '@/utils/materialContext'
const props = withDefaults(defineProps<{
modelValue: MaterialContextItem[]
showSelected?: boolean
}>(), {
showSelected: true,
})
const emit = defineEmits<{ 'update:modelValue': [value: MaterialContextItem[]] }>()
const router = useRouter()
const visible = ref(false)
const loading = ref(false)
const keyword = ref('')
const materials = ref<any[]>([])
async function openLibrary() {
if (!localStorage.getItem('access_token')) {
ElMessage.warning('请先登录后使用素材库')
router.push({ path: '/login', query: { redirect: router.currentRoute.value.fullPath } })
return
}
visible.value = true
await loadLibrary()
}
async function loadLibrary() {
loading.value = true
try {
const data: any = await getMaterials({
keyword: keyword.value.trim() || undefined,
limit: 50,
})
materials.value = Array.isArray(data?.data) ? data.data : Array.isArray(data) ? data : []
} finally {
loading.value = false
}
}
function clearKeyword() {
keyword.value = ''
loadLibrary()
}
function addMaterial(item: any) {
const context = materialToContext(item)
if (props.modelValue.some(material => material.id === context.id)) {
ElMessage.info('该素材已在当前任务中')
return
}
emit('update:modelValue', [...props.modelValue, context])
ElMessage.success('已加入当前生成任务')
}
function removeMaterial(id: string) {
emit('update:modelValue', props.modelValue.filter(item => item.id !== id))
}
</script>
<style scoped>
.material-picker {
display: contents;
}
.material-btn {
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: 1px solid #dbe8de;
border-radius: 18px;
background: #fff;
color: #31584d;
padding: 0 13px;
font-size: 13px;
font-weight: 800;
cursor: pointer;
}
.material-btn:hover {
border-color: #9ed0aa;
background: #f6fbf6;
color: #00543d;
}
.material-btn:disabled {
opacity: .55;
cursor: not-allowed;
}
.materials-row {
flex-basis: 100%;
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 4px;
}
.materials-row span {
max-width: 100%;
min-height: 30px;
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid #dbe8de;
border-radius: 15px;
background: #f6fbf6;
color: #31584d;
padding: 5px 8px 5px 11px;
font-size: 12px;
font-weight: 800;
}
.materials-row em {
color: #8a9992;
font-style: normal;
font-weight: 500;
}
.materials-row button {
border: none;
background: transparent;
color: #78908a;
cursor: pointer;
}
.material-library-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.library-search {
height: 36px;
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
border: 1px solid #dbe8de;
border-radius: 18px;
background: #f8faf8;
padding: 0 12px;
}
.library-search input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
color: #173f35;
}
.library-search button {
border: none;
background: transparent;
color: #00724f;
font-weight: 800;
cursor: pointer;
}
.material-library-list {
min-height: 240px;
max-height: 520px;
overflow-y: auto;
display: grid;
gap: 10px;
}
.library-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 14px;
align-items: center;
border: 1px solid #dce8de;
border-radius: 12px;
background: #fff;
padding: 14px;
}
.library-item strong {
display: block;
color: #173f35;
font-size: 15px;
margin-bottom: 5px;
}
.library-item span {
display: block;
color: #6a7f77;
font-size: 12px;
margin-bottom: 8px;
}
.library-item p {
color: #51665f;
font-size: 13px;
line-height: 1.55;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.library-item button {
height: 34px;
border: none;
border-radius: 17px;
background: #00543d;
color: #fff;
padding: 0 15px;
font-size: 13px;
font-weight: 900;
cursor: pointer;
}
.library-empty {
min-height: 220px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
border: 1px dashed #cfe0d3;
border-radius: 12px;
color: #75867f;
}
.library-empty strong {
color: #173f35;
}
</style>
+158
View File
@@ -0,0 +1,158 @@
<template>
<li class="mind-node" :class="{ leaf: !hasChildren }">
<span class="connector" :style="{ borderColor: branchColor }" />
<div
class="node-card"
:class="[levelClass, { clickable: hasChildren }]"
:style="cardStyle"
@click="toggle"
>
<span class="node-dot" v-if="level > 1" :style="{ background: branchColor }" />
<span class="node-title">{{ node.title }}</span>
<el-icon v-if="hasChildren" class="toggle-icon" :class="{ collapsed: !expanded }">
<CaretBottom />
</el-icon>
</div>
<transition name="mm-expand">
<ul v-if="hasChildren && expanded" class="mind-children">
<MindNode
v-for="(child, i) in node.children"
:key="i"
:node="child"
:level="level + 1"
:branch-color="branchColor"
/>
</ul>
</transition>
</li>
</template>
<script setup lang="ts">
import { computed, ref, inject, watch } from 'vue'
import { CaretBottom } from '@element-plus/icons-vue'
import type { MindMapNode } from '@/api/mindmap'
const props = withDefaults(defineProps<{
node: MindMapNode
level?: number
branchColor?: string
}>(), {
level: 1,
branchColor: '#00a870',
})
defineOptions({ name: 'MindNode' })
const mm: any = inject('mmExpand', null)
const expanded = ref(true)
watch(() => mm?.expandVersion?.value, () => {
if (mm) expanded.value = mm.expandMode.value === 'open'
})
const hasChildren = computed(() => Array.isArray(props.node.children) && props.node.children.length > 0)
const levelClass = computed(() => `lvl-${Math.min(props.level, 3)}`)
const cardStyle = computed(() => {
if (props.level === 1) {
return {
background: props.branchColor + '14',
borderColor: props.branchColor,
color: '#1f2937',
}
}
return {}
})
function toggle() {
if (hasChildren.value) expanded.value = !expanded.value
}
</script>
<style scoped>
.mind-node {
display: flex;
align-items: center;
position: relative;
padding-left: 30px;
}
.connector {
position: absolute;
left: 0;
top: 50%;
width: 22px;
height: 0;
border-top: 2px solid #d1d5db;
border-radius: 2px;
}
.leaf .connector {
border-color: #e5e7eb;
}
.mind-children {
display: flex;
flex-direction: column;
gap: 10px;
list-style: none;
margin: 0;
padding: 10px 0 10px 8px;
}
.node-card {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 16px;
border-radius: 10px;
border: 1.5px solid #e5e7eb;
background: #ffffff;
color: #1f2937;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
transition: transform 0.15s, box-shadow 0.15s;
user-select: none;
}
.node-card.clickable {
cursor: pointer;
}
.node-card.clickable:hover {
transform: translateX(3px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.node-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.node-title {
line-height: 1.4;
}
.toggle-icon {
font-size: 12px;
color: #9ca3af;
transition: transform 0.2s;
}
.toggle-icon.collapsed {
transform: rotate(-90deg);
}
.node-card.lvl-1 {
font-size: 15px;
font-weight: 700;
border-width: 2px;
}
.node-card.lvl-3 {
font-size: 13px;
font-weight: 500;
background: #fafafa;
}
.mm-expand-enter-active,
.mm-expand-leave-active {
transition: opacity 0.2s, transform 0.2s;
}
.mm-expand-enter-from,
.mm-expand-leave-to {
opacity: 0;
transform: translateX(-10px);
}
</style>
@@ -0,0 +1,306 @@
<template>
<el-dialog
v-model="visible"
:title="title"
width="92%"
top="3vh"
class="tpl-gallery-dialog"
:close-on-click-modal="false"
append-to-body
destroy-on-close
>
<div class="tg-layout">
<!-- Left: template list -->
<aside class="tg-sidebar">
<div class="tg-search">
<el-input v-model="keyword" placeholder="搜索模板" size="small" clearable>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
</div>
<div class="tg-cat-row">
<button
v-for="c in categories"
:key="c.value"
class="tg-cat-chip"
:class="{ active: cat === c.value }"
@click="cat = c.value"
>
{{ c.label }}
</button>
</div>
<div class="tg-list">
<button
v-for="t in filteredTemplates"
:key="t.id"
class="tg-card"
:class="{ active: selected?.id === t.id }"
@click="selectTemplate(t)"
>
<div class="tg-card-icon" :style="{ background: t.accent }">
<el-icon :size="18" color="#fff"><component :is="t.icon" /></el-icon>
</div>
<div class="tg-card-body">
<div class="tg-card-name">{{ t.name }}</div>
<div class="tg-card-desc">{{ t.description }}</div>
</div>
</button>
<div v-if="!filteredTemplates.length" class="tg-empty">未找到匹配模板</div>
</div>
</aside>
<!-- Right: params + preview -->
<section class="tg-main" v-if="selected">
<header class="tg-main-head">
<div>
<h3>{{ selected.name }}</h3>
<p>{{ selected.description }}</p>
</div>
<span class="tg-cat-badge">{{ categoryLabelOf(selected) }}</span>
</header>
<div class="tg-params" v-if="selected.params.length">
<div class="tg-param" v-for="p in selected.params" :key="p.key">
<label>
{{ p.label }}
<span class="tg-param-val" v-if="p.type === 'number'">{{ params[p.key] }}</span>
</label>
<el-input-number
v-if="p.type === 'number'"
v-model="params[p.key]"
:min="p.min"
:max="p.max"
:step="p.step"
size="small"
@change="renderPreview"
/>
<el-select v-else-if="p.type === 'select'" v-model="params[p.key]" size="small" @change="renderPreview">
<el-option v-for="o in p.options" :key="String(o.value)" :label="o.label" :value="o.value" />
</el-select>
<el-input
v-else-if="p.type === 'text'"
v-model="params[p.key]"
type="textarea"
:rows="Math.min(6, String(p.default).split(/\n/).length + 1)"
size="small"
@input="renderPreview"
/>
<el-color-picker v-else-if="p.type === 'color'" v-model="params[p.key]" @change="renderPreview" />
</div>
</div>
<div class="tg-preview-wrap">
<div class="tg-preview-head">
<span>实时预览</span>
<button class="tg-refresh" @click="renderPreview" title="刷新预览">
<el-icon><Refresh /></el-icon> 刷新
</button>
</div>
<HtmlPreview :html="previewHtml" height="460px" />
</div>
<footer class="tg-actions">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="handleUse">
<el-icon><Check /></el-icon> 使用此模板
</el-button>
</footer>
</section>
<section class="tg-main tg-empty-main" v-else>
<el-icon :size="48" color="#cbd5e1"><Grid /></el-icon>
<p>从左侧选择一个模板开始</p>
</section>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Search, Refresh, Check, Grid } from '@element-plus/icons-vue'
import HtmlPreview from '@/components/common/HtmlPreview.vue'
import {
animationTemplates,
defaultParams,
animationCategoryLabels,
type AnimationTemplate,
} from '@/utils/animationTemplates'
import {
gameTemplates,
defaultGameParams,
gameCategoryLabels,
type GameTemplate,
} from '@/utils/gameTemplates'
type AnyTemplate = (AnimationTemplate & { _kind: 'anim' }) | (GameTemplate & { _kind: 'game' })
const props = defineProps<{
modelValue: boolean
kind: 'animation' | 'game'
title?: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'use', payload: { kind: 'animation' | 'game'; template: any; params: Record<string, any>; html: string; title: string }): void
}>()
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const title = computed(() => props.title || (props.kind === 'animation' ? '教学动画模板库' : '教学游戏模板库'))
const allTemplates = computed<AnyTemplate[]>(() => {
if (props.kind === 'animation') {
return animationTemplates.map((t) => ({ ...t, _kind: 'anim' as const }))
}
return gameTemplates.map((t) => ({ ...t, _kind: 'game' as const }))
})
const categories = computed(() => {
if (props.kind === 'animation') {
return [
{ value: 'all', label: '全部' },
...Object.entries(animationCategoryLabels).map(([value, label]) => ({ value, label })),
]
}
return [
{ value: 'all', label: '全部' },
...Object.entries(gameCategoryLabels).map(([value, label]) => ({ value, label })),
]
})
const cat = ref('all')
const keyword = ref('')
const selected = ref<AnyTemplate | null>(null)
const params = ref<Record<string, any>>({})
const previewHtml = ref('')
const filteredTemplates = computed(() => {
const kw = keyword.value.trim().toLowerCase()
return allTemplates.value.filter((t) => {
if (cat.value !== 'all' && t.category !== cat.value) return false
if (!kw) return true
return t.name.toLowerCase().includes(kw) || t.description.toLowerCase().includes(kw)
})
})
function categoryLabelOf(t: AnyTemplate): string {
if (t._kind === 'anim') return animationCategoryLabels[t.category]
return gameCategoryLabels[t.category]
}
function selectTemplate(t: AnyTemplate) {
selected.value = t
params.value = t._kind === 'anim' ? defaultParams(t) : defaultGameParams(t)
renderPreview()
}
function renderPreview() {
if (!selected.value) return
previewHtml.value = selected.value.render(params.value)
}
watch(
() => props.modelValue,
(v) => {
if (v) {
cat.value = 'all'
keyword.value = ''
const first = allTemplates.value[0]
if (first) selectTemplate(first)
else {
selected.value = null
previewHtml.value = ''
}
}
},
)
function handleUse() {
if (!selected.value) return
const t = selected.value
const html = t.render(params.value)
const t2 = t as any
const generatedTitle = typeof t2.defaultTitle === 'function' ? t2.defaultTitle(params.value) : t.name
emit('use', {
kind: props.kind,
template: t,
params: { ...params.value },
html,
title: generatedTitle,
})
visible.value = false
}
</script>
<style scoped>
.tg-layout {
display: grid;
grid-template-columns: 280px 1fr;
gap: 16px;
height: calc(90vh - 110px);
min-height: 480px;
}
.tg-sidebar {
display: flex; flex-direction: column; gap: 10px;
border-right: 1px solid #e5e7eb; padding-right: 12px; overflow: hidden;
}
.tg-search :deep(.el-input) { width: 100%; }
.tg-cat-row { display: flex; flex-wrap: wrap; gap: 6px; }
.tg-cat-chip {
padding: 4px 10px; border-radius: 14px; border: 1px solid #e5e7eb;
background: #fff; color: #6b7280; font-size: 12px; cursor: pointer;
transition: all 0.15s;
}
.tg-cat-chip:hover { border-color: #6366f1; color: #6366f1; }
.tg-cat-chip.active { background: #4f46e5; color: #fff; border-color: #4f46e5; }
.tg-list { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; padding-right: 4px; }
.tg-card {
display: flex; gap: 10px; align-items: flex-start;
padding: 10px; border: 1px solid #e5e7eb; border-radius: 10px;
background: #fff; cursor: pointer; text-align: left;
transition: all 0.15s;
}
.tg-card:hover { border-color: #c7d2fe; background: #f5f7ff; }
.tg-card.active { border-color: #4f46e5; background: #eef2ff; box-shadow: 0 2px 8px rgba(79,70,229,0.1); }
.tg-card-icon { width: 34px; height: 34px; border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.tg-card-body { flex: 1; min-width: 0; }
.tg-card-name { font-size: 13px; font-weight: 600; color: #1f2937; }
.tg-card-desc { font-size: 11px; color: #6b7280; margin-top: 2px; line-height: 1.4; }
.tg-empty { padding: 24px; text-align: center; color: #9ca3af; font-size: 13px; }
.tg-main { display: flex; flex-direction: column; gap: 12px; overflow: hidden; }
.tg-empty-main { align-items: center; justify-content: center; color: #9ca3af; }
.tg-empty-main p { margin-top: 10px; font-size: 14px; }
.tg-main-head {
display: flex; justify-content: space-between; align-items: flex-start; gap: 12px;
padding-bottom: 10px; border-bottom: 1px solid #e5e7eb;
}
.tg-main-head h3 { font-size: 17px; font-weight: 700; color: #111827; }
.tg-main-head p { font-size: 12px; color: #6b7280; margin-top: 3px; }
.tg-cat-badge {
padding: 3px 10px; border-radius: 12px; background: #eef2ff; color: #4f46e5;
font-size: 11px; font-weight: 600; white-space: nowrap;
}
.tg-params {
display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px 16px; padding: 12px; background: #f9fafb; border-radius: 10px;
}
.tg-param { display: flex; flex-direction: column; gap: 4px; }
.tg-param label { font-size: 12px; color: #4b5563; font-weight: 500; display: flex; justify-content: space-between; align-items: center; }
.tg-param-val { color: #4f46e5; font-weight: 600; }
.tg-preview-wrap { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.tg-preview-head {
display: flex; justify-content: space-between; align-items: center;
font-size: 13px; color: #6b7280; font-weight: 600; margin-bottom: 6px;
}
.tg-refresh {
display: inline-flex; align-items: center; gap: 4px; border: 1px solid #e5e7eb;
background: #fff; color: #6b7280; font-size: 12px; padding: 4px 10px;
border-radius: 6px; cursor: pointer; transition: all 0.15s;
}
.tg-refresh:hover { border-color: #6366f1; color: #6366f1; }
.tg-preview-wrap :deep(.html-preview) { flex: 1; border: 1px solid #e5e7eb; border-radius: 10px; overflow: hidden; }
.tg-actions { display: flex; justify-content: flex-end; gap: 10px; padding-top: 8px; border-top: 1px solid #e5e7eb; }
</style>
@@ -0,0 +1,387 @@
<template>
<div class="zj-header">
<div class="header-left">
<button class="collapse-btn" @click="$emit('toggle')" :title="isCollapse ? '展开侧边栏' : '收起侧边栏'">
<el-icon>
<Fold v-if="!isCollapse" />
<Expand v-else />
</el-icon>
</button>
<router-link to="/" class="back-square">
<el-icon><HomeFilled /></el-icon>
</router-link>
<span class="page-title">{{ currentTitle }}</span>
</div>
<form class="header-center" @submit.prevent="submitSearch">
<el-icon><Search /></el-icon>
<input v-model="searchText" placeholder="搜索资源、模板或输入知识点快速创作" />
<button v-if="searchText" type="button" title="清空搜索" @click="searchText = ''">×</button>
</form>
<div class="header-right">
<button class="ghost-btn" type="button" @click="router.push('/materials')">素材库</button>
<el-popover trigger="click" placement="bottom-end" :width="380" @show="onBellOpen" popper-class="notif-popover">
<template #reference>
<button class="bell-btn" type="button" title="通知">
<el-icon><Bell /></el-icon>
<span v-if="unreadCount > 0" class="bell-badge">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
</button>
</template>
<div class="notif-panel">
<div class="notif-header">
<span>通知</span>
<button v-if="unreadCount > 0" type="button" class="notif-read-all" @click="handleMarkAll">全部已读</button>
</div>
<div class="notif-list" v-if="notifications.length">
<div
v-for="n in notifications"
:key="n.id"
class="notif-item"
:class="{ unread: !n.is_read }"
@click="handleNotifClick(n)"
>
<span class="notif-dot" v-if="!n.is_read"></span>
<div class="notif-body">
<div class="notif-title">{{ n.title }}</div>
<div class="notif-time">{{ formatTime(n.created_at) }}</div>
</div>
</div>
</div>
<el-empty v-else description="暂无通知" :image-size="48" />
</div>
</el-popover>
<button class="try-btn" type="button" @click="router.push('/courseware/create')">立即体验</button>
<el-dropdown trigger="click" @command="handleCommand">
<button class="user-btn" type="button">
<span>{{ userStore.user?.name?.[0] || '未' }}</span>
</button>
<template #dropdown>
<el-dropdown-menu>
<template v-if="userStore.user">
<el-dropdown-item command="profile">个人资料</el-dropdown-item>
<el-dropdown-item command="works">我的作品</el-dropdown-item>
<el-dropdown-item command="materials">我的素材库</el-dropdown-item>
<el-dropdown-item command="resources">资源广场</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</template>
<template v-else>
<el-dropdown-item command="login">登录</el-dropdown-item>
<el-dropdown-item command="register">注册</el-dropdown-item>
<el-dropdown-item command="resources" divided>先看资源</el-dropdown-item>
</template>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { Bell } from '@element-plus/icons-vue'
import { getNotifications, getUnreadCount, markRead, markAllRead, type NotificationItem } from '@/api/notification'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
defineProps<{ isCollapse: boolean }>()
defineEmits(['toggle'])
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const currentTitle = computed(() => (route.meta.title as string) || '创作广场')
const unreadCount = ref(0)
const notifications = ref<NotificationItem[]>([])
async function fetchUnread() {
if (!userStore.user) return
try {
const res: any = await getUnreadCount()
unreadCount.value = res.count || 0
} catch { /* ignore */ }
}
async function onBellOpen() {
if (!userStore.user) return
try {
const res: any = await getNotifications({ limit: 20 })
notifications.value = res || []
} catch { /* ignore */ }
}
async function handleMarkAll() {
await markAllRead()
notifications.value = notifications.value.map((n: NotificationItem) => ({ ...n, is_read: true }))
unreadCount.value = 0
}
async function handleNotifClick(n: NotificationItem) {
if (!n.is_read) {
await markRead(n.id).catch(() => undefined)
n.is_read = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
if (n.link) router.push(n.link)
}
function formatTime(ts: string | null): string {
if (!ts) return ''
const d = new Date(ts)
if (isNaN(d.getTime())) return ''
const diff = Date.now() - d.getTime()
if (diff < 60000) return '刚刚'
if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前'
if (diff < 86400000) return Math.floor(diff / 3600000) + '小时前'
return (d.getMonth() + 1) + '/' + d.getDate()
}
const searchText = ref('')
onMounted(() => {
fetchUnread()
setInterval(fetchUnread, 60000)
if (localStorage.getItem('access_token') && !userStore.user) {
userStore.fetchProfile()
}
})
function handleCommand(command: string) {
if (command === 'login') router.push('/login')
if (command === 'register') router.push({ path: '/login', query: { tab: 'register' } })
if (command === 'profile') router.push('/profile')
if (command === 'works') router.push('/courseware')
if (command === 'materials') router.push('/materials')
if (command === 'resources') router.push('/resources')
if (command === 'logout') {
userStore.clearToken()
router.push('/')
}
}
function submitSearch() {
const keyword = searchText.value.trim()
if (keyword) {
if (router.currentRoute.value.path === '/materials') {
router.push({ path: '/materials', query: { keyword } })
return
}
router.push({ path: '/search', query: { keyword } })
return
}
router.push('/search')
}
watch(() => route.query.keyword, (keyword) => {
searchText.value = typeof keyword === 'string' ? keyword : ''
}, { immediate: true })
</script>
<style scoped>
.zj-header {
width: 100%;
display: grid;
grid-template-columns: minmax(170px, 1fr) minmax(260px, 440px) minmax(150px, 1fr);
align-items: center;
gap: 16px;
}
.header-left,
.header-right {
display: flex;
align-items: center;
min-width: 0;
}
.header-left {
gap: 8px;
}
.header-right {
justify-content: flex-end;
gap: 10px;
}
.collapse-btn,
.back-square,
.user-btn {
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #d6e4d8;
border-radius: 9px;
background: #fff;
color: #31584d;
cursor: pointer;
text-decoration: none;
}
.page-title {
color: #173f35;
font-size: 15px;
font-weight: 800;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-center {
height: 36px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
border-radius: 19px;
background: #eef1ec;
color: #9aa7a0;
padding: 0 10px 0 15px;
min-width: 0;
}
.header-center input {
min-width: 0;
flex: 1;
border: none;
outline: none;
background: transparent;
color: #173f35;
font-size: 13px;
}
.header-center input::placeholder {
color: #9aa7a0;
}
.header-center button {
width: 20px;
height: 20px;
flex: 0 0 20px;
border: none;
border-radius: 50%;
background: #dce8de;
color: #51665f;
cursor: pointer;
line-height: 20px;
}
.header-center button:hover {
background: #cfe0d3;
color: #00543d;
}
.header-center span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.try-btn {
height: 34px;
border: none;
border-radius: 18px;
background: #00543d;
color: #fff;
padding: 0 18px;
font-size: 14px;
font-weight: 800;
cursor: pointer;
}
.bell-btn {
position: relative;
width: 36px; height: 36px;
border: none; border-radius: 50%;
background: transparent;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
color: #64748b;
transition: background .2s;
}
.bell-btn:hover { background: #f1f5f9; color: #4f46e5; }
.bell-badge {
position: absolute; top: 2px; right: 2px;
min-width: 16px; height: 16px;
background: #ef4444; color: #fff;
font-size: 10px; font-weight: 700;
border-radius: 8px; line-height: 16px;
text-align: center; padding: 0 4px;
}
.notif-panel { max-height: 420px; overflow-y: auto; }
.notif-header {
display: flex; justify-content: space-between; align-items: center;
padding: 4px 4px 12px; font-size: 15px; font-weight: 700;
}
.notif-read-all {
border: none; background: transparent;
color: #4f46e5; font-size: 12px; cursor: pointer;
}
.notif-read-all:hover { text-decoration: underline; }
.notif-list { display: flex; flex-direction: column; }
.notif-item {
display: flex; align-items: flex-start; gap: 8px;
padding: 10px 8px; border-radius: 8px;
cursor: pointer; transition: background .15s;
}
.notif-item:hover { background: #f8fafc; }
.notif-item.unread { background: #eef2ff; }
.notif-item.unread:hover { background: #e0e7ff; }
.notif-dot {
width: 8px; height: 8px; border-radius: 50%;
background: #4f46e5; flex-shrink: 0; margin-top: 5px;
}
.notif-body { flex: 1; min-width: 0; }
.notif-title { font-size: 13px; color: #1e293b; line-height: 1.5; }
.notif-time { font-size: 11px; color: #94a3b8; margin-top: 2px; }
.ghost-btn {
height: 34px;
border: 1px solid #d6e4d8;
border-radius: 18px;
background: #fff;
color: #31584d;
padding: 0 14px;
font-size: 13px;
font-weight: 800;
cursor: pointer;
}
.ghost-btn:hover {
border-color: #a8cfab;
background: #f6fbf6;
color: #00543d;
}
.user-btn span {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 7px;
background: #d9f0d6;
color: #00543d;
font-size: 12px;
font-weight: 800;
}
@media (max-width: 980px) {
.zj-header {
grid-template-columns: 1fr auto;
}
.header-center {
display: none;
}
}
@media (max-width: 720px) {
.ghost-btn {
display: none;
}
}
</style>
@@ -0,0 +1,92 @@
<template>
<router-view v-if="route.meta.fullPage" />
<el-container v-else class="app-layout">
<el-aside :width="isCollapse ? 'var(--sidebar-collapsed)' : 'var(--sidebar-width)'" class="sidebar" :class="{ collapsed: isCollapse }">
<AppSidebar :is-collapse="isCollapse" />
</el-aside>
<el-container class="main-container">
<el-header class="app-header-bar" height="60px">
<AppHeader :is-collapse="isCollapse" @toggle="isCollapse = !isCollapse" />
</el-header>
<el-main class="app-main">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import AppSidebar from './AppSidebar.vue'
import AppHeader from './AppHeader.vue'
const isCollapse = ref(false)
const route = useRoute()
</script>
<style scoped>
.app-layout {
height: 100vh;
min-width: 0;
overflow: hidden;
background: #f8faf7;
}
.sidebar {
background: #eef5ee;
border-right: 1px solid #cfded2;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
z-index: 10;
flex-shrink: 0;
}
.app-layout:has(.sidebar.collapsed) {
--active-sidebar-width: var(--sidebar-collapsed);
}
.app-layout:not(:has(.sidebar.collapsed)) {
--active-sidebar-width: var(--sidebar-width);
}
.main-container {
height: 100vh;
min-width: 0;
flex-direction: column;
}
.app-header-bar {
background: #fff;
border-bottom: 1px solid #e8ede8;
padding: 0 24px;
display: flex;
align-items: center;
z-index: 5;
flex-shrink: 0;
}
.app-main {
position: relative;
flex: 1;
background: #f8faf7;
padding: 0;
overflow-y: auto;
overflow-x: hidden;
min-width: 0;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
@@ -0,0 +1,356 @@
<template>
<aside class="zj-sidebar" :class="{ compact: isCollapse }">
<div class="brand-row">
<router-link to="/" class="brand" :title="isCollapse ? '智教助手' : undefined">
<strong>{{ isCollapse ? '智' : '智教助手' }}</strong>
<sup v-if="!isCollapse">®</sup>
</router-link>
</div>
<button class="new-task" type="button" @click="goCreate('courseware')">
<el-icon><Plus /></el-icon>
<span v-if="!isCollapse">新建任务</span>
</button>
<nav class="nav-list">
<button
v-for="item in taskNav"
:key="item.mode"
type="button"
class="nav-item"
:class="{ active: isModeActive(item.mode) }"
@click="goCreate(item.mode)"
:title="isCollapse ? item.label : undefined"
>
<el-icon><component :is="item.icon" /></el-icon>
<span v-if="!isCollapse">{{ item.label }}</span>
</button>
</nav>
<nav class="nav-list secondary-nav">
<button
v-for="item in workspaceNav"
:key="item.path"
type="button"
class="nav-item"
:class="{ active: route.path === item.path }"
@click="router.push(item.path)"
:title="isCollapse ? item.label : undefined"
>
<el-icon><component :is="item.icon" /></el-icon>
<span v-if="!isCollapse">{{ item.label }}</span>
</button>
</nav>
<button v-if="!isCollapse" class="points-banner" type="button" @click="router.push('/profile')">
<span class="medal"><el-icon><Medal /></el-icon></span>
<strong>智教助手2.0 积分活动</strong>
</button>
<div class="sidebar-bottom">
<button class="score-pill" type="button" @click="router.push('/profile')" :title="pointsText">
<el-icon><Collection /></el-icon>
<span v-if="!isCollapse">{{ pointsText }}</span>
</button>
<button class="user-entry" type="button" @click="handleUserClick" :title="userStore.user?.name || '智教用户'">
<span class="avatar"></span>
<span v-if="!isCollapse">{{ userStore.user?.name || '智教用户' }}</span>
</button>
</div>
</aside>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
defineProps<{ isCollapse: boolean }>()
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const taskNav = [
{ mode: 'courseware', label: 'AI互动课件', icon: 'MagicStick' },
{ mode: 'animation', label: '教学动画', icon: 'Film' },
{ mode: 'exam', label: 'AI命题', icon: 'Finished' },
{ mode: 'exercise', label: 'AI组题', icon: 'List' },
{ mode: 'lesson', label: 'AI教案 · 大单元', icon: 'Tickets' },
{ mode: 'essay', label: '作文批改', icon: 'EditPen' },
{ mode: 'mindmap', label: 'AI思维导图', icon: 'Connection' },
{ mode: 'assistant', label: 'AI教学助手', icon: 'ChatLineRound' },
{ mode: 'analytics', label: '数据回收', icon: 'DataLine' },
]
const baseWorkspaceNav = [
{ path: '/courseware', label: '我的作品', icon: 'Collection' },
{ path: '/favorites', label: '我的收藏', icon: 'Star' },
{ path: '/materials', label: '我的素材库', icon: 'FolderOpened' },
{ path: '/resources', label: '资源广场', icon: 'Share' },
{ path: '/classroom', label: '课堂工具', icon: 'Monitor' },
{ path: '/community', label: '模板社区', icon: 'ChatDotRound' },
{ path: '/help', label: '使用帮助', icon: 'QuestionFilled' },
]
const workspaceNav = computed(() => {
const isAdmin = userStore.user?.role === 'admin'
return isAdmin ? [...baseWorkspaceNav, { path: '/admin', label: '管理后台', icon: 'Setting' }] : baseWorkspaceNav
})
const pointsText = computed(() => {
const raw = userStore.user?.credits ?? userStore.user?.points ?? userStore.user?.quota
return `剩余积分: ${raw ?? 130}`
})
onMounted(() => {
if (localStorage.getItem('access_token') && !userStore.user) {
userStore.fetchProfile().catch(() => undefined)
}
})
function isModeActive(mode: string) {
const pathMode: Record<string, string> = {
'/courseware/create': 'courseware',
'/animation': 'animation',
'/exam': 'exam',
'/exercise': 'exercise',
'/lesson-plan': 'lesson',
'/essay-grade': 'essay',
'/mindmap': 'mindmap',
'/assistant': 'assistant',
'/classroom': 'analytics',
}
if (pathMode[route.path]) return pathMode[route.path] === mode
if (route.path !== '/') return false
return (route.query.mode || 'courseware') === mode
}
function goCreate(mode: string) {
if (mode === 'animation') {
router.push('/animation')
return
}
if (mode === 'analytics') {
router.push('/classroom')
return
}
if (mode === 'essay') {
router.push('/essay-grade')
return
}
if (mode === 'mindmap') {
router.push('/mindmap')
return
}
if (mode === 'assistant') {
router.push('/assistant')
return
}
router.push({ path: '/', query: { mode } })
}
function handleUserClick() {
router.push(userStore.user ? '/profile' : '/login')
}
</script>
<style scoped>
.zj-sidebar {
height: 100%;
display: flex;
flex-direction: column;
padding: 15px 12px 24px;
background: #edf5ed;
color: #183f35;
overflow-x: hidden;
overflow-y: auto;
}
.brand-row {
height: 34px;
display: flex;
align-items: center;
margin-bottom: 11px;
}
.brand {
display: inline-flex;
align-items: flex-start;
gap: 2px;
color: #0f4f3e;
text-decoration: none;
}
.brand strong {
font-size: 22px;
line-height: 1;
font-weight: 900;
}
.brand sup {
color: #345b50;
font-size: 9px;
line-height: 1;
}
.compact .brand {
width: 100%;
justify-content: center;
}
.compact .brand strong {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 9px;
background: #d9f0d6;
}
.new-task {
height: 40px;
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid #a8cfab;
border-radius: 8px;
background: #d9f0d6;
color: #0f4f3e;
font-size: 14px;
font-weight: 800;
cursor: pointer;
margin: 10px 0 10px;
}
.nav-list {
display: grid;
gap: 4px;
}
.secondary-nav {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #d6e4d8;
}
.nav-item {
height: 40px;
display: flex;
align-items: center;
gap: 10px;
border: none;
border-radius: 7px;
background: transparent;
color: #173f35;
padding: 0 16px;
font-size: 14px;
font-weight: 800;
cursor: pointer;
text-align: left;
}
.compact .nav-item {
justify-content: center;
padding: 0;
}
.nav-item:hover,
.nav-item.active {
background: #fff;
color: #00724f;
}
.points-banner {
height: 39px;
display: flex;
align-items: center;
gap: 8px;
border: 1px solid rgba(38, 206, 85, .5);
border-radius: 7px;
background:
radial-gradient(circle at 23% 52%, rgba(145, 245, 121, .6), transparent 24%),
linear-gradient(100deg, #13cb3c, #003b24 78%);
color: #dfffbc;
padding: 0 25px;
margin-top: 22px;
cursor: pointer;
box-shadow: inset 0 0 13px rgba(255,255,255,.22);
}
.medal {
width: 23px;
height: 23px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #fff;
color: #00a845;
flex-shrink: 0;
}
.points-banner strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
}
.sidebar-bottom {
margin-top: auto;
display: grid;
gap: 14px;
}
.score-pill,
.user-entry {
min-width: 0;
height: 29px;
display: inline-flex;
align-items: center;
gap: 7px;
border: none;
background: transparent;
color: #173f35;
cursor: pointer;
}
.score-pill {
width: fit-content;
border: 1px solid #b9d8c1;
border-radius: 6px;
background: #f5fbf5;
padding: 0 10px;
color: #0f4f3e;
font-size: 13px;
}
.compact .score-pill {
width: 100%;
justify-content: center;
}
.user-entry {
font-size: 14px;
font-weight: 700;
}
.compact .user-entry {
justify-content: center;
}
.avatar {
width: 30px;
height: 30px;
flex: 0 0 30px;
border-radius: 8px;
background:
linear-gradient(135deg, rgba(78, 116, 255, .65), rgba(24, 68, 57, .6)),
url('/favicon.svg') center/20px 20px no-repeat,
#26446a;
}
</style>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+165
View File
@@ -0,0 +1,165 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import {
ArrowLeft,
ArrowRight,
ChatDotRound,
Check,
Collection,
CopyDocument,
DataLine,
Delete,
Download,
Document,
Edit,
EditPen,
Expand,
Film,
Finished,
Flag,
Fold,
FolderOpened,
FullScreen,
HomeFilled,
InfoFilled,
Iphone,
List,
Lock,
MagicStick,
Medal,
Microphone,
Monitor,
MoreFilled,
Paperclip,
Camera,
Picture,
Plus,
PriceTag,
Refresh,
Search,
Share,
Star,
Sunrise,
Tickets,
Top,
Trophy,
Upload,
UploadFilled,
User,
VideoPlay,
View,
Warning,
Aim,
Clock,
Cloudy,
Coin,
Connection,
Cpu,
Grid,
Lightning,
Moon,
RefreshRight,
Sort,
Sunny,
TrendCharts,
TrophyBase,
ChatLineRound,
Setting,
PieChart,
Brush,
Histogram,
Key,
Link,
Position,
Promotion,
Timer,
CircleCheck,
} from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import './styles/global.css'
const app = createApp(App)
const icons = {
ArrowLeft,
ArrowRight,
ChatDotRound,
Check,
Collection,
CopyDocument,
DataLine,
Delete,
Download,
Document,
Edit,
EditPen,
Expand,
Film,
Finished,
Flag,
Fold,
FolderOpened,
FullScreen,
HomeFilled,
InfoFilled,
Iphone,
List,
Lock,
MagicStick,
Medal,
Microphone,
Monitor,
MoreFilled,
Paperclip,
Camera,
Picture,
Plus,
PriceTag,
Refresh,
Search,
Share,
Star,
Sunrise,
Tickets,
Top,
Trophy,
Upload,
UploadFilled,
User,
VideoPlay,
View,
Warning,
Aim,
Clock,
Cloudy,
Coin,
Connection,
Cpu,
Grid,
Lightning,
Moon,
RefreshRight,
Sort,
Sunny,
TrendCharts,
TrophyBase,
ChatLineRound,
Setting,
PieChart,
Brush,
Histogram,
Key,
Link,
Position,
Promotion,
Timer,
CircleCheck,
}
for (const [name, component] of Object.entries(icons)) {
app.component(name, component)
}
app.use(createPinia())
app.use(router)
+99
View File
@@ -0,0 +1,99 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { title: '登录' },
},
{
path: '/preview/:id?',
name: 'CoursewarePreview',
component: () => import('@/views/CoursewarePreview.vue'),
meta: { title: '课件预览' },
},
{
path: '/preview/share/:token',
name: 'CoursewareSharedPreview',
component: () => import('@/views/CoursewarePreview.vue'),
meta: { title: '课件分享', public: true },
},
{
path: '/demo/:id',
name: 'DemoResource',
component: () => import('@/views/DemoResource.vue'),
meta: { title: '资源演示', public: true },
},
{
path: '/classroom/join/:id',
name: 'ClassroomJoin',
component: () => import('@/views/ClassroomJoin.vue'),
meta: { title: '课堂数据回收', public: true },
},
{
path: '/classroom/join',
name: 'ClassroomJoinCode',
component: () => import('@/views/ClassroomJoin.vue'),
meta: { title: '课堂数据回收', public: true },
},
{
path: '/',
component: () => import('@/components/layout/AppLayout.vue'),
children: [
{ path: '', name: 'Home', component: () => import('@/views/Home.vue'), meta: { title: '首页', public: true } },
{ path: 'favorites', name: 'Favorites', component: () => import('@/views/Favorites.vue'), meta: { title: '我的收藏' } },
{ path: 'courseware', name: 'CoursewareList', component: () => import('@/views/CoursewareList.vue'), meta: { title: '我的作品' } },
{ path: 'profile', name: 'Profile', component: () => import('@/views/Profile.vue'), meta: { title: '个人资料' } },
{ path: 'materials', name: 'Materials', component: () => import('@/views/Materials.vue'), meta: { title: '我的素材库' } },
{ path: 'search', name: 'Search', component: () => import('@/views/Search.vue'), meta: { title: '全局搜索', public: true } },
{ path: 'courseware/create', name: 'CoursewareCreate', component: () => import('@/views/CoursewareCreate.vue'), meta: { title: 'AI互动课件' } },
{ path: 'courseware/:id', name: 'CoursewareDetail', component: () => import('@/views/CoursewareDetail.vue'), meta: { title: '课件详情' } },
{ path: 'animation', name: 'Animation', component: () => import('@/views/Animation.vue'), meta: { title: '教学动画' } },
{ path: 'exercise', name: 'Exercise', component: () => import('@/views/Exercise.vue'), meta: { title: 'AI组题' } },
{ path: 'lesson-plan', name: 'LessonPlan', component: () => import('@/views/LessonPlan.vue'), meta: { title: 'AI教案 · 大单元' } },
{ path: 'essay-grade', name: 'EssayGrade', component: () => import('@/views/EssayGrade.vue'), meta: { title: '作文批改' } },
{ path: 'mindmap', name: 'MindMap', component: () => import('@/views/MindMap.vue'), meta: { title: 'AI思维导图' } },
{ path: 'assistant', name: 'Assistant', component: () => import('@/views/Assistant.vue'), meta: { title: 'AI教学助手' } },
{ path: 'exam', name: 'Exam', component: () => import('@/views/Exam.vue'), meta: { title: 'AI命题' } },
{ path: 'classroom', name: 'Classroom', component: () => import('@/views/Classroom.vue'), meta: { title: '课堂工具' } },
{ path: 'resources', name: 'Resources', component: () => import('@/views/Resources.vue'), meta: { title: '资源广场', public: true } },
{ path: 'resources/:id', name: 'ResourceDetail', component: () => import('@/views/ResourceDetail.vue'), meta: { title: '资源详情', public: true } },
{ path: 'community', name: 'Community', component: () => import('@/views/Community.vue'), meta: { title: '模板社区', public: true } },
{ path: 'community/:id', name: 'CommunityDetail', component: () => import('@/views/CommunityDetail.vue'), meta: { title: '模板详情', public: true } },
{ path: 'help', name: 'Help', component: () => import('@/views/Help.vue'), meta: { title: '使用帮助', public: true } },
{ path: 'admin', name: 'Admin', component: () => import('@/views/Admin.vue'), meta: { title: '管理后台' } },
{ path: ':pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/NotFound.vue'), meta: { title: '页面未找到', public: true } },
],
},
{
path: '/:pathMatch(.*)*',
name: 'RootNotFound',
component: () => import('@/views/NotFound.vue'),
meta: { title: '页面未找到', public: true },
},
],
})
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('access_token')
const publicPaths = ['/login', '/preview']
const isPublic = Boolean(to.meta.public) || publicPaths.some(p => to.path.startsWith(p))
document.title = `${to.meta.title || '智教助手'} - 智教助手`
if (!isPublic && !token) {
next({ path: '/login', query: { redirect: to.fullPath } })
return
}
if (to.path === '/login' && token) {
next('/')
return
}
next()
})
export default router
+71
View File
@@ -0,0 +1,71 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getProfile } from '@/api'
export const useUserStore = defineStore('user', () => {
const user = ref<any>(null)
const token = ref(localStorage.getItem('access_token') || '')
function setToken(newToken: string) {
if (!newToken) return
token.value = newToken
localStorage.setItem('access_token', newToken)
}
function setTokens(accessToken: string, refreshToken?: string) {
if (!accessToken) return
token.value = accessToken
localStorage.setItem('access_token', accessToken)
if (refreshToken) {
localStorage.setItem('refresh_token', refreshToken)
}
}
function getRefreshToken(): string {
return localStorage.getItem('refresh_token') || ''
}
function clearToken() {
token.value = ''
user.value = null
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
}
async function fetchProfile() {
if (!token.value && localStorage.getItem('access_token')) {
token.value = localStorage.getItem('access_token') || ''
}
if (!token.value) {
user.value = null
return null
}
try {
user.value = await getProfile()
return user.value
} catch (error) {
clearToken()
throw error
}
}
async function refreshCreditsFromResponse(response: any) {
const credits = response?.credits
const balance = typeof credits?.balance === 'number' ? credits.balance : undefined
if (typeof balance !== 'number') return
if (!user.value) {
await fetchProfile().catch(() => undefined)
}
user.value = {
...(user.value || {}),
credits: balance,
total_credits_used: typeof credits?.cost === 'number'
? Number(user.value?.total_credits_used || 0) + credits.cost
: user.value?.total_credits_used,
}
}
const isLoggedIn = () => !!token.value
return { user, token, setToken, setTokens, getRefreshToken, clearToken, fetchProfile, refreshCreditsFromResponse, isLoggedIn }
})
+403
View File
@@ -0,0 +1,403 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #app {
height: 100%;
font-family: 'PingFang SC', -apple-system, BlinkMacSystemFont, 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
:root {
--primary: #00543D;
--primary-light: #98D997;
--primary-dark: #004532;
--primary-bg: #E8F5EA;
--accent: #2563EB;
--warning: #F59E0B;
--ink: #14213D;
--gradient: linear-gradient(135deg, #0F766E 0%, #14B8A6 52%, #2DD4BF 100%);
--gradient-blue: linear-gradient(135deg, #2563EB 0%, #06B6D4 100%);
--gradient-green: linear-gradient(135deg, #0F766E 0%, #22C55E 100%);
--gradient-orange: linear-gradient(135deg, #F59E0B 0%, #F97316 100%);
--gradient-red: linear-gradient(135deg, #DC2626 0%, #FB7185 100%);
--gradient-pink: linear-gradient(135deg, #DB2777 0%, #F472B6 100%);
--gradient-cyan: linear-gradient(135deg, #0891B2 0%, #22D3EE 100%);
--text-primary: #111827;
--text-secondary: #4B5563;
--text-muted: #8A94A6;
--border-color: #DCE8DE;
--bg-color: #F7FAF6;
--bg-white: #FFFFFF;
--sidebar-width: 252px;
--sidebar-collapsed: 82px;
--header-height: 58px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 12px;
--radius-xl: 16px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.04);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.06);
}
.page-container {
width: 100%;
max-width: 1480px;
margin: 0 auto;
padding: 22px 32px 28px;
background: var(--bg-color);
min-height: calc(100vh - var(--header-height));
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 28px;
}
.page-header h2 {
font-size: 25px;
font-weight: 900;
color: var(--primary);
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
}
.gen-input-section,
.guide-card,
.question-card,
.phase-content,
.resource-card,
.post-card {
border-radius: 14px !important;
border-color: #dce8de !important;
box-shadow: none !important;
}
.guide-cards {
display: grid !important;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)) !important;
gap: 12px !important;
}
.settings-row,
.settings-grid,
.filter-bar {
flex-wrap: wrap;
}
.settings-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)) !important;
}
.gen-hero {
max-width: 1120px;
margin: 0 auto 18px !important;
align-items: center !important;
}
.gen-hero-icon {
width: 42px !important;
height: 42px !important;
border-radius: 50% !important;
background: #fff !important;
border: 1px solid #d6e4d8 !important;
color: var(--primary) !important;
}
.gen-hero-icon .el-icon,
.gen-hero-icon svg {
color: var(--primary) !important;
}
.gen-hero h2 {
font-size: 25px !important;
color: var(--primary) !important;
font-weight: 900 !important;
}
.gen-hero p {
color: #75867f !important;
font-size: 14px !important;
}
.gen-input-section {
max-width: 1120px;
margin: 0 auto 22px !important;
background: rgba(255,255,255,0.86) !important;
border: 1px solid #cfe0d3 !important;
border-radius: 18px !important;
box-shadow: 0 18px 42px rgba(17, 69, 52, 0.07) !important;
}
.prompt-box :deep(.el-textarea__inner),
.input-area :deep(.el-textarea__inner),
.essay-input :deep(.el-textarea__inner),
.setting-full :deep(.el-textarea__inner) {
background: #fff !important;
border: 1px solid #dce8de !important;
border-radius: 14px !important;
color: #173f35 !important;
}
.prompt-tag,
.sug-tag,
.type-check,
.hot-tag {
border-radius: 18px !important;
background: #fff !important;
border: 1px solid #dce8de !important;
color: #51665f !important;
}
.prompt-tag:hover,
.sug-tag:hover,
.type-check.checked,
.hot-tag:hover {
background: #e8f5ea !important;
border-color: #a8cfab !important;
color: var(--primary) !important;
}
.gen-btn,
.generate-btn,
.create-btn,
.upload-btn,
.grade-btn,
.save-btn,
.action-btn.primary {
background: #00543d !important;
border-radius: 13px !important;
box-shadow: none !important;
}
.guide-section,
.result-section,
.essay-input-panel,
.essay-result-panel,
.info-tags,
.homework-section,
.filter-bar,
.community-layout,
.content-area {
max-width: 1120px;
margin-left: auto;
margin-right: auto;
}
.guide-card,
.guide-item,
.question-card,
.phase-content,
.resource-card,
.post-card,
.cw-card,
.input-card,
.result-panel,
.essay-input-panel,
.essay-result-panel,
.hot-tags-card {
border: 1px solid #dce8de !important;
border-radius: 14px !important;
box-shadow: none !important;
}
.cw-cover,
.template-cover,
.card-thumb {
border-radius: 10px;
margin: 5px;
}
.el-input__wrapper,
.el-textarea__inner,
.el-select__wrapper {
box-shadow: none !important;
border: 1px solid #dce8de !important;
background: #fff !important;
}
@media (max-width: 760px) {
.page-container {
padding: 14px;
}
.page-header {
align-items: flex-start;
flex-direction: column;
margin-bottom: 18px;
}
.prompt-footer,
.result-header,
.header-actions {
align-items: stretch !important;
flex-direction: column !important;
}
}
/* Element Plus overrides */
.el-card {
border-radius: var(--radius-lg) !important;
border: 1px solid var(--border-color) !important;
box-shadow: var(--shadow-sm) !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
}
.el-card:hover {
box-shadow: var(--shadow-lg) !important;
transform: translateY(-2px);
}
.el-button--primary {
background: var(--gradient) !important;
border: none !important;
border-radius: var(--radius-sm) !important;
font-weight: 500 !important;
}
.el-button--primary:hover {
opacity: 0.9;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.4) !important;
}
.el-input__wrapper,
.el-textarea__inner,
.el-select__wrapper {
border-radius: var(--radius-sm) !important;
}
.el-form-item__label {
font-weight: 500 !important;
color: var(--text-secondary) !important;
}
/* Dialog */
.el-dialog {
border-radius: var(--radius-lg) !important;
overflow: hidden;
}
.el-dialog__header {
border-bottom: 1px solid var(--border-color);
padding: 20px 24px !important;
margin-right: 0 !important;
}
.el-dialog__title {
font-weight: 600 !important;
font-size: 17px !important;
color: var(--text-primary) !important;
}
.el-dialog__body {
padding: 24px !important;
}
.el-dialog__footer {
border-top: 1px solid var(--border-color);
padding: 16px 24px !important;
}
/* Tag */
.el-tag {
border-radius: 6px !important;
}
/* Dropdown */
.el-dropdown-menu__item {
border-radius: 6px !important;
margin: 2px 6px !important;
padding: 8px 16px !important;
}
/* Message */
.el-message {
border-radius: var(--radius-sm) !important;
box-shadow: var(--shadow-lg) !important;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #CBD5E1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #94A3B8;
}
/* Skeleton loading */
.skeleton {
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: var(--radius-sm);
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Animations */
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideInLeft {
from { opacity: 0; transform: translateX(-20px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-20px); }
}
@keyframes floatSlow {
0%, 100% { transform: translateY(0px) rotate(0deg); }
50% { transform: translateY(-15px) rotate(3deg); }
}
@keyframes pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Utility classes */
.fade-in-up {
animation: fadeInUp 0.5s ease both;
}
/* Selection */
::selection {
background: var(--primary-bg);
color: var(--primary);
}
File diff suppressed because it is too large Load Diff
+131
View File
@@ -0,0 +1,131 @@
// 课件主题模板 —— 通过注入 CSS 变量与背景为每一页换肤
// 每个主题返回一段可插入到页面 HTML 开头的 <style> 片段
// 换肤 = 移除旧主题 style + 插入新主题 style
export interface CoursewareTheme {
id: string
name: string
description: string
swatch: string // CSS background for preview chip
accent: string // accent color for UI
// 返回注入到页面 HTML 开头的 <style> 片段
styleBlock: () => string
}
const THEME_MARKER = '/* @courseware-theme:'
const THEME_END = ' */'
function themeOpen(id: string) {
return THEME_MARKER + id + THEME_END
}
// 检测页面是否已经是完整 HTML 文档(含 <html>
function isFullDoc(html: string): boolean {
return /<html[s>]/i.test(html)
}
// 移除已存在的主题 style 块(以 THEME_MARKER 开始的 <style>...</style>
function stripTheme(html: string): string {
const re = new RegExp('<style>[\\s\\S]*?' + THEME_MARKER.replace(/[/*]/g, '\\$&') + '[\\s\\S]*?<\\/style>\\s*', 'i')
let out = html.replace(re, '')
// 二次清理:可能存在仅有标记无 style 包裹的残留
const re2 = new RegExp('<!--' + THEME_MARKER.replace(/[/*]/g, '\\$&') + '[\\s\\S]*?-->\\s*', 'i')
out = out.replace(re2, '')
return out
}
/** 应用主题到单页 HTML */
export function applyThemeToPage(html: string, theme: CoursewareTheme | null): string {
let base = stripTheme(html || '')
if (!theme) return base
const marker = '<!-- ' + themeOpen(theme.id) + ' -->'
if (isFullDoc(base)) {
// 完整文档:在 </head> 前注入
if (/<\/head>/i.test(base)) {
return base.replace(/<\/head>/i, '<style>' + theme.styleBlock() + '</style></head>')
}
return base
}
// 片段:直接前置
return marker + '<style>' + theme.styleBlock() + '</style>' + base
}
/** 检测页面当前主题 id */
export function detectPageTheme(html: string): string | null {
if (!html) return null
const m = html.match(new RegExp(THEME_MARKER.replace(/[/*]/g, '\\$&') + '(\\w+)'))
return m ? m[1] : null
}
export const coursewareThemes: CoursewareTheme[] = [
{
id: 'clean',
name: '简洁明亮',
description: '干净的白色背景,深色正文',
swatch: 'linear-gradient(135deg,#ffffff,#f8fafc)',
accent: '#4f46e5',
styleBlock: () => `:root{--tk-bg:#ffffff;--tk-fg:#1f2937;--tk-muted:#6b7280;--tk-accent:#4f46e5;}
html,body{background:var(--tk-bg)!important;color:var(--tk-fg)!important;}
body *{color:inherit;}
h1,h2,h3,h4{color:var(--tk-fg)!important;}`,
},
{
id: 'warm',
name: '暖橙课堂',
description: '暖色调,适合低年级与人文课堂',
swatch: 'linear-gradient(135deg,#fff7ed,#fed7aa)',
accent: '#ea580c',
styleBlock: () => `:root{--tk-bg:#fff7ed;--tk-fg:#7c2d12;--tk-muted:#9a3412;--tk-accent:#ea580c;}
html,body{background:var(--tk-bg)!important;color:var(--tk-fg)!important;}
body{background-image:radial-gradient(circle at 90% 10%,rgba(251,191,36,0.15),transparent 40%)!important;}
h1,h2,h3,h4{color:var(--tk-accent)!important;}`,
},
{
id: 'ocean',
name: '海洋蓝',
description: '冷静的蓝色系,适合理科与综合课程',
swatch: 'linear-gradient(135deg,#eff6ff,#bfdbfe)',
accent: '#2563eb',
styleBlock: () => `:root{--tk-bg:#eff6ff;--tk-fg:#1e3a8a;--tk-muted:#1e40af;--tk-accent:#2563eb;}
html,body{background:var(--tk-bg)!important;color:var(--tk-fg)!important;}
body{background-image:linear-gradient(135deg,rgba(59,130,246,0.08),rgba(14,165,233,0.05))!important;}
h1,h2,h3,h4{color:var(--tk-accent)!important;}`,
},
{
id: 'forest',
name: '森林绿',
description: '自然绿色调,适合生物/地理/环保主题',
swatch: 'linear-gradient(135deg,#f0fdf4,#bbf7d0)',
accent: '#059669',
styleBlock: () => `:root{--tk-bg:#f0fdf4;--tk-fg:#14532d;--tk-muted:#166534;--tk-accent:#059669;}
html,body{background:var(--tk-bg)!important;color:var(--tk-fg)!important;}
body{background-image:radial-gradient(circle at 10% 90%,rgba(34,197,94,0.12),transparent 40%)!important;}
h1,h2,h3,h4{color:var(--tk-accent)!important;}`,
},
{
id: 'dark',
name: '深夜模式',
description: '深色背景,高对比投影展示',
swatch: 'linear-gradient(135deg,#1e293b,#0f172a)',
accent: '#a78bfa',
styleBlock: () => `:root{--tk-bg:#0f172a;--tk-fg:#f1f5f9;--tk-muted:#cbd5e1;--tk-accent:#a78bfa;}
html,body{background:var(--tk-bg)!important;color:var(--tk-fg)!important;}
body{background-image:radial-gradient(circle at 80% 20%,rgba(167,139,250,0.15),transparent 50%)!important;}
h1,h2,h3,h4{color:var(--tk-accent)!important;}`,
},
{
id: 'sunset',
name: '日落渐变',
description: '粉紫渐变,适合艺术与展示型课件',
swatch: 'linear-gradient(135deg,#fdf2f8,#fbcfe8)',
accent: '#db2777',
styleBlock: () => `:root{--tk-bg:#fdf2f8;--tk-fg:#831843;--tk-muted:#9d174d;--tk-accent:#db2777;}
html,body{background:var(--tk-bg)!important;color:var(--tk-fg)!important;}
body{background-image:linear-gradient(135deg,rgba(244,114,182,0.18),rgba(168,85,247,0.1))!important;}
h1,h2,h3,h4{color:var(--tk-accent)!important;}`,
},
]
export function getTheme(id: string): CoursewareTheme | undefined {
return coursewareThemes.find((t) => t.id === id)
}
+387
View File
@@ -0,0 +1,387 @@
import { createAnimation } from '@/api/animation'
import { createCourseware } from '@/api/courseware'
import { createExam } from '@/api/exam'
import { createExercise } from '@/api/exercise'
import { createLessonPlan } from '@/api/lessonPlan'
type DemoDraftType = 'courseware' | 'animation' | 'exercise' | 'lesson_plan' | 'exam'
type DemoDraftSpec = {
type: DemoDraftType
title: string
subject: string
grade: string
description: string
tags: string[]
payload: () => any
}
export type DemoDraftResult = {
type: DemoDraftType
draft: any
openPath: string
}
const aliases: Record<string, string> = {
'demo-cyl': 'cylinder',
cylinder: 'cylinder',
'demo-guide': 'guide',
guide: 'guide',
'demo-tips': 'tips',
tips: 'tips',
'demo-data': 'data',
data: 'data',
'demo-food': 'food',
food: 'food',
'english-food': 'food',
'demo-case': 'case',
case: 'case',
'demo-creator': 'creator',
creator: 'creator',
'demo-ai': 'vision',
vision: 'vision',
'demo-exam': 'exam',
exam: 'exam',
}
const specs: Record<string, DemoDraftSpec> = {
cylinder: {
type: 'courseware',
title: '小学数学六年级圆柱的体积(示例改编)',
subject: '数学',
grade: '六年级下册',
description: '围绕圆柱体积公式推导组织课堂,包含旧知回顾、操作探究、公式推导、巩固练习和拓展应用。',
tags: ['六年级', '圆柱体积', '公式推导'],
payload: () => coursewarePayload('小学数学六年级圆柱的体积(示例改编)', '数学', '六年级下册', [
['封面', '小学数学六年级圆柱的体积', '从已有体积经验出发,理解圆柱体积公式。', 'title'],
['知识回顾', '长方体、正方体体积公式', '复习 V = 底面积 x 高,为转化思想做准备。', 'content'],
['操作探究', '把圆柱切拼成近似长方体', '观察底面积不变、高不变,体积也不变。', 'interaction'],
['公式推导', 'V = S x h', '用底面积乘高计算圆柱体积,并解释每个量的意义。', 'summary'],
['巩固练习', '分层计算与应用', '完成基础计算、逆向求高和生活情境题。', 'exercise'],
], ['六年级', '圆柱体积', '公式推导']),
},
guide: {
type: 'courseware',
title: '智教助手新手指引(示例改编)',
subject: '课堂工具',
grade: '通用',
description: '展示从输入需求、上传材料到生成、预览、改编和发布的完整创作流程。',
tags: ['快速入门', '生成课件', '发布资源'],
payload: () => coursewarePayload('智教助手新手指引(示例改编)', '课堂工具', '通用', [
['封面', '智教助手新手指引', '用一个主题完成从生成到发布的完整流程。', 'title'],
['输入教学需求', '描述课题、学段和课堂目标', '把教材版本、知识点、互动要求写清楚。', 'content'],
['选择生成类型', '课件、动画、练习、教案、试卷', '按课堂任务选择最合适的 AI 工具。', 'interaction'],
['预览与微调', '检查页面结构与教学节奏', '根据课堂实际调整标题、图片、互动和练习。', 'content'],
['发布与改编', '沉淀为可复用资源', '发布到资源广场后支持收藏、下载和二次改编。', 'summary'],
], ['快速入门', '生成课件', '发布资源']),
},
tips: {
type: 'courseware',
title: '玩转互动课件必看小技巧(示例改编)',
subject: '课堂工具',
grade: '通用',
description: '整理课件编辑、图片替换、动画插入和课堂展示的高频技巧。',
tags: ['互动课件', '图片替换', '课堂展示'],
payload: () => coursewarePayload('玩转互动课件必看小技巧(示例改编)', '课堂工具', '通用', [
['封面', '互动课件小技巧', '快速掌握高频编辑与课堂展示方法。', 'title'],
['替换图片素材', '选中图片后替换或调整大小', '保持图片和文字的教学信息一致。', 'content'],
['插入动画片段', '把动画放到关键解释处', '用动画承接抽象概念和课堂提问。', 'interaction'],
['复用优秀结构', '从热门资源改编为原创课件', '保留结构,替换主题、例题和教师引导语。', 'summary'],
], ['互动课件', '图片替换', '课堂展示']),
},
data: {
type: 'animation',
title: '课堂数据回收新手指引(示例改编)',
subject: '课堂工具',
grade: '通用',
description: '演示问答收集、投票统计和学生反馈汇总流程。',
tags: ['数据回收', '问答收集', '课堂反馈'],
payload: () => ({
title: '课堂数据回收新手指引(示例改编)',
anim_type: 'general',
description: '演示问答收集、投票统计和学生反馈汇总流程。',
config: {
title: '课堂数据回收新手指引',
description: '从创建任务到统计反馈的课堂闭环。',
html: animationHtml('课堂数据回收', ['创建收集任务', '学生扫码提交', '实时统计分布', '生成复盘建议'], '#063a2d', '#0f8a5f'),
scenes: [
{ id: 1, name: '创建任务', duration: 8, narration: '选择问答、投票或打卡模板。' },
{ id: 2, name: '学生提交', duration: 10, narration: '学生扫码后实时提交答案。' },
{ id: 3, name: '数据统计', duration: 12, narration: '教师端显示选项分布和关键词。' },
{ id: 4, name: '复盘建议', duration: 8, narration: '根据数据生成讲评重点。' },
],
totalDuration: 38,
interactive: true,
},
}),
},
food: {
type: 'exercise',
title: 'Unit4 Healthy Food 第一课时(示例改编)',
subject: '英语',
grade: '七年级',
description: '围绕健康饮食主题组织单词认读、句型表达、听说操练和闯关反馈。',
tags: ['Healthy Food', '词汇认读', '句型操练'],
payload: () => ({
title: 'Unit4 Healthy Food 第一课时(示例改编)',
exercise_type: 'choice',
subject: '英语',
knowledge_points: ['Healthy Food', 'food vocabulary', 'would like'],
questions: [
question('Which one is a healthy drink?', ['Cola', 'Water', 'Candy', 'Cake'], 'Water', 'Water is a healthy drink.'),
question('What would you like?', ['I like play.', 'I would like an apple.', 'I am seven.', 'It is blue.'], 'I would like an apple.', 'Use “would like” to express what you want.'),
question('Choose the fruit.', ['Carrot', 'Milk', 'Banana', 'Bread'], 'Banana', 'Banana is a fruit.'),
question('Which food should we eat more often?', ['Vegetables', 'Chips', 'Candy', 'Ice cream'], 'Vegetables', 'Vegetables are healthier for daily meals.'),
],
settings: { show_analysis: true, shuffle: false, mode: 'class_game' },
}),
},
case: {
type: 'lesson_plan',
title: '智教助手经典案例解析合集(示例改编)',
subject: '课堂工具',
grade: '通用',
description: '拆解优秀课件的结构、互动设计和课堂使用场景,适合备课复盘。',
tags: ['优秀案例', '结构拆解', '教学设计'],
payload: () => lessonPlanPayload('智教助手经典案例解析合集(示例改编)', '课堂工具', '通用', [
['案例导入', 8, ['展示优秀互动课件封面和学习目标。'], ['引导教师观察结构。'], ['记录课件亮点。']],
['结构拆解', 18, ['拆分封面、导入、探究、练习、总结五类页面。'], ['讲解每类页面的作用。'], ['对照自己的课题迁移。']],
['互动分析', 14, ['分析点击、拖拽、投票和即时反馈的使用位置。'], ['指出互动与目标的关系。'], ['判断互动是否服务学习。']],
['改编实践', 15, ['选择一个课题完成原创改编方案。'], ['提供改编检查表。'], ['完成小组互评。']],
]),
},
creator: {
type: 'courseware',
title: 'AI好课晒一晒创作者激励活动(示例改编)',
subject: '课堂工具',
grade: '通用',
description: '沉淀教师作品、活动说明和优秀模板推荐,支持发布后持续改编。',
tags: ['发布作品', '资源复用', '教学案例'],
payload: () => coursewarePayload('AI好课晒一晒创作者激励活动(示例改编)', '课堂工具', '通用', [
['封面', 'AI好课晒一晒', '把好课发布出来,让更多老师复用和改编。', 'title'],
['活动目标', '沉淀优秀课堂资源', '鼓励教师发布可预览、可改编、可复用的教学作品。', 'content'],
['发布流程', '保存作品后发布到资源广场', '补充标题、学科、年级、标签和使用说明。', 'interaction'],
['反馈迭代', '查看收藏、下载和改编反馈', '根据使用数据持续优化资源。', 'summary'],
], ['发布作品', '资源复用', '教学案例']),
},
vision: {
type: 'animation',
title: 'AI如何看世界:图像识别(示例改编)',
subject: '信息科技',
grade: '人工智能体验课',
description: '用流程动画解释图像识别的基本工作过程。',
tags: ['人工智能', '图像识别', '流程动画'],
payload: () => ({
title: 'AI如何看世界:图像识别(示例改编)',
anim_type: 'general',
description: '用流程动画解释图像识别的基本工作过程。',
config: {
title: 'AI如何看世界',
description: '图像输入、特征提取、分类判断和反思边界。',
html: animationHtml('图像识别流程', ['输入图像', '拆分像素', '提取特征', '分类预测', '讨论偏差'], '#080b12', '#38bdf8'),
scenes: [
{ id: 1, name: '输入图像', duration: 8, narration: '图像被拆成像素和颜色信息。' },
{ id: 2, name: '提取特征', duration: 12, narration: '模型寻找边缘、纹理和形状。' },
{ id: 3, name: '分类预测', duration: 10, narration: '输出类别和置信度。' },
{ id: 4, name: '讨论边界', duration: 8, narration: '分析 AI 可能出错的场景。' },
],
totalDuration: 38,
interactive: true,
},
}),
},
exam: {
type: 'exam',
title: '七年级数学有理数运算测试(示例改编)',
subject: '数学',
grade: '七年级',
description: '围绕有理数加减乘除组织基础检测,含答案与解析。',
tags: ['智能命题', '有理数', '基础检测'],
payload: () => ({
title: '七年级数学有理数运算测试(示例改编)',
subject: '数学',
grade: '七年级',
questions: [
examQuestion('计算:-3 + 8 = ?', ['-11', '-5', '5', '11'], '5', '异号两数相加,取绝对值较大的符号,用 8 - 3 = 5。', 5),
examQuestion('计算:(-2) x (-6) = ?', ['-12', '12', '8', '-8'], '12', '两个负数相乘结果为正。', 5),
examQuestion('下列数中最小的是?', ['-5', '-1', '0', '2'], '-5', '数轴上越靠左的数越小。', 5),
],
answers: ['5', '12', '-5'],
duration: 45,
total_score: 100,
difficulty: 'medium',
knowledge_points: ['有理数运算'],
}),
},
}
export function normalizeDemoDraftKey(input: any) {
const raw = typeof input === 'string' ? input : String(input?.id || input?.demoId || '')
return aliases[raw] || ''
}
export function isDemoDraftAvailable(input: any) {
const key = normalizeDemoDraftKey(input)
return Boolean(key && specs[key])
}
export function getDemoDraftType(input: any): DemoDraftType | '' {
const key = normalizeDemoDraftKey(input)
return key && specs[key] ? specs[key].type : ''
}
export async function createDemoDraft(input: any): Promise<DemoDraftResult> {
const key = normalizeDemoDraftKey(input)
const spec = key ? specs[key] : null
if (!spec) throw new Error('示例资源暂不支持直接改编')
const payload = spec.payload()
let draft: any
if (spec.type === 'courseware') draft = unwrap(await createCourseware(payload))
if (spec.type === 'animation') draft = unwrap(await createAnimation(payload))
if (spec.type === 'exercise') draft = unwrap(await createExercise(payload))
if (spec.type === 'lesson_plan') draft = unwrap(await createLessonPlan(payload))
if (spec.type === 'exam') draft = unwrap(await createExam(payload))
return { type: spec.type, draft, openPath: openPath(spec.type, draft.id) }
}
function unwrap(res: any) {
return res?.data || res
}
function openPath(type: DemoDraftType, id: number) {
if (type === 'courseware') return `/courseware/${id}`
if (type === 'animation') return `/animation?open=${id}`
if (type === 'exercise') return `/exercise?open=${id}`
if (type === 'lesson_plan') return `/lesson-plan?open=${id}`
return `/exam?open=${id}`
}
function coursewarePayload(title: string, subject: string, grade: string, pages: Array<[string, string, string, string]>, tags: string[]) {
return {
title,
subject,
grade,
description: `${subject}${grade ? ` · ${grade}` : ''}示例课件,可继续编辑、授课预览和发布分享。`,
content: pages.map(([pageTitle, heading, subtitle, type], index) => ({
title: pageTitle,
type,
content: slideHtml(heading, subtitle, pageTitle, index),
notes: subtitle,
})),
tags,
status: 'draft',
}
}
function lessonPlanPayload(title: string, subject: string, grade: string, phases: any[]) {
const normalizedPhases = phases.map(([name, duration, activities, teacher_actions, student_actions]) => ({
name,
duration,
activities,
teacher_actions,
student_actions,
resources: ['互动课件', '学习单'],
}))
return {
title,
subject,
grade,
objectives: ['理解优秀教学资源的结构特征', '能根据本班学情完成原创改编', '形成资源复盘与迭代意识'],
key_points: ['结构拆解', '互动设计', '改编实践'],
difficulties: ['让互动服务教学目标', '从案例迁移到真实课题'],
content: { title, phases: normalizedPhases },
duration: normalizedPhases.reduce((sum, item) => sum + Number(item.duration || 0), 0),
materials: ['案例资源', '改编检查表', '小组评价表'],
homework: ['选择一个个人课题,完成一份资源改编提纲。'],
}
}
function question(stem: string, options: string[], answer: string, explanation: string) {
return {
type: 'choice',
question: stem,
stem,
options,
answer,
explanation,
difficulty: 'medium',
html: questionHtml(stem, options),
}
}
function examQuestion(content: string, options: string[], answer: string, analysis: string, score: number) {
return {
type: 'choice',
content,
options,
answer,
analysis,
score,
}
}
function slideHtml(title: string, subtitle: string, badge: string, index: number) {
const themes = [
['#fff3c4', '#7a5600', '#f7c948'],
['#e8f7ef', '#00543d', '#8ecf9a'],
['#dbe8ff', '#003f68', '#7fb3ff'],
['#fff1a1', '#654100', '#ffd84d'],
]
const [bg, fg, accent] = themes[index % themes.length]
return `
<div style="width:100%;height:100%;box-sizing:border-box;padding:48px;background:${bg};color:${fg};font-family:Microsoft YaHei,Arial,sans-serif;position:relative;overflow:hidden;">
<div style="display:inline-flex;padding:7px 13px;border-radius:18px;background:#fff;color:${fg};font-size:13px;font-weight:900;">${escapeHtml(badge)}</div>
<h1 style="max-width:68%;margin:34px 0 18px;font-size:42px;line-height:1.12;">${escapeHtml(title)}</h1>
<p style="max-width:62%;font-size:19px;line-height:1.8;margin:0;">${escapeHtml(subtitle)}</p>
<div style="position:absolute;right:58px;top:72px;width:230px;height:230px;border-radius:28px;background:#fff;box-shadow:0 18px 42px rgba(17,69,52,.12);">
<i style="position:absolute;left:32px;top:42px;width:166px;height:22px;border-radius:11px;background:${accent};display:block;"></i>
<i style="position:absolute;left:32px;top:86px;width:126px;height:18px;border-radius:9px;background:${accent};opacity:.62;display:block;"></i>
<i style="position:absolute;left:32px;top:130px;width:150px;height:58px;border-radius:16px;background:${accent};opacity:.36;display:block;"></i>
</div>
</div>`
}
function animationHtml(title: string, steps: string[], bg: string, accent: string) {
return `
<div style="width:100%;height:100%;position:relative;overflow:hidden;background:${bg};font-family:Microsoft YaHei,Arial,sans-serif;color:#fff;">
<h1 style="position:absolute;left:42px;top:34px;margin:0;font-size:34px;">${escapeHtml(title)}</h1>
<p style="position:absolute;left:42px;top:86px;margin:0;color:rgba(255,255,255,.72);font-size:15px;">点击演示时,按流程观察课堂任务如何推进。</p>
<div style="position:absolute;left:54px;right:54px;top:182px;height:4px;background:rgba(255,255,255,.18);"></div>
${steps.map((step, index) => {
const left = 58 + index * Math.max(118, 640 / Math.max(steps.length, 1))
return `<div style="position:absolute;left:${left}px;top:138px;width:104px;text-align:center;animation:demoPulse 2.8s ease-in-out ${index * 0.22}s infinite;">
<span style="width:62px;height:62px;margin:0 auto 12px;border-radius:50%;background:${accent};display:flex;align-items:center;justify-content:center;font-weight:900;box-shadow:0 0 28px rgba(255,255,255,.2);">${index + 1}</span>
<strong style="font-size:15px;line-height:1.35;">${escapeHtml(step)}</strong>
</div>`
}).join('')}
<style>
@keyframes demoPulse {
0%,100% { transform: translateY(0); opacity: .72; }
50% { transform: translateY(-14px); opacity: 1; }
}
</style>
</div>`
}
function questionHtml(stem: string, options: string[]) {
return `
<div style="width:100%;height:100%;box-sizing:border-box;padding:40px;background:#fff8df;color:#654100;font-family:Microsoft YaHei,Arial,sans-serif;overflow:auto;">
<h1 style="font-size:30px;line-height:1.35;margin:0 0 24px;">${escapeHtml(stem)}</h1>
<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;">
${options.map((option, index) => `<div style="min-height:58px;border:1px solid #f0dca3;border-radius:14px;background:#fff;padding:16px;display:flex;gap:12px;align-items:center;">
<strong style="width:30px;height:30px;border-radius:9px;background:#f59e0b;color:#fff;display:inline-flex;align-items:center;justify-content:center;">${String.fromCharCode(65 + index)}</strong>
<span>${escapeHtml(option)}</span>
</div>`).join('')}
</div>
</div>`
}
function escapeHtml(value: string) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
+10
View File
@@ -0,0 +1,10 @@
export function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
export type MaterialContextItem = {
id: string
name: string
icon: string
typeLabel: string
sizeLabel: string
excerpt: string
}
export function formatMaterialFileSize(size: number) {
const n = Number(size || 0)
if (n < 1024) return `${n}B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`
return `${(n / 1024 / 1024).toFixed(1)}MB`
}
export function materialTypeLabel(type: string, file?: { type?: string; name?: string }) {
if (file?.type?.startsWith('image/') || type === 'image') return '图片'
const map: Record<string, string> = {
docx: 'Word材料',
pptx: 'PPT材料',
csv: '表格材料',
json: '结构化材料',
md: 'Markdown材料',
pdf: 'PDF材料',
xlsx: '表格材料',
xls: '表格材料',
txt: '文本材料',
text: '文本材料',
}
return map[type] || '教学材料'
}
export function materialToContext(item: any): MaterialContextItem {
return {
id: `library-${item.id}`,
name: item.title || item.filename || '素材',
icon: item.material_type === 'image' ? 'Picture' : 'Document',
typeLabel: materialTypeLabel(item.material_type || 'text', { type: item.material_type || '', name: item.filename || '' }),
sizeLabel: formatMaterialFileSize(item.size || 0),
excerpt: item.summary || '',
}
}
export function fileParseToContext(file: File, data: any): MaterialContextItem {
return {
id: `${Date.now()}-${file.name}`,
name: file.name,
icon: file.type.startsWith('image/') ? 'Picture' : 'Document',
typeLabel: materialTypeLabel(data.material_type, file),
sizeLabel: formatMaterialFileSize(data.size || file.size),
excerpt: data.summary || '',
}
}
export function buildMaterialPromptBlock(materials: MaterialContextItem[], title = '已选素材') {
if (!materials.length) return ''
return `\n\n【${title}\n${materials.map(item => `- ${item.name}${item.typeLabel}):${item.excerpt || '暂无摘要,请结合素材标题和类型进行创作。'}`).join('\n')}`
}
+97
View File
@@ -0,0 +1,97 @@
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
import { useUserStore } from '@/stores/user'
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '',
timeout: 300000,
})
request.interceptors.request.use(
(config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.set('Authorization', `Bearer ${token}`)
}
return config
},
(error) => Promise.reject(error),
)
// 单飞刷新:并发 401 时只发起一次刷新,其他请求等待结果
let refreshPromise: Promise<string | null> | null = null
async function doRefresh(): Promise<string | null> {
if (refreshPromise) return refreshPromise
const refreshToken = localStorage.getItem('refresh_token')
if (!refreshToken) return null
refreshPromise = (async () => {
try {
const res: any = await axios.post('/api/auth/refresh', null, { params: { refresh_token: refreshToken } })
const data = res?.data || res
if (data?.access_token) {
localStorage.setItem('access_token', data.access_token)
if (data.refresh_token) localStorage.setItem('refresh_token', data.refresh_token)
return data.access_token as string
}
return null
} catch {
return null
} finally {
refreshPromise = null
}
})()
return refreshPromise
}
request.interceptors.response.use(
(response) => response.data,
async (error: AxiosError) => {
const detail = (error.response?.data as any)?.detail
let msg = '请求失败,请稍后重试'
if (typeof detail === 'string') {
msg = detail
} else if (Array.isArray(detail)) {
msg = detail.map((e: any) => e.msg || JSON.stringify(e)).join('; ')
}
console.error('API Error:', error.response?.status, detail)
const originalRequest = error.config as InternalAxiosRequestConfig & { _retried?: boolean }
const status = error.response?.status
const isAuthError =
status === 401 ||
(status === 403 && detail === 'Not authenticated')
// 自动刷新:401 且未重试过 且不是刷新接口本身
if (status === 401 && originalRequest && !originalRequest._retried && !originalRequest.url?.includes('/auth/refresh')) {
const newToken = await doRefresh()
if (newToken) {
originalRequest._retried = true
originalRequest.headers.set('Authorization', `Bearer ${newToken}`)
return request(originalRequest)
}
// 刷新失败:清理并跳转登录
try { useUserStore().clearToken() } catch { localStorage.removeItem('access_token') }
if (router.currentRoute.value.path !== '/login') router.push('/login')
return Promise.reject(error)
}
if (isAuthError) {
try {
useUserStore().clearToken()
} catch {
localStorage.removeItem('access_token')
}
if (router.currentRoute.value.path !== '/login') {
router.push('/login')
}
} else {
ElMessage.error(msg)
}
return Promise.reject(error)
},
)
export default request
+51
View File
@@ -0,0 +1,51 @@
export type SpeechController = {
start: () => void
stop: () => void
isSupported: () => boolean
}
type SpeechOptions = {
onStart?: () => void
onText: (text: string) => void
onError?: () => void
onEnd?: () => void
}
export function createSpeechController(options: SpeechOptions): SpeechController {
let recognition: any = null
function getSpeechRecognition() {
return (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
}
function stop() {
recognition?.stop()
}
function start() {
const SpeechRecognition = getSpeechRecognition()
if (!SpeechRecognition) {
options.onError?.()
return
}
stop()
recognition = new SpeechRecognition()
recognition.lang = 'zh-CN'
recognition.interimResults = false
recognition.continuous = false
recognition.onstart = () => options.onStart?.()
recognition.onresult = (event: any) => {
const text = Array.from(event.results).map((result: any) => result[0]?.transcript || '').join('')
if (text.trim()) options.onText(text.trim())
}
recognition.onerror = () => options.onError?.()
recognition.onend = () => options.onEnd?.()
recognition.start()
}
return {
start,
stop,
isSupported: () => !!getSpeechRecognition(),
}
}
+447
View File
@@ -0,0 +1,447 @@
<template>
<div class="admin-page">
<header class="admin-head">
<div>
<h1>管理后台</h1>
<p>运营数据用户管理与资源审核</p>
</div>
<div class="tab-bar">
<button v-for="tab in tabs" :key="tab.value" type="button" :class="{ active: activeTab === tab.value }" @click="activeTab = tab.value">{{ tab.label }}</button>
</div>
</header>
<!-- Dashboard -->
<section v-if="activeTab === 'dashboard'" v-loading="loading" class="dashboard-grid">
<div class="stat-card" v-for="card in statCards" :key="card.label">
<el-icon :size="20" :color="card.color"><component :is="card.icon" /></el-icon>
<strong>{{ card.value }}</strong>
<span>{{ card.label }}</span>
</div>
</section>
<!-- Audit Logs -->
<section v-else-if="activeTab === 'audit'" class="data-section">
<div class="filter-row">
<el-input v-model="auditFilter.keyword" placeholder="搜索用户/详情/IP" clearable style="width: 240px" @keyup.enter="loadAuditLogs" />
<el-select v-model="auditFilter.action" placeholder="全部操作" clearable style="width: 130px" @change="loadAuditLogs">
<el-option v-for="o in actionOptions" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
<el-select v-model="auditFilter.status" placeholder="全部状态" clearable style="width: 120px" @change="loadAuditLogs">
<el-option v-for="o in statusOptions" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
<button class="ghost-btn" type="button" @click="loadAuditLogs">查询</button>
</div>
<el-table :data="auditLogs" v-loading="loading" stripe style="width: 100%" @row-click="() => {}">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="created_at" label="时间" width="160">
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
</el-table-column>
<el-table-column prop="username" label="用户" width="120" show-overflow-tooltip />
<el-table-column prop="action" label="操作" width="110">
<template #default="{ row }">{{ actionLabel(row.action) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 'success' ? 'success' : row.status === 'failed' ? 'warning' : 'danger'" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="ip" label="IP" width="130" show-overflow-tooltip />
<el-table-column prop="detail" label="详情" min-width="220" show-overflow-tooltip />
<el-table-column prop="target_type" label="对象" width="100" />
</el-table>
<p v-if="!auditLogs.length && !loading" class="empty-tip">暂无审计日志切换筛选或进行登录/上传操作后再查询</p>
</section>
<!-- Users -->
<section v-else-if="activeTab === 'users'" class="data-section">
<div class="filter-row">
<el-input v-model="userFilter.keyword" placeholder="搜索手机号或姓名" clearable style="width: 240px" @keyup.enter="loadUsers" />
<el-select v-model="userFilter.role" placeholder="全部角色" clearable style="width: 130px" @change="loadUsers">
<el-option label="教师" value="teacher" />
<el-option label="管理员" value="admin" />
</el-select>
<button class="ghost-btn" type="button" @click="loadUsers">查询</button>
</div>
<el-table :data="users" v-loading="loading" stripe style="width: 100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="姓名" width="100" />
<el-table-column prop="phone" label="手机号" width="130" />
<el-table-column prop="role" label="角色" width="90">
<template #default="{ row }">
<el-tag :type="row.role === 'admin' ? 'danger' : 'info'" size="small" effect="plain">{{ row.role === 'admin' ? '管理员' : '教师' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="subject" label="学科" width="80" />
<el-table-column prop="credits" label="积分" width="70" />
<el-table-column prop="is_active" label="状态" width="70">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'warning'" size="small">{{ row.is_active ? '正常' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="注册时间" width="150">
<template #default="{ row }">{{ formatDate(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="toggleUserActive(row)">{{ row.is_active ? '禁用' : '启用' }}</el-button>
<el-button size="small" @click="toggleUserRole(row)">{{ row.role === 'admin' ? '降为教师' : '升为管理员' }}</el-button>
<el-button size="small" type="warning" @click="grantCredits(row)">发积分</el-button>
</template>
</el-table-column>
</el-table>
</section>
<!-- Resources -->
<section v-else-if="activeTab === 'resources'" class="data-section">
<div class="filter-row">
<el-input v-model="resFilter.keyword" placeholder="搜索资源标题" clearable style="width: 240px" @keyup.enter="loadResources" />
<el-select v-model="resFilter.resource_type" placeholder="全部类型" clearable style="width: 130px" @change="loadResources">
<el-option label="课件" value="courseware" />
<el-option label="动画" value="animation" />
<el-option label="练习" value="exercise" />
<el-option label="教案" value="lesson_plan" />
<el-option label="其他" value="other" />
</el-select>
<el-select v-model="resFilter.status" placeholder="状态" style="width: 110px" @change="loadResources">
<el-option label="上架" value="active" />
<el-option label="已下架" value="archived" />
</el-select>
<button class="ghost-btn" type="button" @click="loadResources">查询</button>
</div>
<el-table :data="resources" v-loading="loading" stripe style="width: 100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="title" label="标题" min-width="180" show-overflow-tooltip />
<el-table-column prop="resource_type" label="类型" width="90">
<template #default="{ row }">{{ typeLabel(row.resource_type) }}</template>
</el-table-column>
<el-table-column prop="subject" label="学科" width="80" />
<el-table-column prop="views" label="浏览" width="70" />
<el-table-column prop="downloads" label="下载" width="70" />
<el-table-column prop="likes" label="收藏" width="70" />
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">{{ row.status === 'active' ? '上架' : '下架' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-popconfirm :title="row.status === 'active' ? '确定下架该资源?' : '确定恢复上架?'" @confirm="moderate(row)">
<template #reference>
<el-button size="small" :type="row.status === 'active' ? 'danger' : 'success'">{{ row.status === 'active' ? '下架' : '上架' }}</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getAdminAuditLogs, getAdminDashboard, getAdminResources, getAdminUsers, grantAdminCredits, moderateResource, updateAdminUser } from '@/api/admin'
const tabs = [
{ value: 'dashboard', label: '运营看板' },
{ value: 'users', label: '用户管理' },
{ value: 'resources', label: '资源审核' },
{ value: 'audit', label: '安全审计' },
]
const activeTab = ref('dashboard')
const loading = ref(false)
const dashboard = ref<any>({})
const users = ref<any[]>([])
const resources = ref<any[]>([])
const userFilter = reactive({ keyword: '', role: '' })
const auditLogs = ref<any[]>([])
const auditFilter = reactive({ action: '', status: '', keyword: '' })
const resFilter = reactive({ keyword: '', resource_type: '', status: 'active' })
const statCards = computed(() => [
{ label: '注册用户', value: dashboard.value.total_users ?? 0, icon: 'User', color: '#00543d' },
{ label: '今日新增', value: dashboard.value.new_users_today ?? 0, icon: 'Plus', color: '#10b981' },
{ label: '上架资源', value: dashboard.value.public_resources ?? 0, icon: 'Share', color: '#0ea5e9' },
{ label: '课件总数', value: dashboard.value.total_coursewares ?? 0, icon: 'Document', color: '#8b5cf6' },
{ label: '总浏览量', value: dashboard.value.total_views ?? 0, icon: 'View', color: '#f59e0b' },
{ label: '总下载量', value: dashboard.value.total_downloads ?? 0, icon: 'Download', color: '#ec4899' },
{ label: '收藏总数', value: dashboard.value.total_favorites ?? 0, icon: 'Star', color: '#eab308' },
{ label: '消耗积分', value: dashboard.value.total_credits_used ?? 0, icon: 'GoldMedal', color: '#ef4444' },
])
function typeLabel(type: string) {
const map: Record<string, string> = { courseware: '课件', animation: '动画', exercise: '练习', lesson_plan: '教案', exam: '试卷', other: '其他' }
return map[type] || type
}
function formatDate(value: any) {
if (!value) return ''
const d = new Date(value)
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString('zh-CN')
}
async function loadDashboard() {
loading.value = true
try {
dashboard.value = await getAdminDashboard()
} catch {
dashboard.value = {}
} finally {
loading.value = false
}
}
async function loadUsers() {
loading.value = true
try {
users.value = await getAdminUsers({ keyword: userFilter.keyword, role: userFilter.role })
} catch {
users.value = []
} finally {
loading.value = false
}
}
async function loadResources() {
loading.value = true
try {
resources.value = await getAdminResources({ keyword: resFilter.keyword, resource_type: resFilter.resource_type, status: resFilter.status })
} catch {
resources.value = []
} finally {
loading.value = false
}
}
async function toggleUserActive(row: any) {
try {
const updated: any = await updateAdminUser(row.id, { is_active: !row.is_active })
Object.assign(row, updated)
ElMessage.success(row.is_active ? '已启用' : '已禁用')
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '操作失败')
}
}
async function toggleUserRole(row: any) {
const newRole = row.role === 'admin' ? 'teacher' : 'admin'
try {
const updated: any = await updateAdminUser(row.id, { role: newRole })
Object.assign(row, updated)
ElMessage.success('角色已更新')
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '操作失败')
}
}
async function grantCredits(row: any) {
try {
const { value } = await ElMessageBox.prompt('输入要发放的积分数量(1-10000', '发放积分', {
confirmButtonText: '发放',
cancelButtonText: '取消',
inputPattern: /^\d+$/,
inputErrorMessage: '请输入正整数',
})
const amount = Number(value)
if (amount < 1 || amount > 10000) {
ElMessage.warning('数量需在 1-10000 之间')
return
}
const updated: any = await grantAdminCredits(row.id, amount)
Object.assign(row, updated)
ElMessage.success(`已发放 ${amount} 积分`)
} catch {
// cancelled
}
}
async function moderate(row: any) {
const status = row.status === 'active' ? 'archived' : 'active'
try {
await moderateResource(row.id, status)
row.status = status
ElMessage.success(status === 'active' ? '已上架' : '已下架')
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '操作失败')
}
}
async function loadAuditLogs() {
loading.value = true
try {
auditLogs.value = await getAdminAuditLogs({
action: auditFilter.action || undefined,
status: auditFilter.status || undefined,
keyword: auditFilter.keyword || undefined,
limit: 100,
})
} catch {
auditLogs.value = []
} finally {
loading.value = false
}
}
const actionOptions = [
{ value: '', label: '全部操作' },
{ value: 'login', label: '登录' },
{ value: 'register', label: '注册' },
{ value: 'material_parse', label: '材料解析' },
{ value: 'publish_resource', label: '发布资源' },
{ value: 'admin_grant_credits', label: '管理员发积分' },
{ value: 'admin_update_user', label: '管理员改用户' },
{ value: 'admin_moderate_resource', label: '管理员审核' },
]
const statusOptions = [
{ value: '', label: '全部状态' },
{ value: 'success', label: '成功' },
{ value: 'failed', label: '失败' },
{ value: 'denied', label: '拒绝' },
]
function actionLabel(a: string) {
const m: Record<string, string> = {
login: '登录', register: '注册', material_parse: '材料解析',
publish_resource: '发布资源', admin_grant_credits: '管理员发积分',
admin_update_user: '管理员改用户', admin_moderate_resource: '管理员审核',
}
return m[a] || a || '-'
}
function statusLabel(s: string) {
const m: Record<string, string> = { success: '成功', failed: '失败', denied: '拒绝' }
return m[s] || s || '-'
}
function formatTime(t: any) {
if (!t) return '-'
try {
const d = new Date(t)
return d.toLocaleString('zh-CN', { hour12: false })
} catch { return String(t) }
}
watch(activeTab, (v) => {
if (v === 'audit' && !auditLogs.value.length) loadAuditLogs()
if (v === 'users' && !users.value.length) loadUsers()
if (v === 'resources' && !resources.value.length) loadResources()
})
onMounted(() => {
loadDashboard()
})
</script>
<style scoped>
.admin-page {
min-height: calc(100vh - var(--header-height));
padding: 28px;
max-width: 1280px;
margin: 0 auto;
}
.admin-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
}
.admin-head h1 {
font-size: 24px;
color: #173f35;
margin: 0;
}
.admin-head p {
color: #71847c;
font-size: 14px;
margin: 4px 0 0;
}
.tab-bar {
display: inline-flex;
gap: 4px;
border: 1px solid #dce8de;
border-radius: 10px;
padding: 4px;
background: #fff;
}
.tab-bar button {
height: 34px;
border: none;
border-radius: 7px;
background: transparent;
color: #51665f;
padding: 0 18px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.tab-bar button.active {
background: #00543d;
color: #fff;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
}
.stat-card {
border: 1px solid #dce8de;
border-radius: 14px;
background: #fff;
padding: 20px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.stat-card strong {
font-size: 32px;
color: #173f35;
line-height: 1;
}
.stat-card span {
color: #71847c;
font-size: 13px;
}
.data-section {
background: #fff;
border: 1px solid #dce8de;
border-radius: 14px;
padding: 20px;
}
.filter-row {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
}
.ghost-btn {
height: 34px;
border: 1px solid #dbe8de;
border-radius: 8px;
background: #fff;
color: #00543d;
padding: 0 16px;
font-weight: 700;
cursor: pointer;
}
.ghost-btn:hover {
background: #f0fdf4;
}
</style>
+733
View File
@@ -0,0 +1,733 @@
<template>
<div class="page-container">
<div class="gen-hero">
<div class="gen-hero-icon" style="background: var(--gradient-green)">
<el-icon :size="28" color="#fff"><Film /></el-icon>
</div>
<div>
<h2>教学动画</h2>
<p>描述教学场景AI生成可交互的动画页面</p>
</div>
</div>
<div class="gen-input-section">
<div v-if="quickPromptNotice" class="quick-notice">
<el-icon><MagicStick /></el-icon>
<span>{{ quickPromptNotice }}</span>
</div>
<div class="prompt-box">
<el-input
v-model="form.prompt"
type="textarea"
:rows="4"
placeholder="描述你想要的教学动画,例如:展示二次函数y=ax²+bx+c的图像随参数a、b、c变化的过程..."
resize="none"
/>
<div class="prompt-footer">
<div class="prompt-tags">
<span class="prompt-tag" v-for="t in suggestions" :key="t" @click="form.prompt = t">{{ t }}</span>
</div>
<div class="prompt-actions">
<button class="tpl-btn" type="button" @click="showTemplateGallery = true">
<el-icon><Grid /></el-icon>
<span>用模板</span>
</button>
<button class="voice-btn" type="button" title="语音输入" :class="{ active: listening }" @click="toggleVoice">
<el-icon><Microphone /></el-icon>
</button>
<button class="gen-btn" style="background: var(--gradient-green)" :disabled="!form.prompt || generating" @click="handleGenerate">
<el-icon v-if="!generating"><MagicStick /></el-icon>
<span v-if="generating" class="btn-loading"></span>
{{ generating ? 'AI生成中...' : '生成动画' }}
</button>
</div>
</div>
</div>
<div class="material-row">
<MaterialPicker v-model="selectedMaterials" />
</div>
<div class="settings-row">
<div class="setting-item">
<label>动画类型</label>
<el-select v-model="form.anim_type" size="default">
<el-option label="数学" value="math" />
<el-option label="物理" value="physics" />
<el-option label="化学" value="chemistry" />
<el-option label="通用" value="general" />
</el-select>
</div>
<div class="setting-item">
<label>学科</label>
<el-input v-model="form.subject" placeholder="例如:数学" size="default" style="width: 140px" />
</div>
</div>
</div>
<div v-if="generating" class="loading-section">
<div class="loading-bar"><div class="loading-bar-fill"></div></div>
<p>AI正在生成动画页面...</p>
<p style="font-size:13px;color:var(--text-secondary);opacity:.65;margin-top:10px">模型深度推理中通常需要 1-2 分钟请耐心等待</p>
</div>
<div v-else-if="!result" class="guide-section">
<div class="capability-row">
<div v-for="item in capabilities" :key="item.title">
<strong>{{ item.title }}</strong>
<span>{{ item.desc }}</span>
</div>
</div>
<div class="guide-cards">
<div class="guide-card" v-for="g in guideExamples" :key="g.title" @click="form.prompt = g.prompt">
<div class="guide-icon" :style="{ background: g.bg }">
<el-icon :size="18" color="#fff"><component :is="g.icon" /></el-icon>
</div>
<div class="guide-info">
<h4>{{ g.title }}</h4>
<p>{{ g.prompt }}</p>
</div>
<span class="guide-arrow">试试 </span>
</div>
</div>
</div>
<div v-if="result" class="result-section">
<div class="result-header">
<div>
<h3>{{ result.title }}</h3>
<p class="result-desc">{{ result.description }}</p>
</div>
<div class="result-actions">
<button class="tool-btn" type="button" @click="reuseCurrent">
<el-icon><MagicStick /></el-icon>
用它生成
</button>
<button class="tool-btn" type="button" @click="handleExportHtml">
<el-icon><Download /></el-icon>
下载HTML
</button>
<button class="tool-btn publish" type="button" :disabled="publishing" @click="publishCurrent">
<el-icon><Share /></el-icon>
{{ publishing ? '发布中' : '发布资源' }}
</button>
<button class="save-btn" type="button" @click="handleSave">
<el-icon><Check /></el-icon>
保存动画
</button>
</div>
</div>
<div class="animation-preview">
<HtmlPreview :html="result.html" height="600px" />
</div>
<div v-if="result.scenes?.length" class="scenes-section">
<h4>动画场景</h4>
<div class="scenes-list">
<div v-for="scene in result.scenes" :key="scene.id" class="scene-card">
<div class="scene-num">{{ scene.id }}</div>
<div class="scene-info">
<div class="scene-name">{{ scene.name }}</div>
<div class="scene-meta">
<span>时长{{ scene.duration }}</span>
<span class="scene-narration">{{ scene.narration }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="result-meta-bar">
总时长{{ result.totalDuration || '--' }} · {{ result.interactive ? '支持交互控制' : '自动播放' }}
</div>
</div>
<section class="saved-section">
<div class="saved-header">
<div>
<h3>已保存动画</h3>
<p>保存后的教学动画会出现在这里也可以在课件编辑器中插入使用</p>
</div>
<button class="refresh-btn" type="button" @click="handleRefreshSavedAnimations">
<el-icon><Refresh /></el-icon>
刷新
</button>
</div>
<div v-if="savedLoading" class="saved-loading">正在加载已保存动画...</div>
<div v-else-if="savedAnimations.length === 0" class="saved-empty">
<el-icon><Film /></el-icon>
<span>暂无已保存动画</span>
</div>
<div v-else class="saved-grid">
<article v-for="item in savedAnimations" :key="item.id" class="saved-card">
<div class="saved-cover">
<el-icon><Film /></el-icon>
<strong>{{ item.title }}</strong>
<span>{{ typeLabel(item.anim_type) }}</span>
</div>
<div class="saved-body">
<h4>{{ item.title }}</h4>
<p>{{ item.description || '暂无描述' }}</p>
<div class="saved-actions">
<button type="button" @click="previewSaved(item)">
<el-icon><View /></el-icon>
预览
</button>
<button type="button" @click="publishSaved(item)">
<el-icon><Share /></el-icon>
发布
</button>
<button type="button" @click="exportSavedAnimation(item)">
<el-icon><Download /></el-icon>
下载
</button>
<el-popconfirm title="确定删除这个动画?关联资源会同步下架。" @confirm="handleDeleteSaved(item.id)">
<template #reference>
<button type="button" class="danger">
<el-icon><Delete /></el-icon>
删除
</button>
</template>
</el-popconfirm>
</div>
</div>
</article>
</div>
</section>
<TemplateGallery v-model="showTemplateGallery" kind="animation" @use="handleUseTemplate" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { aiGenerateAnimation, createAnimation, deleteAnimation, exportAnimationHtml, getAnimation, getAnimations, updateAnimation } from '@/api/animation'
import { exportHtml } from '@/api/ai'
import { publishResource } from '@/api/resource'
import HtmlPreview from '@/components/common/HtmlPreview.vue'
import MaterialPicker from '@/components/common/MaterialPicker.vue'
import TemplateGallery from '@/components/common/TemplateGallery.vue'
import { useUserStore } from '@/stores/user'
import { downloadBlob } from '@/utils/download'
import { buildMaterialPromptBlock, type MaterialContextItem } from '@/utils/materialContext'
import { createSpeechController } from '@/utils/speech'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const generating = ref(false)
const result = ref<any>(null)
const savedLoading = ref(false)
const savedAnimations = ref<any[]>([])
const quickPromptNotice = ref('')
const savedResultId = ref<number | null>(null)
const publishing = ref(false)
const listening = ref(false)
const selectedMaterials = ref<MaterialContextItem[]>([])
const showTemplateGallery = ref(false)
const suggestions = ['数学动画', '课堂演示', '知识讲解']
const guideExamples = [
{ title: '函数图像', prompt: '展示二次函数 y=ax²+bx+c 的图像随参数 a、b、c 变化的过程', icon: 'DataLine', bg: 'var(--gradient-green)' },
{ title: '实验演示', prompt: '用动画演示一个课堂实验的关键步骤和变化过程', icon: 'Film', bg: 'var(--gradient-blue)' },
{ title: '知识推演', prompt: '用连贯动画解释一个概念的形成、转化和结论', icon: 'Sunrise', bg: 'var(--gradient-orange)' },
]
const capabilities = [
{ title: '知识可视化', desc: '把抽象概念转成图形、标注和动态变化' },
{ title: '课堂投屏', desc: '生成适合大屏展示的轻量 HTML 页面' },
{ title: '可复用保存', desc: '保存后可在课件编辑器中插入使用' },
]
const form = reactive({ prompt: '', anim_type: 'general', subject: '' })
const speech = createSpeechController({
onStart: () => { listening.value = true },
onText: (text) => { form.prompt = `${form.prompt} ${text}`.trim() },
onError: () => {
listening.value = false
ElMessage.warning('语音识别失败,请检查浏览器权限或直接输入文字')
},
onEnd: () => { listening.value = false },
})
function handleUseTemplate(payload: { kind: string; template: any; params: Record<string, any>; html: string; title: string }) {
result.value = {
title: payload.title,
description: payload.template.description || '',
html: payload.html,
interactive: true,
}
savedResultId.value = null
if (payload.template.category) {
const catMap: Record<string, string> = {
math: 'math', physics: 'physics', chemistry: 'chemistry',
biology: 'general', geography: 'general', general: 'general',
}
form.anim_type = catMap[payload.template.category] || form.anim_type
}
ElMessage.success('已应用模板,可在右侧预览并保存')
}
async function handleGenerate() {
if (!(await ensureLoggedIn())) return
generating.value = true
try {
const res: any = await aiGenerateAnimation({
...form,
prompt: buildPromptWithMaterials(form.prompt),
})
userStore.refreshCreditsFromResponse(res)
const data = res.data || res
if (data.html) {
data.html = ensureVisibleAnimation(data.html, data.title || form.prompt)
result.value = data
savedResultId.value = null
ElMessage.success('动画生成成功')
} else if (data.raw_content) {
try {
const parsed = typeof data.raw_content === 'string' ? JSON.parse(data.raw_content) : data.raw_content
const htmlVal = parsed?.html || parsed?.content || ''
if (htmlVal) {
result.value = {
title: parsed?.title || '教学动画',
description: parsed?.description || '',
html: htmlVal,
scenes: parsed?.scenes || [],
totalDuration: parsed?.totalDuration || 0,
interactive: !!parsed?.interactive,
}
savedResultId.value = null
ElMessage.warning('已生成 HTML,但内容可能不完整')
} else {
ElMessage.error('未能解析 AI 返回的 HTML')
}
} catch {
ElMessage.error('动画生成失败,无法解析 AI 返回内容')
}
} else {
ElMessage.error('未获取到可用的动画内容')
}
} catch (e: any) {
if (handleAuthError(e)) return
ElMessage.error('动画生成失败:' + (e.response?.data?.detail || e.message || ''))
} finally {
generating.value = false
}
}
function buildPromptWithMaterials(prompt: string) {
return `${prompt}${buildMaterialPromptBlock(selectedMaterials.value, '参考素材')}`.trim()
}
async function handleSave() {
if (!result.value) return
if (!(await ensureLoggedIn())) return
try {
const payload = {
status: 'draft',
title: result.value.title,
anim_type: form.anim_type,
description: result.value.description || '',
config: result.value,
}
const isUpdate = !!savedResultId.value
const saved: any = isUpdate
? await updateAnimation(savedResultId.value!, payload)
: await createAnimation(payload)
const item = saved.data || saved
savedResultId.value = item?.id || null
ElMessage.success(isUpdate ? '动画已更新' : '动画已保存')
await loadSavedAnimations()
} catch (e: any) {
if (handleAuthError(e)) return
ElMessage.error('保存失败:' + (e.response?.data?.detail || e.message || ''))
}
}
async function ensureLoggedIn(options: { silent?: boolean } = {}) {
if (!userStore.isLoggedIn()) {
if (!options.silent) ElMessage.warning('请先登录后再使用动画生成功能')
await router.push('/login')
return false
}
if (!userStore.user) {
try {
await userStore.fetchProfile()
} catch {
if (!options.silent) ElMessage.warning('登录状态失效,请重新登录')
await router.push('/login')
return false
}
}
return true
}
function isAuthError(error: any) {
return error?.response?.status === 401 ||
(error?.response?.status === 403 && error?.response?.data?.detail === 'Not authenticated')
}
function handleAuthError(error: any) {
if (!isAuthError(error)) return false
userStore.clearToken()
ElMessage.warning('登录状态失效,请重新登录')
router.push('/login')
return true
}
function toggleVoice() {
if (listening.value) {
speech.stop()
return
}
if (!speech.isSupported()) {
ElMessage.warning('当前浏览器不支持语音识别,请使用 Chrome/Edge 或直接输入文字')
return
}
speech.start()
}
async function loadSavedAnimations(options: { silent?: boolean } = {}) {
if (!(await ensureLoggedIn({ silent: true }))) {
savedAnimations.value = []
savedLoading.value = false
if (!options.silent) ElMessage.warning('请先登录后再查看已保存内容')
return
}
savedLoading.value = true
try {
const res: any = await getAnimations({ limit: 50 })
savedAnimations.value = res.data || res || []
} catch (e: any) {
if (handleAuthError(e)) {
savedAnimations.value = []
return
}
ElMessage.error('加载已保存动画失败:' + (e.response?.data?.detail || e.message || ''))
} finally {
savedLoading.value = false
}
}
async function handleRefreshSavedAnimations() {
await loadSavedAnimations()
}
function previewSaved(item: any) {
if (!item.config?.html) {
ElMessage.warning('当前动画没有可预览的 HTML')
return
}
result.value = {
...item.config,
html: ensureVisibleAnimation(item.config.html, item.title),
}
savedResultId.value = item.id
form.anim_type = item.anim_type || 'general'
window.scrollTo({ top: 0, behavior: 'smooth' })
}
async function ensureSavedCurrent() {
if (!result.value) return null
if (!(await ensureLoggedIn())) return null
const payload = {
status: 'draft',
title: result.value.title || '教学动画',
anim_type: form.anim_type,
description: result.value.description || '',
config: result.value,
}
const saved: any = savedResultId.value
? await updateAnimation(savedResultId.value, payload)
: await createAnimation(payload)
const item = saved.data || saved
savedResultId.value = item?.id || null
await loadSavedAnimations({ silent: true })
return savedResultId.value
}
async function publishCurrent() {
if (!result.value) return
publishing.value = true
try {
const id = await ensureSavedCurrent()
if (!id) return
await publishAnimationAsset({
id,
title: result.value.title || '教学动画',
anim_type: form.anim_type,
description: result.value.description || '',
config: result.value,
})
} finally {
publishing.value = false
}
}
async function publishSaved(item: any) {
if (!(await ensureLoggedIn())) return
await publishAnimationAsset(item)
}
async function publishAnimationAsset(item: any) {
const config = item.config || result.value || {}
const html = ensureVisibleAnimation(config.html || '', item.title)
const updated: any = await updateAnimation(item.id, {
title: item.title,
description: item.description || config.description || buildAnimationDescription(item),
config: { ...config, html },
status: 'published',
})
const saved = updated.data || updated
const created: any = await publishResource({
resource_type: 'animation',
title: saved.title,
description: saved.description || buildAnimationDescription(saved),
content_ref: `animation:${saved.id}`,
file_url: '',
tags: ['教学动画', typeLabel(saved.anim_type)],
subject: form.subject || typeLabel(saved.anim_type),
grade: '',
is_public: 1,
})
const resource = created.data || created
ElMessage.success('已发布到资源广场')
if (resource?.id) router.push(`/resources/${resource.id}`)
}
function buildAnimationDescription(item: any) {
const sceneCount = item.config?.scenes?.length || result.value?.scenes?.length || 0
return `${typeLabel(item.anim_type || form.anim_type)}教学动画,包含${sceneCount || '多个'}演示场景,可用于课堂投屏、知识讲解和课件插入。`
}
async function handleExportHtml() {
if (!result.value?.html) return
const html = ensureVisibleAnimation(result.value.html, result.value.title)
const blob = await exportHtml({ title: result.value.title || '教学动画', html }) as Blob
downloadBlob(blob, `${result.value.title || '教学动画'}.html`)
}
async function exportSavedAnimation(item: any) {
if (!(await ensureLoggedIn())) return
try {
const blob = await exportAnimationHtml(item.id)
downloadBlob(blob, `${safeFilename(item.title || '教学动画')}-动画.html`)
} catch (e: any) {
if (handleAuthError(e)) return
ElMessage.error('下载失败:' + (e.response?.data?.detail || e.message || ''))
}
}
function reuseCurrent() {
if (!result.value) return
localStorage.setItem('quick_create_prompt', `参考动画《${result.value.title || '教学动画'}》,重新生成一份原创教学动画。要求保留核心知识点,优化场景节奏、交互控制和课堂提问。`)
result.value = null
savedResultId.value = null
window.scrollTo({ top: 0, behavior: 'smooth' })
}
async function handleDeleteSaved(id: number) {
if (!(await ensureLoggedIn())) return
try {
await deleteAnimation(id)
ElMessage.success('动画已删除,关联资源已下架')
await loadSavedAnimations()
} catch (e: any) {
if (handleAuthError(e)) return
ElMessage.error('删除失败:' + (e.response?.data?.detail || e.message || ''))
}
}
function typeLabel(type: string) {
const map: Record<string, string> = {
math: '数学',
physics: '物理',
chemistry: '化学',
biology: '生物',
geography: '地理',
general: '通用',
}
return map[type] || '通用'
}
function safeFilename(value: string) {
return (value || '教学动画').replace(/[\\/:*?"<>|\r\n]+/g, '_').trim() || '教学动画'
}
function ensureVisibleAnimation(html: string, title?: string) {
const fallback = `
<div style='width:100%;height:100%;position:relative;overflow:hidden;background:linear-gradient(135deg,#052e25,#0f766e);font-family:Microsoft YaHei,sans-serif;color:#fff;'>
<div style='position:absolute;left:36px;top:30px;max-width:460px;'>
<h2 style='font-size:28px;margin:0 0 8px;line-height:1.2;'>${title || '教学动画'}</h2>
<p style='font-size:15px;opacity:.82;margin:0;'>当生成结果缺少可渲染内容时,这里会展示稳定的基础动画预览。</p>
</div>
<div style='position:absolute;left:80px;top:220px;width:72px;height:72px;border-radius:50%;background:#facc15;animation:floatBall 6s ease-in-out infinite,pulseGlow 2s ease-in-out infinite;'></div>
<style>
@keyframes floatBall {
0%,100% { transform: translateX(0) translateY(0); }
25% { transform: translateX(160px) translateY(-34px); }
50% { transform: translateX(320px) translateY(0); }
75% { transform: translateX(480px) translateY(-24px); }
}
@keyframes pulseGlow {
0%,100% { box-shadow: 0 0 18px rgba(250,204,21,.35); }
50% { box-shadow: 0 0 34px rgba(250,204,21,.7); }
}
</style>
</div>`
if (!html || html.trim().length < 120) return fallback
if (!/animation|@keyframes|canvas|svg|script|style/i.test(html)) return fallback
return html
}
onMounted(async () => {
const quickPrompt = localStorage.getItem('quick_create_prompt')
if (quickPrompt) {
form.prompt = quickPrompt
form.subject = inferSubject(quickPrompt)
form.anim_type = inferAnimationType(quickPrompt)
quickPromptNotice.value = '已带入首页动画指令,可直接生成或继续调整类型。'
localStorage.removeItem('quick_create_prompt')
}
await loadSavedAnimations({ silent: true })
await openSavedFromRoute()
})
async function openSavedFromRoute() {
const openId = Number(route.query.open)
if (!Number.isFinite(openId) || openId <= 0) return
try {
const data: any = await getAnimation(openId)
const item = data.data || data
previewSaved(item)
} catch {
ElMessage.warning('未能打开改编后的动画')
}
}
function inferSubject(text: string) {
const subjects = ['数学', '物理', '化学', '英语', '语文', '信息科技']
return subjects.find(item => text.includes(item)) || ''
}
function inferAnimationType(text: string) {
if (/数学|函数|几何|圆柱|公式/.test(text)) return 'math'
if (/物理|力|电|光/.test(text)) return 'physics'
if (/化学|实验|反应/.test(text)) return 'chemistry'
return 'general'
}
</script>
<style scoped>
.gen-hero { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
.gen-hero-icon { width: 52px; height: 52px; border-radius: 14px; display: flex; align-items: center; justify-content: center; }
.gen-hero h2 { font-size: 24px; font-weight: 700; color: var(--text-primary); }
.gen-hero p { font-size: 14px; color: var(--text-muted); }
.gen-input-section { background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 24px; margin-bottom: 24px; }
.quick-notice { min-height: 38px; display: flex; align-items: center; gap: 8px; border: 1px solid #dce8de; border-radius: 12px; background: #f0fbef; color: #00543d; padding: 8px 12px; margin-bottom: 14px; font-size: 13px; font-weight: 800; }
.prompt-box { margin-bottom: 16px; }
.prompt-box :deep(.el-textarea__inner) { border: none; background: #F8FAFC; border-radius: var(--radius-md); padding: 16px; font-size: 15px; line-height: 1.6; }
.prompt-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; }
.prompt-tags { display: flex; gap: 6px; }
.prompt-tag { padding: 4px 12px; background: #ECFDF5; color: #10B981; border-radius: 20px; font-size: 12px; cursor: pointer; transition: all 0.2s; }
.prompt-tag:hover { background: #10B981; color: #fff; }
.prompt-actions { display: flex; align-items: center; gap: 8px; }
.material-row { display: flex; flex-wrap: wrap; gap: 8px; padding: 14px 0 0; margin-top: 4px; border-top: 1px solid var(--border-color); }
.tpl-btn { display: inline-flex; align-items: center; gap: 6px; height: 38px; padding: 0 14px; border: 1px solid #c7d2fe; border-radius: 10px; background: #fff; color: #4f46e5; cursor: pointer; font-size: 13px; font-weight: 600; transition: all 0.2s; }
.tpl-btn:hover { border-color: #4f46e5; background: #eef2ff; }
.voice-btn { width: 38px; height: 38px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid #cfe7d8; border-radius: 10px; background: #fff; color: #087f5b; cursor: pointer; }
.voice-btn:hover,
.voice-btn.active { border-color: #10b981; background: #ecfdf5; color: #047857; }
.gen-btn { display: flex; align-items: center; gap: 8px; border: none; color: #fff; padding: 10px 24px; border-radius: var(--radius-sm); font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.3s; }
.gen-btn:hover:not(:disabled) { opacity: 0.9; transform: translateY(-1px); }
.gen-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.settings-row { display: flex; gap: 20px; align-items: center; padding-top: 16px; border-top: 1px solid var(--border-color); }
.setting-item { display: flex; align-items: center; gap: 8px; }
.setting-item label { font-size: 13px; color: var(--text-muted); white-space: nowrap; }
.loading-section { text-align: center; padding: 48px 0; }
.loading-bar { width: 300px; height: 4px; background: #E2E8F0; border-radius: 2px; margin: 0 auto 20px; overflow: hidden; }
.loading-bar-fill { height: 100%; width: 40%; background: var(--gradient-green); border-radius: 2px; animation: loadingSlide 1.5s ease-in-out infinite; }
@keyframes loadingSlide { 0% { transform: translateX(-100%); } 100% { transform: translateX(350%); } }
.loading-section p { font-size: 14px; color: var(--text-secondary); }
.guide-section { animation: fadeIn 0.4s ease; }
.capability-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 14px; }
.capability-row div { min-height: 78px; border: 1px solid #dce8de; border-radius: 12px; background: #fff; padding: 14px 16px; }
.capability-row strong { display: block; color: #00543d; font-size: 15px; margin-bottom: 6px; }
.capability-row span { color: #6a7f77; font-size: 12px; line-height: 1.45; }
.guide-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.guide-card { display: flex; flex-direction: column; gap: 10px; background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 18px; cursor: pointer; transition: all 0.3s; }
.guide-card:hover { box-shadow: var(--shadow-md); border-color: #10B981; transform: translateY(-2px); }
.guide-icon { width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; }
.guide-info h4 { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.guide-info p { font-size: 12px; color: var(--text-muted); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.guide-arrow { font-size: 12px; color: #10B981; font-weight: 500; margin-top: auto; }
.result-section { animation: fadeInUp 0.5s ease; }
.result-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
.result-header h3 { font-size: 20px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.result-desc { color: var(--text-muted); font-size: 14px; }
.result-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; flex-shrink: 0; }
.save-btn { display: flex; align-items: center; gap: 6px; background: var(--gradient-green); border: none; color: #fff; padding: 8px 20px; border-radius: var(--radius-sm); font-size: 14px; font-weight: 500; cursor: pointer; transition: opacity 0.2s; flex-shrink: 0; }
.save-btn:hover { opacity: 0.9; }
.tool-btn { height: 34px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; border: 1px solid #d9e7dc; border-radius: 8px; background: #fff; color: #31584d; padding: 0 12px; font-size: 13px; font-weight: 800; cursor: pointer; }
.tool-btn:hover { border-color: #a8cfab; background: #f6fbf6; color: #00543d; }
.tool-btn.publish { border-color: #0f8a5f; color: #0f8a5f; }
.tool-btn:disabled { opacity: .58; cursor: not-allowed; }
.animation-preview { background: #F1F5F9; border-radius: var(--radius-lg); padding: 24px; margin-bottom: 20px; }
.animation-preview :deep(.html-preview) { border-radius: var(--radius-md); box-shadow: 0 4px 24px rgba(0,0,0,0.08); }
.scenes-section { background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 20px; margin-bottom: 16px; }
.scenes-section h4 { font-size: 15px; font-weight: 600; color: var(--text-primary); margin-bottom: 14px; }
.scenes-list { display: flex; flex-direction: column; gap: 10px; }
.scene-card { display: flex; align-items: flex-start; gap: 12px; padding: 12px; background: #F8FAFC; border-radius: var(--radius-sm); }
.scene-num { width: 28px; height: 28px; background: var(--gradient-green); border-radius: 8px; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 12px; font-weight: 700; flex-shrink: 0; }
.scene-info { flex: 1; }
.scene-name { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.scene-meta { font-size: 12px; color: var(--text-muted); display: flex; gap: 12px; }
.scene-narration { color: var(--text-secondary); }
.result-meta-bar { padding: 12px 0; border-top: 1px solid var(--border-color); color: var(--text-muted); font-size: 13px; }
.btn-loading { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
.saved-section { margin-top: 28px; background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 22px; }
.saved-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 18px; }
.saved-header h3 { font-size: 18px; font-weight: 700; color: var(--text-primary); margin-bottom: 4px; }
.saved-header p { font-size: 13px; color: var(--text-muted); }
.refresh-btn { height: 34px; display: inline-flex; align-items: center; gap: 6px; border: 1px solid #d9e7dc; border-radius: 17px; background: #fff; color: #00543d; padding: 0 14px; font-weight: 700; cursor: pointer; }
.saved-loading,
.saved-empty { min-height: 120px; display: flex; align-items: center; justify-content: center; gap: 8px; color: var(--text-muted); border: 1px dashed #dbe7de; border-radius: var(--radius-md); background: #f8faf8; }
.saved-empty .el-icon { font-size: 24px; color: #9aaba4; }
.saved-grid { display: grid; grid-template-columns: repeat(3, minmax(220px, 1fr)); gap: 16px; }
.saved-card { overflow: hidden; border: 1px solid #dce8de; border-radius: 12px; background: #fff; }
.saved-cover { height: 132px; display: flex; flex-direction: column; justify-content: center; gap: 8px; margin: 5px; padding: 18px; border-radius: 9px; color: #fff; background: linear-gradient(135deg, #063a2d, #00854f); }
.saved-cover .el-icon { font-size: 22px; opacity: .85; }
.saved-cover strong { font-size: 18px; line-height: 1.25; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.saved-cover span { width: fit-content; padding: 3px 8px; border-radius: 12px; background: rgba(255,255,255,.2); font-size: 12px; font-weight: 800; }
.saved-body { padding: 12px 16px 16px; }
.saved-body h4 { font-size: 15px; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 6px; }
.saved-body p { min-height: 36px; color: var(--text-muted); font-size: 12px; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.saved-actions { display: flex; gap: 8px; margin-top: 12px; }
.saved-actions button { height: 32px; display: inline-flex; align-items: center; justify-content: center; gap: 5px; border: 1px solid #d9e7dc; border-radius: 8px; background: #f8faf8; color: #31584d; padding: 0 12px; cursor: pointer; font-weight: 700; }
.saved-actions button:hover { border-color: #a8cfab; color: #00543d; }
.saved-actions .danger:hover { border-color: #fca5a5; background: #fef2f2; color: #ef4444; }
@media (max-width: 1080px) {
.saved-grid { grid-template-columns: repeat(2, minmax(220px, 1fr)); }
}
@media (max-width: 760px) {
.prompt-footer,
.settings-row,
.result-header,
.saved-header,
.result-actions { flex-direction: column; align-items: stretch; }
.capability-row,
.guide-cards,
.saved-grid { grid-template-columns: 1fr; }
}
</style>
+585
View File
@@ -0,0 +1,585 @@
<template>
<div class="assistant-page">
<aside class="conv-rail">
<button class="new-chat-btn" type="button" @click="startNewChat">
<el-icon><Plus /></el-icon>
<span>新建对话</span>
</button>
<div class="conv-search">
<el-input
v-model="keyword"
placeholder="搜索对话"
:prefix-icon="Search"
clearable
@input="loadConversations"
/>
</div>
<div class="conv-list">
<div v-if="loadingList && !conversations.length" class="conv-empty">加载中</div>
<div v-else-if="!conversations.length" class="conv-empty">还没有对话点上方新建对话开始吧</div>
<div
v-for="conv in conversations"
:key="conv.id"
class="conv-item"
:class="{ active: conv.id === activeId }"
@click="selectConversation(conv.id)"
>
<el-icon class="pin" v-if="conv.pinned"><Top /></el-icon>
<div class="conv-meta">
<div class="conv-title">{{ conv.title }}</div>
<div class="conv-sub">{{ formatTime(conv.updated_at) }} · {{ conv.messages.length }} </div>
</div>
<el-dropdown trigger="click" @command="(c: string) => onConvMenu(c, conv)" @click.stop>
<span class="conv-more" @click.stop><el-icon><MoreFilled /></el-icon></span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="rename" :icon="Edit">重命名</el-dropdown-item>
<el-dropdown-item command="pin" :icon="Top">{{ conv.pinned ? '取消置顶' : '置顶' }}</el-dropdown-item>
<el-dropdown-item command="delete" :icon="Delete" divided>删除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</aside>
<section class="chat-main">
<header class="chat-header">
<div class="chat-title-block">
<el-icon class="spark"><MagicStick /></el-icon>
<div>
<h1>{{ activeConversation?.title || 'AI教学助手' }}</h1>
<p class="chat-sub">备课 · 讲解 · 活动设计 · 学情诊断 · 班级管理随时问我</p>
</div>
</div>
<div class="ctx-selectors">
<el-select v-model="ctxSubject" placeholder="学科" size="small" class="ctx-sel" filterable>
<el-option v-for="s in subjectOptions" :key="s" :label="s" :value="s" />
</el-select>
<el-select v-model="ctxGrade" placeholder="学段" size="small" class="ctx-sel">
<el-option v-for="g in gradeOptions" :key="g" :label="g" :value="g" />
</el-select>
</div>
</header>
<div ref="scrollRef" class="message-area">
<template v-if="activeConversation && activeConversation.messages.length">
<div
v-for="msg in activeConversation.messages"
:key="msg.id"
class="msg-row"
:class="msg.role"
>
<div class="avatar" :class="msg.role">
<el-icon v-if="msg.role === 'user'"><User /></el-icon>
<el-icon v-else><MagicStick /></el-icon>
</div>
<div class="bubble" :class="msg.role">
<MarkdownContent v-if="msg.role === 'assistant'" :content="msg.content" />
<p v-else class="user-text">{{ msg.content }}</p>
<button v-if="msg.role === 'assistant'" class="copy-btn" type="button" @click="copyText(msg.content)">
<el-icon><DocumentCopy /></el-icon>
</button>
</div>
</div>
<div v-if="sending" class="msg-row assistant">
<div class="avatar assistant"><el-icon><MagicStick /></el-icon></div>
<div class="bubble assistant typing">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
</div>
</div>
</template>
<div v-else-if="!sending" class="welcome">
<div class="welcome-icon"><el-icon><MagicStick /></el-icon></div>
<h2>你好我是你的AI教学助手</h2>
<p>告诉我你正在教的学科与年级或者直接抛出一个教学问题我会给你可落地的方案</p>
<div class="suggest-grid">
<button
v-for="(s, i) in suggestions"
:key="i"
class="suggest-card"
type="button"
@click="useSuggestion(s)"
>
<span class="suggest-tag">{{ s.tag }}</span>
<span class="suggest-text">{{ s.text }}</span>
</button>
</div>
</div>
</div>
<footer class="composer">
<div class="composer-inner">
<el-input
v-model="draft"
type="textarea"
:autosize="{ minRows: 1, maxRows: 6 }"
placeholder="输入你的问题,Enter 发送,Shift+Enter 换行"
resize="none"
:disabled="sending"
@keydown.enter="onEnter"
/>
<button class="send-btn" type="button" :disabled="!draft.trim() || sending" @click="onSend">
<el-icon><Promotion /></el-icon>
</button>
</div>
<p class="composer-tip">AI 生成内容仅供参考请教师结合实际学情审阅使用</p>
</footer>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus, Edit, Delete, Top, MoreFilled, User, MagicStick, Promotion, DocumentCopy } from '@element-plus/icons-vue'
import {
listConversations, createConversation, getConversation,
renameConversation, deleteConversation, sendMessage,
type ChatConversation, type ChatMessage,
} from '@/api/chat'
import { useUserStore } from '@/stores/user'
import MarkdownContent from '@/components/common/MarkdownContent.vue'
const userStore = useUserStore()
const conversations = ref<ChatConversation[]>([])
const activeId = ref<number | null>(null)
const activeConversation = ref<ChatConversation | null>(null)
const keyword = ref('')
const draft = ref('')
const sending = ref(false)
const loadingList = ref(false)
const scrollRef = ref<HTMLElement | null>(null)
const ctxSubject = ref('')
const ctxGrade = ref('')
const subjectOptions = ['语文', '数学', '英语', '物理', '化学', '生物', '历史', '地理', '政治', '科学', '道德与法治', '信息技术', '通用技术', '音乐', '美术', '体育', '心理健康', '综合实践']
const gradeOptions = ['幼儿园', '小学低年级', '小学中年级', '小学高年级', '初中', '高中']
const suggestions = [
{ tag: '活动设计', text: '帮我设计一节小学三年级《分数初步认识》的导入活动,要有悬念感' },
{ tag: '知识点讲解', text: '怎样给初二学生讲透“浮力”的产生原因?请给出讲解话术' },
{ tag: '课堂提问', text: '《草船借箭》这课,设计5个能引发深度思考的追问' },
{ tag: '学情诊断', text: '学生在“一元二次方程”常见有哪些易错点?怎样针对性突破?' },
{ tag: '作业设计', text: '为《人体的呼吸》设计一份分层作业(基础/提升/拓展)' },
{ tag: '家校沟通', text: '家长反映孩子写作业拖拉,帮我拟一段委婉但有建议的回复' },
]
const activeMessages = computed(() => activeConversation.value?.messages ?? [])
async function loadConversations() {
loadingList.value = true
try {
conversations.value = await listConversations({ keyword: keyword.value || undefined })
} catch {
/* handled by interceptor */
} finally {
loadingList.value = false
}
}
async function selectConversation(id: number) {
if (activeId.value === id && activeConversation.value) return
activeId.value = id
try {
activeConversation.value = await getConversation(id)
ctxSubject.value = activeConversation.value.subject || ctxSubject.value
ctxGrade.value = activeConversation.value.grade || ctxGrade.value
scrollToBottom()
} catch { /* */ }
}
async function startNewChat() {
activeId.value = null
activeConversation.value = null
draft.value = ''
}
async function ensureConversation(): Promise<ChatConversation | null> {
if (activeConversation.value) return activeConversation.value
try {
const conv = await createConversation({ subject: ctxSubject.value, grade: ctxGrade.value })
conversations.value.unshift(conv)
activeId.value = conv.id
activeConversation.value = conv
return conv
} catch {
return null
}
}
function onEnter(e: KeyboardEvent | Event) {
if (!('shiftKey' in e)) return
if (e.shiftKey) return
e.preventDefault()
onSend()
}
async function onSend() {
const content = draft.value.trim()
if (!content || sending.value) return
if (userStore.user && !localStorage.getItem('access_token')) return
const conv = await ensureConversation()
if (!conv) return
const userMsg: ChatMessage = { role: 'user', content }
conv.messages.push(userMsg)
draft.value = ''
sending.value = true
scrollToBottom()
try {
const res = await sendMessage(conv.id, { content, subject: ctxSubject.value, grade: ctxGrade.value })
conv.messages.push(res.data)
if (res.title && conv.title === '新对话') {
conv.title = res.title
}
if (res.credits && userStore.user) {
userStore.user.credits = res.credits.balance
}
scrollToBottom()
} catch {
conv.messages.pop()
draft.value = content
} finally {
sending.value = false
scrollToBottom()
await loadConversations()
}
}
function useSuggestion(s: { text: string }) {
draft.value = s.text
}
async function onConvMenu(cmd: string, conv: ChatConversation) {
if (cmd === 'delete') {
try {
await ElMessageBox.confirm(`确定删除对话“${conv.title}”吗?`, '删除对话', { type: 'warning' })
} catch { return }
await deleteConversation(conv.id)
conversations.value = conversations.value.filter((c) => c.id !== conv.id)
if (activeId.value === conv.id) {
activeId.value = null
activeConversation.value = null
}
ElMessage.success('已删除')
} else if (cmd === 'rename') {
try {
const { value } = await ElMessageBox.prompt('请输入新的对话标题', '重命名', {
inputValue: conv.title, inputPattern: /.+/, inputErrorMessage: '标题不能为空',
})
const updated = await renameConversation(conv.id, { title: value })
conv.title = updated.title
} catch { /* */ }
} else if (cmd === 'pin') {
const updated = await renameConversation(conv.id, { title: conv.title, pinned: !conv.pinned })
conv.pinned = updated.pinned
await loadConversations()
}
}
function copyText(text: string) {
navigator.clipboard?.writeText(text).then(() => ElMessage.success('已复制')).catch(() => undefined)
}
function scrollToBottom() {
nextTick(() => {
const el = scrollRef.value
if (el) el.scrollTop = el.scrollHeight
})
}
function formatTime(t?: string) {
if (!t) return ''
const d = new Date(t)
const now = new Date()
const sameDay = d.toDateString() === now.toDateString()
return sameDay
? d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
: `${d.getMonth() + 1}/${d.getDate()}`
}
onMounted(async () => {
await loadConversations()
if (conversations.value.length) {
await selectConversation(conversations.value[0].id)
}
})
</script>
<style scoped>
.assistant-page {
display: grid;
grid-template-columns: 280px 1fr;
gap: 0;
height: 100%;
min-height: 0;
background: var(--bg-color);
}
.conv-rail {
display: flex;
flex-direction: column;
background: #fff;
border-right: 1px solid var(--border-color);
padding: 16px 12px;
min-height: 0;
}
.new-chat-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 42px;
width: 100%;
border: none;
border-radius: var(--radius-sm);
background: var(--gradient-green);
color: #fff;
font-size: 14px;
font-weight: 700;
cursor: pointer;
box-shadow: var(--shadow-sm);
}
.new-chat-btn:hover { opacity: 0.92; }
.conv-search { margin: 14px 0 10px; }
.conv-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
min-height: 0;
}
.conv-empty {
color: var(--text-muted);
font-size: 13px;
text-align: center;
padding: 24px 8px;
line-height: 1.7;
}
.conv-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 10px;
border-radius: var(--radius-sm);
cursor: pointer;
border: 1px solid transparent;
}
.conv-item:hover { background: var(--primary-bg); }
.conv-item.active { background: var(--primary-bg); border-color: var(--primary-light); }
.conv-item .pin { color: var(--primary); font-size: 13px; flex-shrink: 0; }
.conv-meta { flex: 1; min-width: 0; }
.conv-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conv-sub { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
.conv-more {
width: 26px; height: 26px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 6px; color: var(--text-muted); cursor: pointer; flex-shrink: 0;
}
.conv-more:hover { background: #eef; color: var(--text-primary); }
.chat-main {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
height: 100%;
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 28px;
background: #fff;
border-bottom: 1px solid var(--border-color);
}
.chat-title-block { display: flex; align-items: center; gap: 12px; min-width: 0; }
.chat-title-block .spark {
width: 42px; height: 42px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 12px;
background: var(--gradient-green);
color: #fff;
font-size: 22px;
flex-shrink: 0;
}
.chat-title-block h1 { font-size: 18px; font-weight: 700; color: var(--text-primary); }
.chat-sub { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
.ctx-selectors { display: flex; gap: 8px; flex-shrink: 0; }
.ctx-sel { width: 116px; }
.message-area {
flex: 1;
overflow-y: auto;
padding: 24px 28px 8px;
display: flex;
flex-direction: column;
gap: 18px;
min-height: 0;
}
.msg-row { display: flex; gap: 12px; align-items: flex-start; }
.msg-row.user { flex-direction: row-reverse; }
.avatar {
width: 36px; height: 36px;
flex-shrink: 0;
border-radius: 10px;
display: inline-flex; align-items: center; justify-content: center;
font-size: 18px;
}
.avatar.user { background: var(--gradient-blue); color: #fff; }
.avatar.assistant { background: var(--gradient-green); color: #fff; }
.bubble {
position: relative;
max-width: min(720px, 78%);
padding: 12px 16px;
border-radius: 14px;
font-size: 14px;
line-height: 1.75;
word-break: break-word;
}
.bubble.user {
background: var(--primary);
color: #fff;
border-top-right-radius: 4px;
}
.bubble.assistant {
background: #fff;
color: var(--text-primary);
border: 1px solid var(--border-color);
border-top-left-radius: 4px;
}
.user-text { white-space: pre-wrap; }
.copy-btn {
position: absolute;
right: 8px; bottom: 6px;
width: 26px; height: 26px;
display: inline-flex; align-items: center; justify-content: center;
border: none; background: transparent;
color: var(--text-muted); cursor: pointer;
border-radius: 6px; opacity: 0;
transition: opacity 0.2s;
}
.bubble.assistant:hover .copy-btn { opacity: 1; }
.copy-btn:hover { background: var(--primary-bg); color: var(--primary); }
.typing { display: inline-flex; align-items: center; gap: 5px; }
.typing .dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--primary-light);
animation: blink 1.2s infinite ease-in-out both;
}
.typing .dot:nth-child(2) { animation-delay: 0.2s; }
.typing .dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes blink { 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); } 40% { opacity: 1; transform: scale(1); } }
.welcome {
margin: auto;
max-width: 720px;
text-align: center;
padding: 32px 16px;
}
.welcome-icon {
width: 64px; height: 64px;
margin: 0 auto 16px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 18px;
background: var(--gradient-green);
color: #fff; font-size: 32px;
box-shadow: var(--shadow-md);
}
.welcome h2 { font-size: 22px; font-weight: 700; color: var(--text-primary); margin-bottom: 8px; }
.welcome p { color: var(--text-secondary); font-size: 14px; line-height: 1.7; margin-bottom: 24px; }
.suggest-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
text-align: left;
}
.suggest-card {
display: flex; flex-direction: column; gap: 8px;
background: #fff;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 14px 16px;
cursor: pointer;
transition: all 0.2s;
}
.suggest-card:hover { border-color: var(--primary-light); box-shadow: var(--shadow-md); transform: translateY(-1px); }
.suggest-tag {
align-self: flex-start;
font-size: 12px; font-weight: 600;
color: var(--primary);
background: var(--primary-bg);
padding: 2px 8px; border-radius: 6px;
}
.suggest-text { font-size: 13px; color: var(--text-secondary); line-height: 1.6; }
.composer {
padding: 12px 28px 18px;
background: var(--bg-color);
border-top: 1px solid var(--border-color);
}
.composer-inner {
display: flex;
align-items: flex-end;
gap: 10px;
background: #fff;
border: 1px solid var(--border-color);
border-radius: 14px;
padding: 8px 8px 8px 14px;
box-shadow: var(--shadow-sm);
}
.composer-inner :deep(.el-textarea__inner) {
border: none; background: transparent; box-shadow: none;
padding: 6px 0; font-size: 14px; line-height: 1.6; resize: none;
}
.send-btn {
width: 38px; height: 38px;
flex-shrink: 0;
border: none; border-radius: 10px;
background: var(--gradient-green); color: #fff;
font-size: 18px; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
transition: opacity 0.2s;
}
.send-btn:hover:not(:disabled) { opacity: 0.9; }
.send-btn:disabled { background: #c9d6cc; cursor: not-allowed; }
.composer-tip { font-size: 12px; color: var(--text-muted); margin-top: 8px; text-align: center; }
@media (max-width: 768px) {
.assistant-page { grid-template-columns: 1fr; }
.conv-rail { display: none; }
.suggest-grid { grid-template-columns: 1fr; }
.bubble { max-width: 88%; }
}
</style>
File diff suppressed because it is too large Load Diff
+529
View File
@@ -0,0 +1,529 @@
<template>
<div class="join-page">
<main class="join-shell">
<header class="join-header">
<span class="brand-mark"></span>
<div>
<strong>智教助手 · 数据回收</strong>
<p>提交后老师端会实时看到统计结果</p>
</div>
</header>
<section v-if="loading" class="state-card">
<span class="loader"></span>
<strong>正在加载课堂活动</strong>
</section>
<section v-else-if="needsCode" class="code-card">
<div class="code-head">
<span>投放码</span>
<strong>输入老师给出的活动 ID</strong>
<p>也可以让老师复制完整投放链接打开后会直接进入提交页</p>
</div>
<form class="code-form" @submit.prevent="joinByCode">
<input
v-model="joinCode"
class="code-input"
inputmode="numeric"
maxlength="12"
placeholder="例如:1024"
/>
<button type="submit">进入活动</button>
</form>
</section>
<section v-else-if="!activity" class="state-card">
<el-icon><Warning /></el-icon>
<strong>活动不存在或已关闭</strong>
<p>请核对投放码或让老师重新发布活动</p>
<button type="button" @click="backToCodeEntry">重新输入投放码</button>
</section>
<section v-else-if="activity.status !== 'active'" class="state-card">
<el-icon><Warning /></el-icon>
<strong>{{ activity.status === 'closed' ? '活动已结束' : '活动暂未发布' }}</strong>
<p>请等待老师重新发布或切换到新的投放链接</p>
<button type="button" @click="loadActivity">重新加载</button>
</section>
<section v-else-if="submitted" class="state-card success">
<el-icon><Check /></el-icon>
<strong>提交成功</strong>
<p>你的回答已进入课堂数据统计</p>
<button type="button" @click="submitAnother">继续提交</button>
</section>
<section v-else class="activity-card">
<div class="activity-head">
<span>{{ activityTypeLabel(activity.activity_type) }}</span>
<small>{{ statusText[activity.status] || activity.status }}</small>
</div>
<h1>{{ activity.title }}</h1>
<p>{{ activity.description || '请根据课堂要求完成提交。' }}</p>
<div v-if="displayQuestion" class="question-box">
{{ displayQuestion }}
</div>
<label class="field-label">姓名</label>
<input v-model="form.student_name" class="text-input" placeholder="请输入姓名,可匿名" />
<template v-if="hasOptions">
<label class="field-label">选择答案</label>
<div class="option-list">
<button
v-for="option in activity.config.options"
:key="option"
type="button"
:class="{ active: form.answer === option }"
@click="form.answer = option"
>
{{ option }}
</button>
</div>
</template>
<template v-else>
<label class="field-label">我的回答</label>
<textarea v-model="form.answer" class="answer-input" placeholder="请输入你的回答"></textarea>
</template>
<button class="submit-btn" type="button" :disabled="submitting" @click="handleSubmit">
<span v-if="submitting" class="loader small"></span>
{{ submitting ? '提交中' : '提交回答' }}
</button>
</section>
</main>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getPublicClassroomActivity, submitClassroomResponse } from '@/api/classroom'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const submitting = ref(false)
const submitted = ref(false)
const activity = ref<any>(null)
const joinCode = ref('')
const form = reactive({
student_name: '',
answer: '',
})
const statusText: Record<string, string> = {
draft: '草稿',
active: '进行中',
closed: '已结束',
}
const hasOptions = computed(() => Array.isArray(activity.value?.config?.options) && activity.value.config.options.length > 0)
const displayQuestion = computed(() => {
const raw = activity.value?.config?.question || activity.value?.description || ''
return String(raw).replace(/\n\n【参考素材】[\s\S]*$/g, '').trim()
})
const needsCode = computed(() => !route.params.id && !route.query.code)
onMounted(loadActivity)
async function loadActivity() {
const id = Number(route.params.id || route.query.code)
if (!Number.isFinite(id) || id <= 0) {
loading.value = false
activity.value = null
return
}
loading.value = true
try {
activity.value = await getPublicClassroomActivity(id)
form.answer = activity.value?.config?.options?.[0] || ''
} catch {
activity.value = null
} finally {
loading.value = false
}
}
function joinByCode() {
const code = joinCode.value.trim()
if (!/^\d+$/.test(code)) {
ElMessage.warning('请输入数字投放码')
return
}
router.push(`/classroom/join/${code}`)
}
function backToCodeEntry() {
activity.value = null
joinCode.value = ''
router.push('/classroom/join')
}
async function handleSubmit() {
if (!activity.value) return
const answer = String(form.answer || '').trim()
if (!answer) {
ElMessage.warning('请填写或选择回答')
return
}
submitting.value = true
try {
await submitClassroomResponse(activity.value.id, {
student_name: form.student_name.trim() || '匿名',
answer,
meta: {
source: 'student_public_page',
user_agent: navigator.userAgent,
},
})
submitted.value = true
} finally {
submitting.value = false
}
}
function submitAnother() {
submitted.value = false
form.student_name = ''
form.answer = activity.value?.config?.options?.[0] || ''
}
function activityTypeLabel(type: string) {
const map: Record<string, string> = {
poll: '课堂投票',
qa: '即时问答',
quiz: '随堂测验',
checkin: '课堂签到',
feedback: '学习反馈',
}
return map[type] || '课堂活动'
}
</script>
<style scoped>
.join-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background:
linear-gradient(180deg, rgba(255,255,255,.72), rgba(248,250,247,.92)),
#f8faf7;
color: #173f35;
}
.join-shell {
width: min(100%, 520px);
}
.join-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
}
.brand-mark {
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #cfe0d3;
border-radius: 50%;
background: #fff;
color: #0f4f3e;
font-size: 20px;
font-weight: 900;
}
.join-header strong {
display: block;
color: #0f4f3e;
font-size: 20px;
}
.join-header p {
margin: 4px 0 0;
color: #71847c;
font-size: 13px;
}
.activity-card,
.code-card,
.state-card {
border: 1px solid #cfe0d3;
border-radius: 18px;
background: rgba(255,255,255,.92);
box-shadow: 0 18px 42px rgba(17, 69, 52, .08);
padding: 24px;
}
.code-head {
display: grid;
gap: 8px;
text-align: center;
}
.code-head span {
width: fit-content;
display: inline-flex;
justify-self: center;
border-radius: 999px;
background: #e8f5ea;
color: #0f4f3e;
padding: 5px 12px;
font-size: 13px;
font-weight: 900;
}
.code-head strong {
color: #0f4f3e;
font-size: 24px;
}
.code-head p {
margin: 0;
color: #71847c;
line-height: 1.65;
}
.code-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
margin-top: 22px;
}
.code-input {
width: 100%;
height: 46px;
border: 1px solid #dce8de;
border-radius: 13px;
background: #fff;
color: #173f35;
outline: none;
padding: 0 15px;
font-family: inherit;
font-size: 18px;
font-weight: 900;
letter-spacing: 0;
}
.code-input:focus {
border-color: #0f8a5f;
}
.code-form button {
height: 46px;
border: none;
border-radius: 13px;
background: #0f5a42;
color: #fff;
padding: 0 18px;
font-weight: 900;
cursor: pointer;
}
.activity-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.activity-head span,
.activity-head small {
height: 26px;
display: inline-flex;
align-items: center;
border-radius: 13px;
padding: 0 10px;
font-size: 12px;
font-weight: 900;
}
.activity-head span {
background: #e8f5ea;
color: #0f4f3e;
}
.activity-head small {
background: #f1f5f9;
color: #5c6f67;
}
.activity-card h1 {
margin: 18px 0 8px;
color: #0f4f3e;
font-size: 28px;
line-height: 1.2;
}
.activity-card p {
margin: 0 0 22px;
color: #5d7169;
line-height: 1.7;
}
.question-box {
margin: -6px 0 22px;
border: 1px solid #dce8de;
border-radius: 12px;
background: #f7faf6;
color: #173f35;
padding: 13px;
line-height: 1.7;
font-weight: 800;
}
.field-label {
display: block;
margin: 18px 0 8px;
color: #31584d;
font-size: 13px;
font-weight: 900;
}
.text-input,
.answer-input {
width: 100%;
border: 1px solid #dce8de;
border-radius: 12px;
background: #fff;
color: #173f35;
outline: none;
font-family: inherit;
font-size: 15px;
}
.text-input {
height: 42px;
padding: 0 13px;
}
.answer-input {
min-height: 136px;
resize: vertical;
padding: 13px;
line-height: 1.65;
}
.text-input:focus,
.answer-input:focus {
border-color: #0f8a5f;
}
.option-list {
display: grid;
gap: 10px;
}
.option-list button {
min-height: 48px;
border: 1px solid #dce8de;
border-radius: 12px;
background: #fff;
color: #31584d;
font-size: 15px;
font-weight: 800;
text-align: left;
padding: 0 14px;
cursor: pointer;
}
.option-list button.active {
border-color: #0f8a5f;
background: #e8f5ea;
color: #0f4f3e;
}
.submit-btn {
width: 100%;
height: 46px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 22px;
border: none;
border-radius: 13px;
background: #0f5a42;
color: #fff;
font-size: 16px;
font-weight: 900;
cursor: pointer;
}
.submit-btn:disabled {
opacity: .62;
cursor: not-allowed;
}
.state-card {
min-height: 220px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: #75867f;
text-align: center;
}
.state-card .el-icon {
font-size: 36px;
color: #0f8a5f;
}
.state-card strong {
color: #173f35;
font-size: 20px;
}
.state-card p {
margin: 0;
}
.state-card button {
height: 38px;
border: none;
border-radius: 19px;
background: #0f5a42;
color: #fff;
padding: 0 18px;
font-weight: 900;
cursor: pointer;
}
.loader {
width: 28px;
height: 28px;
display: inline-block;
border: 3px solid #dce8de;
border-top-color: #0f8a5f;
border-radius: 50%;
animation: spin .7s linear infinite;
}
.loader.small {
width: 16px;
height: 16px;
border-width: 2px;
border-top-color: #fff;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 560px) {
.code-form {
grid-template-columns: 1fr;
}
}
</style>
+493
View File
@@ -0,0 +1,493 @@
<template>
<div class="page-container">
<div class="page-header">
<div>
<h2>模板社区</h2>
<p>交流互动课件教学动画和课堂工具模板沉淀可改编的教学案例</p>
</div>
<button class="create-btn" @click="openCreateDialog">
<el-icon><EditPen /></el-icon> 发布模板
</button>
</div>
<div class="scope-tabs">
<button
v-for="scope in scopeOptions"
:key="scope.value"
type="button"
:class="{ active: activeScope === scope.value }"
@click="setScope(scope.value)"
>
{{ scope.label }}
</button>
</div>
<div class="community-tabs">
<button
v-for="tag in ['全部', ...hotTags]"
:key="tag"
type="button"
:class="{ active: activeTag === tag }"
@click="activeTag = tag"
>
{{ tag }}
</button>
</div>
<div class="community-layout">
<div class="posts-section">
<div v-loading="loading" class="post-list">
<div v-for="post in filteredPosts" :key="post.id" class="post-card" @click="openPost(post)">
<div class="post-cover" :class="postCover(post)">
<span>{{ postTypeLabel(post.post_type) }}</span>
</div>
<div class="post-body">
<div class="post-type-badge">
<el-tag :type="post.post_type === 'share' ? 'success' : post.post_type === 'help' ? 'warning' : undefined" size="small" effect="plain">
{{ postTypeLabel(post.post_type) }}
</el-tag>
</div>
<h3>{{ post.title }}</h3>
<div class="post-meta">
<span>{{ post.author?.name || '社区教师' }}</span>
<span v-if="primaryAttachment(post)">{{ resourceTypeLabel(primaryAttachment(post).resource_type) }}</span>
<span v-if="post.is_favorited">已收藏</span>
</div>
<p class="post-excerpt">{{ postExcerpt(post) }}</p>
<div class="post-tags">
<el-tag v-for="tag in post.tags" :key="tag" size="small" effect="plain" style="margin-right: 4px">{{ tag }}</el-tag>
</div>
<div class="post-stats">
<span><el-icon><View /></el-icon> {{ post.views }}</span>
<span><el-icon><Star /></el-icon> {{ post.likes }}</span>
<span><el-icon><ChatDotRound /></el-icon> {{ post.comments_count }}</span>
</div>
<div class="post-actions">
<button type="button" @click.stop="openPost(post)">查看详情</button>
<button type="button" class="primary" @click.stop="useTemplate(post)">使用模板</button>
<template v-if="isOwner(post)">
<button type="button" @click.stop="openEditDialog(post)">
<el-icon><Edit /></el-icon> 编辑
</button>
<el-popconfirm title="确定删除这个社区模板?" @confirm="handleDeletePost(post)">
<template #reference>
<button type="button" class="danger" @click.stop>
<el-icon><Delete /></el-icon> 删除
</button>
</template>
</el-popconfirm>
</template>
</div>
</div>
</div>
</div>
<div v-if="!loading && filteredPosts.length === 0" class="empty-state">
<el-icon :size="48" color="#CBD5E1"><ChatDotRound /></el-icon>
<p>{{ emptyText }}</p>
<button type="button" @click="openCreateDialog">发布第一个模板</button>
</div>
</div>
<div class="sidebar-section">
<div class="hot-tags-card">
<h4>热门标签</h4>
<div class="hot-tags">
<span v-for="tag in hotTags" :key="tag" class="hot-tag" @click="activeTag = tag">{{ tag }}</span>
</div>
</div>
<div class="hot-tags-card">
<h4>推荐模板</h4>
<div v-if="rankedPosts.length" class="template-rank">
<button v-for="(item, index) in rankedPosts" :key="item.id" type="button" @click="openPost(item)">
<span :class="rankTheme(index)">{{ index + 1 }}</span>
<div>
<strong>{{ item.title }}</strong>
<small>{{ primaryTag(item) }} · {{ formatCount(item.views) }}浏览</small>
</div>
</button>
</div>
<div v-else class="rank-empty">暂无推荐模板</div>
</div>
</div>
</div>
<el-dialog v-model="showCreate" :title="editingPost ? '编辑模板' : '发布模板'" width="600px" :close-on-click-modal="false">
<el-form :model="postForm" label-position="top">
<el-form-item label="标题">
<el-input v-model="postForm.title" placeholder="模板标题" />
</el-form-item>
<el-form-item label="类型">
<div class="type-select">
<label v-for="t in postTypes" :key="t.value" class="type-option" :class="{ active: postForm.post_type === t.value }" @click="postForm.post_type = t.value">
{{ t.label }}
</label>
</div>
</el-form-item>
<el-form-item label="内容">
<el-input v-model="postForm.content" type="textarea" :rows="8" placeholder="描述模板适用的学科、年级、教学场景和使用方法..." />
</el-form-item>
<el-form-item label="标签">
<el-select v-model="postForm.tags" multiple filterable allow-create placeholder="添加标签" style="width: 100%">
</el-select>
</el-form-item>
<el-form-item label="关联资源">
<el-select v-model="postForm.resource_id" clearable filterable placeholder="选择一个已发布资源作为模板来源" style="width: 100%" @visible-change="loadMyResourcesIfNeeded">
<el-option
v-for="item in myResources"
:key="item.id"
:label="`${item.title} · ${resourceTypeLabel(item.resource_type)}`"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreate = false">取消</el-button>
<el-button type="primary" @click="handleSubmitPost">{{ editingPost ? '保存修改' : '发布' }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, ref, reactive, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getPosts, getPostRanking, createPost, updatePost, deletePost } from '@/api/community'
import { getMyResources } from '@/api/resource'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const loading = ref(false)
const posts = ref<any[]>([])
const rankedPosts = ref<any[]>([])
const hotTags = ['互动课件', '教学动画', '课堂游戏', 'AI工具', '公开课', '单元复习', '实验模拟']
const postTypes = [{ value: 'discussion', label: '案例' }, { value: 'share', label: '模板' }, { value: 'help', label: '需求' }]
const scopeOptions = [
{ value: 'all', label: '全部模板' },
{ value: 'mine', label: '我的发布' },
{ value: 'favorite', label: '我的收藏' },
]
const resourceTypes = [
{ value: 'courseware', label: '课件' },
{ value: 'animation', label: '动画' },
{ value: 'exercise', label: '习题' },
{ value: 'lesson_plan', label: '教案' },
{ value: 'exam', label: '试卷' },
{ value: 'other', label: '资源' },
]
const activeTag = ref('全部')
const activeScope = ref('all')
const showCreate = ref(false)
const editingPost = ref<any>(null)
const myResources = ref<any[]>([])
const loadingResources = ref(false)
const postForm = reactive({ title: '', content: '', post_type: 'discussion', tags: [] as string[], resource_id: null as number | null })
const isLoggedIn = computed(() => !!localStorage.getItem('access_token'))
const currentUserId = computed(() => userStore.user?.id)
const emptyText = computed(() => {
if (activeScope.value === 'mine') return '还没有发布模板'
if (activeScope.value === 'favorite') return '还没有收藏模板'
return '暂无模板内容'
})
const filteredPosts = computed(() => {
if (activeTag.value === '全部') return posts.value
return posts.value.filter(post => post.tags?.includes(activeTag.value))
})
function postTypeLabel(type: string) {
return postTypes.find(item => item.value === type)?.label || '讨论'
}
function resourceTypeLabel(type: string) {
return resourceTypes.find(item => item.value === type)?.label || '资源'
}
function primaryTag(post: any) {
return post.tags?.[0] || postTypeLabel(post.post_type)
}
function postExcerpt(post: any) {
const content = String(post.content || '')
return content.length > 150 ? `${content.slice(0, 150)}...` : content
}
function rankTheme(index: number) {
return ['rank-cream', 'rank-yellow', 'rank-mint'][index % 3]
}
function formatCount(value: any) {
const n = Number(value || 0)
if (n >= 10000) return `${(n / 10000).toFixed(1)}`
return String(n)
}
function postCover(post: any) {
if (post.post_type === 'share') return 'cover-green'
if (post.post_type === 'help') return 'cover-yellow'
return 'cover-blue'
}
function targetPathForPost(post: any) {
const attachment = primaryAttachment(post)
if (attachment?.content_ref) return pathFromContentRef(attachment.content_ref)
const tags = post.tags || []
if (tags.includes('教学动画') || tags.includes('实验模拟')) return '/animation'
if (tags.includes('课堂游戏')) return '/exercise'
if (tags.includes('AI工具')) return '/classroom'
if (tags.includes('单元复习')) return '/lesson-plan'
return '/courseware/create'
}
function useTemplate(post: any) {
const attachment = primaryAttachment(post)
if (attachment?.resource_id) {
router.push(`/resources/${attachment.resource_id}`)
return
}
const prompt = `参考社区模板《${post.title}》进行原创改编。模板说明:${post.content || ''}`
localStorage.setItem('quick_create_prompt', prompt)
router.push(targetPathForPost(post))
}
function primaryAttachment(post: any) {
return Array.isArray(post.attachments) ? post.attachments[0] : null
}
function pathFromContentRef(ref: string) {
const [type, id] = String(ref).split(':')
if (type === 'courseware') return `/courseware/${id}`
if (type === 'animation') return `/animation?open=${id}`
if (type === 'exercise') return `/exercise?open=${id}`
if (type === 'lesson_plan') return `/lesson-plan?open=${id}`
if (type === 'exam') return `/exam?open=${id}`
return '/courseware/create'
}
async function loadData() {
loading.value = true
try {
const params = activeScope.value === 'all' ? undefined : { scope: activeScope.value }
const [res, ranking] = await Promise.all([
getPosts(params),
getPostRanking({ limit: 5 }),
]) as any[]
posts.value = Array.isArray(res) ? res : []
rankedPosts.value = Array.isArray(ranking) ? ranking : []
} catch {
posts.value = []
rankedPosts.value = []
}
finally { loading.value = false }
}
function syncScopeFromRoute() {
const scope = route.query.scope
const nextScope = scopeOptions.some(item => item.value === scope) ? String(scope) : 'all'
if (nextScope !== 'all' && !ensureLoggedIn(`/community?scope=${nextScope}`)) {
activeScope.value = 'all'
return false
}
activeScope.value = nextScope
activeTag.value = '全部'
return true
}
function resetPostForm() {
postForm.title = ''
postForm.content = ''
postForm.post_type = 'discussion'
postForm.tags = []
postForm.resource_id = null
editingPost.value = null
}
function openCreateDialog() {
if (!ensureLoggedIn('/community')) return
resetPostForm()
showCreate.value = true
}
function openEditDialog(post: any) {
if (!ensureLoggedIn('/community')) return
editingPost.value = post
postForm.title = post.title || ''
postForm.content = post.content || ''
postForm.post_type = post.post_type || 'discussion'
postForm.tags = Array.isArray(post.tags) ? [...post.tags] : []
postForm.resource_id = primaryAttachment(post)?.resource_id || null
showCreate.value = true
}
async function handleSubmitPost() {
if (!ensureLoggedIn('/community')) return
if (!postForm.title.trim() || !postForm.content.trim()) {
ElMessage.warning('请填写标题和内容')
return
}
const selectedResource = myResources.value.find(item => item.id === postForm.resource_id)
const payload = {
title: postForm.title,
content: postForm.content,
post_type: postForm.post_type,
tags: postForm.tags,
attachments: selectedResource ? [{
resource_id: selectedResource.id,
resource_type: selectedResource.resource_type,
content_ref: selectedResource.content_ref,
title: selectedResource.title,
}] : [],
}
if (editingPost.value) {
await updatePost(editingPost.value.id, payload)
ElMessage.success('已保存修改')
} else {
await createPost(payload)
ElMessage.success('发布成功')
}
showCreate.value = false
resetPostForm()
loadData()
}
async function openPost(post: any) {
router.push(`/community/${post.id}`)
}
async function handleDeletePost(post: any) {
if (!ensureLoggedIn('/community')) return
await deletePost(post.id)
ElMessage.success('已删除模板')
loadData()
}
async function setScope(scope: string) {
if (scope !== 'all' && !ensureLoggedIn('/community')) return
activeScope.value = scope
activeTag.value = '全部'
const nextScope = scope === 'all' ? undefined : scope
if (route.query.scope !== nextScope) {
await router.replace({ path: '/community', query: scope === 'all' ? {} : { scope } })
return
}
await loadData()
}
function isOwner(post: any) {
return currentUserId.value && post.user_id === currentUserId.value
}
async function loadMyResourcesIfNeeded(open: boolean) {
if (!open || myResources.value.length || loadingResources.value) return
if (!ensureLoggedIn('/community')) return
loadingResources.value = true
try {
const data: any = await getMyResources({ limit: 100 })
myResources.value = Array.isArray(data?.data) ? data.data : Array.isArray(data) ? data : []
} finally {
loadingResources.value = false
}
}
function ensureLoggedIn(redirect: string) {
if (localStorage.getItem('access_token')) return true
ElMessage.warning('请先登录后再参与社区互动')
router.push({ path: '/login', query: { redirect } })
return false
}
onMounted(async () => {
if (localStorage.getItem('access_token') && !userStore.user) {
await userStore.fetchProfile().catch(() => undefined)
}
if (syncScopeFromRoute()) loadData()
})
watch(() => route.query.scope, () => {
if (syncScopeFromRoute()) loadData()
})
</script>
<style scoped>
.page-header p { margin-top: 6px; color: var(--text-muted); font-size: 14px; }
.create-btn { display: flex; align-items: center; gap: 6px; background: var(--gradient); border: none; color: #fff; padding: 10px 20px; border-radius: var(--radius-sm); font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.3s; }
.create-btn:hover { opacity: 0.9; }
.scope-tabs,
.community-tabs { display: flex; align-items: center; gap: 10px; overflow-x: auto; scrollbar-width: none; }
.scope-tabs { margin-bottom: 10px; }
.community-tabs { margin-bottom: 18px; }
.scope-tabs::-webkit-scrollbar,
.community-tabs::-webkit-scrollbar { display: none; }
.scope-tabs button,
.community-tabs button { height: 34px; border: 1px solid #dbe8de; border-radius: 17px; background: #fff; color: #51665f; padding: 0 14px; white-space: nowrap; cursor: pointer; }
.scope-tabs button.active,
.scope-tabs button:hover,
.community-tabs button.active,
.community-tabs button:hover { border-color: #9ed0aa; background: #eaf7e9; color: #00543d; font-weight: 800; }
.community-layout { display: grid; grid-template-columns: 1fr 280px; gap: 24px; }
.post-list { display: flex; flex-direction: column; gap: 12px; }
.post-card { display: grid; grid-template-columns: 170px minmax(0, 1fr); gap: 16px; background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 12px; cursor: pointer; transition: all 0.3s; }
.post-card:hover { box-shadow: var(--shadow-md); transform: translateY(-1px); }
.post-cover { position: relative; min-height: 124px; border-radius: 10px; overflow: hidden; }
.post-cover span { position: absolute; left: 10px; bottom: 10px; height: 24px; display: inline-flex; align-items: center; padding: 0 9px; border-radius: 12px; background: rgba(255,255,255,.88); color: #00543d; font-size: 12px; font-weight: 900; }
.cover-green { background: linear-gradient(135deg, #064231, #00844e); }
.cover-yellow { background: linear-gradient(135deg, #fff1a1, #ffd84d); }
.cover-blue { background: linear-gradient(135deg, #dbe8ff, #c8ddff); }
.post-body { min-width: 0; padding: 6px 4px; }
.post-type-badge { margin-bottom: 8px; }
.post-body h3 { font-size: 16px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; }
.post-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; margin-bottom: 7px; color: #75867f; font-size: 12px; }
.post-meta span { display: inline-flex; align-items: center; height: 22px; border-radius: 11px; background: #f1f6f1; padding: 0 8px; }
.post-excerpt { font-size: 14px; color: var(--text-muted); line-height: 1.6; margin-bottom: 12px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.post-tags { margin-bottom: 12px; }
.post-stats { display: flex; gap: 16px; font-size: 13px; color: var(--text-muted); }
.post-stats span { display: flex; align-items: center; gap: 4px; }
.post-actions { display: flex; gap: 8px; margin-top: 12px; }
.post-actions button { height: 32px; border: 1px solid #dbe8de; border-radius: 8px; background: #fff; color: #31584d; padding: 0 12px; font-weight: 800; cursor: pointer; }
.post-actions button .el-icon { margin-right: 4px; vertical-align: -2px; }
.post-actions button.primary { border-color: #00543d; background: #00543d; color: #fff; }
.post-actions button.danger { border-color: #f3c9c9; color: #b42318; }
.post-actions button:hover { border-color: #9ed0aa; background: #f6fbf6; color: #00543d; }
.post-actions button.primary:hover { background: #004532; color: #fff; }
.post-actions button.danger:hover { border-color: #f0a8a8; background: #fff5f5; color: #9f1c14; }
.sidebar-section { position: sticky; top: 32px; }
.hot-tags-card { background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 20px; margin-bottom: 14px; }
.hot-tags-card h4 { font-size: 15px; font-weight: 600; color: var(--text-primary); margin-bottom: 12px; }
.hot-tags { display: flex; flex-wrap: wrap; gap: 8px; }
.hot-tag { padding: 4px 12px; background: var(--primary-bg); color: var(--primary); border-radius: 20px; font-size: 13px; cursor: pointer; transition: all 0.2s; }
.hot-tag:hover { background: var(--primary); color: #fff; }
.template-rank { display: flex; flex-direction: column; gap: 10px; }
.template-rank button { display: grid; grid-template-columns: 56px minmax(0, 1fr); gap: 10px; align-items: center; border: none; background: transparent; padding: 0; text-align: left; cursor: pointer; }
.template-rank button > span { height: 42px; border-radius: 8px; display: inline-flex; align-items: center; justify-content: center; color: #173f35; font-weight: 900; }
.rank-cream { background: #f7e3a1; }
.rank-yellow { background: #ffd84d; }
.rank-mint { background: #dff7e5; }
.template-rank strong { display: block; color: #173f35; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.template-rank small { color: #87958f; font-size: 12px; }
.rank-empty { border: 1px dashed #dbe8de; border-radius: 10px; color: #87958f; font-size: 13px; padding: 14px; text-align: center; }
.type-select { display: flex; gap: 8px; }
.type-option { padding: 8px 20px; border: 1px solid var(--border-color); border-radius: var(--radius-sm); font-size: 14px; color: var(--text-secondary); cursor: pointer; transition: all 0.2s; }
.type-option.active { background: var(--primary-bg); border-color: var(--primary); color: var(--primary); font-weight: 500; }
.empty-state { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 60px 0; }
.empty-state p { color: var(--text-muted); font-size: 15px; }
.empty-state button { border: none; border-radius: 18px; background: #00543d; color: #fff; padding: 8px 18px; font-weight: 800; cursor: pointer; }
@media (max-width: 980px) {
.community-layout { grid-template-columns: 1fr; }
.sidebar-section { position: static; }
}
@media (max-width: 640px) {
.post-card { grid-template-columns: 1fr; }
.post-actions { flex-direction: column; }
}
</style>
+461
View File
@@ -0,0 +1,461 @@
<template>
<div class="community-detail-page">
<section class="detail-shell" v-loading="loading">
<div class="top-row">
<button type="button" class="back-btn" @click="router.push('/community')">
<el-icon><ArrowLeft /></el-icon>
返回模板社区
</button>
<div class="top-actions">
<button v-if="isOwner" type="button" class="share-btn" @click="openEditDialog">
<el-icon><Edit /></el-icon>
编辑
</button>
<el-popconfirm v-if="isOwner" title="确定删除这个社区模板?" @confirm="handleDeletePost">
<template #reference>
<button type="button" class="share-btn danger">
<el-icon><Delete /></el-icon>
删除
</button>
</template>
</el-popconfirm>
<button type="button" class="share-btn" @click="sharePost">
<el-icon><Share /></el-icon>
分享
</button>
</div>
</div>
<div v-if="post" class="detail-layout">
<main class="post-panel">
<div class="post-cover" :class="postCover(post)">
<span>{{ postTypeLabel(post.post_type) }}</span>
<h1>{{ post.title }}</h1>
<p>{{ post.tags?.join(' · ') || '教学模板' }}</p>
</div>
<article class="content-block">
<h2>模板说明</h2>
<div class="author-line" v-if="post.author">
<span>{{ post.author.name?.[0] || '师' }}</span>
<strong>{{ post.author.name || '智教教师' }}</strong>
<em>{{ [post.author.school, post.author.subject].filter(Boolean).join(' · ') || '社区创作者' }}</em>
</div>
<p>{{ post.content }}</p>
</article>
<article class="content-block" v-if="primaryAttachment">
<h2>关联资源</h2>
<button type="button" class="attachment-card" @click="openAttachment">
<span>{{ resourceTypeLabel(primaryAttachment.resource_type) }}</span>
<strong>{{ primaryAttachment.title || '社区模板资源' }}</strong>
<em>打开资源详情或作品编辑入口</em>
</button>
</article>
<article class="content-block">
<h2>使用建议</h2>
<div class="tips-grid">
<div v-for="item in usageTips" :key="item.title">
<strong>{{ item.title }}</strong>
<span>{{ item.desc }}</span>
</div>
</div>
</article>
<article class="content-block">
<h2>评论 {{ post.comments_count || comments.length }}</h2>
<div class="comment-list" v-if="comments.length">
<div v-for="comment in comments" :key="comment.id" class="comment-item">
<div class="comment-avatar">{{ comment.author?.name?.[0] || 'U' }}</div>
<div>
<p>{{ comment.content }}</p>
<span>{{ comment.created_at || '刚刚' }}</span>
</div>
<el-popconfirm v-if="canDeleteComment(comment)" title="确定删除这条评论?" @confirm="handleDeleteComment(comment)">
<template #reference>
<button type="button" class="comment-delete">
<el-icon><Delete /></el-icon>
</button>
</template>
</el-popconfirm>
</div>
</div>
<div v-else class="comment-empty">暂无评论登录后可以参与讨论</div>
<div class="comment-input">
<el-input v-model="commentText" placeholder="写评论...">
<template #append>
<el-button @click="handleComment">{{ isLoggedIn ? '发送' : '登录评论' }}</el-button>
</template>
</el-input>
</div>
</article>
</main>
<aside class="side-panel">
<div class="stats-card">
<div><strong>{{ formatCount(post.views) }}</strong><span>浏览</span></div>
<div><strong>{{ formatCount(post.likes) }}</strong><span>点赞</span></div>
<div><strong>{{ formatCount(post.comments_count || comments.length) }}</strong><span>评论</span></div>
</div>
<div class="action-stack">
<button type="button" class="primary" @click="useTemplate(post)">
<el-icon><MagicStick /></el-icon>
使用模板生成
</button>
<button type="button" @click="likeCurrent">
<el-icon><Star /></el-icon>
{{ post.is_favorited ? '已收藏,点击取消' : '收藏模板' }}
</button>
<button type="button" @click="sharePost">
<el-icon><Share /></el-icon>
复制链接
</button>
</div>
<div class="action-stack danger-zone" v-if="isOwner">
<button type="button" @click="openEditDialog">
<el-icon><Edit /></el-icon>
编辑模板
</button>
<el-popconfirm title="确定删除这个社区模板?" @confirm="handleDeletePost">
<template #reference>
<button type="button" class="danger">
<el-icon><Delete /></el-icon>
删除模板
</button>
</template>
</el-popconfirm>
</div>
<div class="tag-card" v-if="post.tags?.length">
<strong>标签</strong>
<div>
<span v-for="tag in post.tags" :key="tag">{{ tag }}</span>
</div>
</div>
</aside>
</div>
<div v-else-if="!loading" class="empty-state">
<el-icon><ChatDotRound /></el-icon>
<strong>帖子不存在</strong>
<button type="button" @click="router.push('/community')">返回模板社区</button>
</div>
</section>
<el-dialog v-model="showEdit" title="编辑模板" width="600px" :close-on-click-modal="false">
<el-form :model="editForm" label-position="top">
<el-form-item label="标题">
<el-input v-model="editForm.title" placeholder="模板标题" />
</el-form-item>
<el-form-item label="类型">
<div class="type-select">
<label
v-for="t in postTypes"
:key="t.value"
class="type-option"
:class="{ active: editForm.post_type === t.value }"
@click="editForm.post_type = t.value"
>
{{ t.label }}
</label>
</div>
</el-form-item>
<el-form-item label="内容">
<el-input v-model="editForm.content" type="textarea" :rows="8" />
</el-form-item>
<el-form-item label="标签">
<el-select v-model="editForm.tags" multiple filterable allow-create placeholder="添加标签" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEdit = false">取消</el-button>
<el-button type="primary" @click="handleUpdatePost">保存修改</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { createComment, deleteComment, deletePost, getComments, getPost, likePost, unlikePost, updatePost } from '@/api/community'
import { useUserStore } from '@/stores/user'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const loading = ref(false)
const post = ref<any>(null)
const comments = ref<any[]>([])
const commentText = ref('')
const showEdit = ref(false)
const editForm = reactive({ title: '', content: '', post_type: 'discussion', tags: [] as string[] })
const isLoggedIn = computed(() => !!localStorage.getItem('access_token'))
const currentUserId = computed(() => userStore.user?.id)
const isOwner = computed(() => post.value?.user_id && post.value.user_id === currentUserId.value)
const postTypes = [
{ value: 'discussion', label: '案例' },
{ value: 'share', label: '模板' },
{ value: 'help', label: '需求' },
]
const resourceTypes = [
{ value: 'courseware', label: '课件' },
{ value: 'animation', label: '动画' },
{ value: 'exercise', label: '习题' },
{ value: 'lesson_plan', label: '教案' },
{ value: 'exam', label: '试卷' },
{ value: 'other', label: '资源' },
]
const usageTips = computed(() => [
{ title: '快速改编', desc: '一键带入生成器,保留结构但输出原创内容。' },
{ title: '课堂复用', desc: '适合公开课、单元复习、课堂互动或课后练习。' },
{ title: '教研沉淀', desc: '可作为备课组案例,继续补充评论与改进建议。' },
])
const primaryAttachment = computed(() => Array.isArray(post.value?.attachments) ? post.value.attachments[0] : null)
function postTypeLabel(type: string) {
return postTypes.find(item => item.value === type)?.label || '讨论'
}
function resourceTypeLabel(type: string) {
return resourceTypes.find(item => item.value === type)?.label || '资源'
}
function postCover(item: any) {
if (item.post_type === 'share') return 'cover-green'
if (item.post_type === 'help') return 'cover-yellow'
return 'cover-blue'
}
function formatCount(value: any) {
const n = Number(value || 0)
if (n >= 10000) return `${(n / 10000).toFixed(1)}`
return String(n)
}
function targetPathForPost(item: any) {
const attachment = Array.isArray(item.attachments) ? item.attachments[0] : null
if (attachment?.content_ref) return pathFromContentRef(attachment.content_ref)
const tags = item.tags || []
if (tags.includes('教学动画') || tags.includes('实验模拟')) return '/animation'
if (tags.includes('课堂游戏')) return '/exercise'
if (tags.includes('AI工具')) return '/classroom'
if (tags.includes('单元复习')) return '/lesson-plan'
return '/courseware/create'
}
function useTemplate(item: any) {
const attachment = Array.isArray(item.attachments) ? item.attachments[0] : null
if (attachment?.resource_id) {
router.push(`/resources/${attachment.resource_id}`)
return
}
const prompt = `参考社区模板《${item.title}》进行原创改编。模板说明:${item.content || ''}`
localStorage.setItem('quick_create_prompt', prompt)
router.push(targetPathForPost(item))
}
function pathFromContentRef(ref: string) {
const [type, id] = String(ref).split(':')
if (type === 'courseware') return `/courseware/${id}`
if (type === 'animation') return `/animation?open=${id}`
if (type === 'exercise') return `/exercise?open=${id}`
if (type === 'lesson_plan') return `/lesson-plan?open=${id}`
if (type === 'exam') return `/exam?open=${id}`
return '/courseware/create'
}
function openAttachment() {
if (!primaryAttachment.value) return
if (primaryAttachment.value.resource_id) {
router.push(`/resources/${primaryAttachment.value.resource_id}`)
return
}
if (primaryAttachment.value.content_ref) {
router.push(pathFromContentRef(primaryAttachment.value.content_ref))
}
}
async function likeCurrent() {
if (!post.value?.id) return
if (!isLoggedIn.value) {
ElMessage.warning('请先登录后再收藏模板')
router.push({ path: '/login', query: { redirect: route.fullPath } })
return
}
const data: any = post.value.is_favorited
? await unlikePost(post.value.id)
: await likePost(post.value.id)
post.value.likes = data.likes
post.value.is_favorited = data.is_favorited
ElMessage.success(data.is_favorited ? '已收藏' : '已取消收藏')
}
async function handleComment() {
if (!post.value || !commentText.value.trim()) return
if (!isLoggedIn.value) {
ElMessage.warning('请先登录后再评论')
router.push({ path: '/login', query: { redirect: route.fullPath } })
return
}
await createComment(post.value.id, { content: commentText.value })
commentText.value = ''
comments.value = (await getComments(post.value.id)) as any
post.value.comments_count = comments.value.length
}
function openEditDialog() {
if (!post.value) return
editForm.title = post.value.title || ''
editForm.content = post.value.content || ''
editForm.post_type = post.value.post_type || 'discussion'
editForm.tags = Array.isArray(post.value.tags) ? [...post.value.tags] : []
showEdit.value = true
}
async function handleUpdatePost() {
if (!post.value?.id) return
if (!editForm.title.trim() || !editForm.content.trim()) {
ElMessage.warning('请填写标题和内容')
return
}
const updated: any = await updatePost(post.value.id, {
title: editForm.title,
content: editForm.content,
post_type: editForm.post_type,
tags: editForm.tags,
})
post.value = updated
showEdit.value = false
ElMessage.success('已保存修改')
}
async function handleDeletePost() {
if (!post.value?.id) return
await deletePost(post.value.id)
ElMessage.success('已删除模板')
router.push('/community')
}
function canDeleteComment(comment: any) {
return currentUserId.value && (comment.user_id === currentUserId.value || post.value?.user_id === currentUserId.value)
}
async function handleDeleteComment(comment: any) {
if (!post.value?.id) return
await deleteComment(post.value.id, comment.id)
comments.value = comments.value.filter(item => item.id !== comment.id)
post.value.comments_count = comments.value.length
ElMessage.success('已删除评论')
}
function sharePost() {
navigator.clipboard?.writeText(window.location.href)
ElMessage.success('链接已复制')
}
onMounted(async () => {
loading.value = true
try {
if (localStorage.getItem('access_token') && !userStore.user) {
await userStore.fetchProfile().catch(() => undefined)
}
const id = Number(route.params.id)
post.value = await getPost(id) as any
comments.value = await getComments(id) as any
} catch {
post.value = null
comments.value = []
} finally {
loading.value = false
}
})
</script>
<style scoped>
.community-detail-page { min-height: calc(100vh - var(--header-height)); background: #f7faf6; padding: 28px 36px; }
.detail-shell { max-width: 1200px; margin: 0 auto; }
.top-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 18px; }
.top-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
.back-btn,
.share-btn { height: 36px; display: inline-flex; align-items: center; gap: 7px; border: 1px solid #dbe8de; border-radius: 18px; background: #fff; color: #31584d; padding: 0 14px; font-weight: 800; cursor: pointer; }
.share-btn.danger { border-color: #f3c9c9; color: #b42318; }
.share-btn.danger:hover { border-color: #f0a8a8; background: #fff5f5; }
.detail-layout { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 22px; }
.post-panel,
.side-panel,
.content-block,
.stats-card,
.tag-card { border: 1px solid #dce8de; border-radius: 14px; background: #fff; }
.post-panel { overflow: hidden; }
.post-cover { min-height: 300px; display: flex; flex-direction: column; justify-content: flex-end; padding: 38px; }
.post-cover span { width: fit-content; border-radius: 16px; background: rgba(255,255,255,.86); color: #00543d; padding: 5px 12px; font-size: 13px; font-weight: 900; }
.post-cover h1 { max-width: 720px; margin: 18px 0 10px; font-size: 40px; line-height: 1.14; }
.post-cover p { font-size: 14px; opacity: .78; }
.cover-green { color: #fff; background: linear-gradient(135deg, #064231, #00844e); }
.cover-yellow { color: #654100; background: linear-gradient(135deg, #fff1a1, #ffd84d); }
.cover-blue { color: #003e6b; background: linear-gradient(135deg, #dbe8ff, #c8ddff); }
.content-block { margin: 18px; padding: 20px; }
.content-block h2 { color: #173f35; font-size: 18px; margin-bottom: 12px; }
.content-block > p { color: #51665f; line-height: 1.9; white-space: pre-line; }
.author-line { display: flex; align-items: center; gap: 9px; margin-bottom: 14px; color: #75867f; }
.author-line span { width: 32px; height: 32px; display: inline-flex; align-items: center; justify-content: center; border-radius: 10px; background: #d9f0d6; color: #00543d; font-weight: 900; }
.author-line strong { color: #173f35; font-size: 14px; }
.author-line em { font-style: normal; font-size: 12px; }
.attachment-card { width: 100%; display: grid; gap: 7px; border: 1px solid #dce8de; border-radius: 12px; background: #f8faf8; padding: 14px; text-align: left; cursor: pointer; }
.attachment-card:hover { border-color: #9ed0aa; background: #f1faf3; }
.attachment-card span { width: fit-content; border-radius: 999px; background: #eaf7e9; color: #00543d; padding: 4px 9px; font-size: 12px; font-weight: 900; }
.attachment-card strong { color: #173f35; font-size: 16px; }
.attachment-card em { color: #75867f; font-size: 12px; font-style: normal; }
.tips-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.tips-grid div { border: 1px solid #dce8de; border-radius: 12px; background: #f8faf8; padding: 14px; }
.tips-grid strong { display: block; color: #00543d; margin-bottom: 6px; }
.tips-grid span { color: #6a7f77; font-size: 13px; line-height: 1.5; }
.comment-list { display: grid; gap: 12px; margin-bottom: 14px; }
.comment-item { position: relative; display: flex; gap: 10px; border: 1px solid #eef3ef; border-radius: 12px; padding: 12px; background: #fbfdfb; }
.comment-avatar { width: 32px; height: 32px; border-radius: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; background: #d9f0d6; color: #00543d; font-weight: 900; }
.comment-item p { color: #31584d; line-height: 1.6; }
.comment-item span,
.comment-empty { color: #75867f; font-size: 12px; }
.comment-empty { margin-bottom: 14px; }
.comment-delete { position: absolute; top: 10px; right: 10px; width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid #f3c9c9; border-radius: 8px; background: #fff; color: #b42318; cursor: pointer; }
.comment-delete:hover { background: #fff5f5; }
.side-panel { height: fit-content; position: sticky; top: 82px; padding: 16px; }
.stats-card { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; padding: 12px; text-align: center; }
.stats-card strong { display: block; color: #00543d; font-size: 20px; }
.stats-card span { color: #75867f; font-size: 12px; }
.action-stack { display: grid; gap: 10px; margin-top: 12px; }
.action-stack button { height: 40px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; border: 1px solid #dbe8de; border-radius: 10px; background: #fff; color: #31584d; font-weight: 900; cursor: pointer; }
.action-stack .primary { border-color: #00543d; background: #00543d; color: #fff; }
.action-stack .danger { border-color: #f3c9c9; color: #b42318; }
.action-stack .danger:hover { border-color: #f0a8a8; background: #fff5f5; }
.danger-zone { border-top: 1px solid #e7eee8; padding-top: 12px; }
.type-select { display: flex; gap: 8px; flex-wrap: wrap; }
.type-option { padding: 8px 20px; border: 1px solid #dce8de; border-radius: 8px; color: #51665f; cursor: pointer; transition: all .2s; }
.type-option.active { background: #eaf7e9; border-color: #00543d; color: #00543d; font-weight: 800; }
.tag-card { margin-top: 12px; padding: 14px; }
.tag-card strong { display: block; color: #173f35; margin-bottom: 10px; }
.tag-card div { display: flex; flex-wrap: wrap; gap: 8px; }
.tag-card span { border-radius: 999px; background: #edf7ee; color: #00543d; padding: 6px 10px; font-size: 12px; font-weight: 800; }
.empty-state { min-height: 360px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; border: 1px dashed #cfe0d3; border-radius: 14px; background: #fff; color: #75867f; }
.empty-state .el-icon { font-size: 36px; color: #9aaba4; }
.empty-state button { border: none; border-radius: 18px; background: #00543d; color: #fff; padding: 8px 18px; font-weight: 800; cursor: pointer; }
@media (max-width: 980px) {
.detail-layout,
.tips-grid { grid-template-columns: 1fr; }
.side-panel { position: static; }
}
@media (max-width: 640px) {
.community-detail-page { padding: 16px; }
.top-row { align-items: stretch; flex-direction: column; gap: 10px; }
.top-actions { justify-content: flex-start; }
.post-cover { min-height: 240px; padding: 28px; }
.post-cover h1 { font-size: 30px; }
}
</style>
File diff suppressed because it is too large Load Diff
+621
View File
@@ -0,0 +1,621 @@
<template>
<div class="page-container detail-page">
<div class="detail-topbar">
<div class="topbar-left">
<button class="back-btn" @click="router.back()">
<el-icon :size="16"><ArrowLeft /></el-icon>
<span>返回</span>
</button>
<div class="topbar-divider"></div>
<h2 class="detail-title" v-if="!editingTitle">{{ courseware?.title || '课件详情' }}</h2>
<el-input v-else v-model="courseware.title" size="default" style="max-width: 360px" @blur="editingTitle = false" @keyup.enter="editingTitle = false" />
<button class="icon-btn" v-if="courseware && !editingTitle" @click="editingTitle = true">
<el-icon :size="13"><Edit /></el-icon>
</button>
</div>
<div class="topbar-right">
<el-tag v-if="courseware?.status" :type="courseware.status === 'published' ? 'success' : 'info'" size="default" effect="plain" round>
{{ courseware.status === 'published' ? '已发布' : '草稿' }}
</el-tag>
<button class="action-btn" @click="openFullPreview">
<el-icon :size="15"><FullScreen /></el-icon>
全屏预览
</button>
<button class="action-btn" @click="handleExportHtml">
<el-icon :size="15"><Upload /></el-icon>
下载HTML
</button>
<button class="action-btn" :disabled="exportingDocx" @click="handleExportDocx">
<el-icon :size="15"><Document /></el-icon>
{{ exportingDocx ? '导出中' : '下载Word' }}
</button>
<button class="action-btn" :disabled="sharing" @click="handleShare">
<el-icon :size="15"><Share /></el-icon>
{{ sharing ? '生成中' : '分享链接' }}
</button>
<button class="action-btn publish" :disabled="publishing" @click="handlePublish">
<el-icon :size="15"><Share /></el-icon>
{{ publishing ? '发布中' : '发布资源' }}
</button>
<button class="action-btn primary" @click="handleSave">
<el-icon :size="15"><Check /></el-icon>
保存
</button>
</div>
</div>
<div class="meta-bar" v-if="courseware">
<div class="meta-chips">
<div class="meta-chip"><el-icon :size="14" color="var(--primary)"><Document /></el-icon><span>{{ courseware.content?.length || 0 }} </span></div>
<div class="meta-chip" v-if="courseware.subject"><el-icon :size="14" color="#10B981"><Collection /></el-icon><span>{{ courseware.subject }}</span></div>
<div class="meta-chip" v-if="courseware.grade"><el-icon :size="14" color="#F59E0B"><User /></el-icon><span>{{ courseware.grade }}</span></div>
</div>
</div>
<div v-loading="loading">
<div v-if="courseware?.content?.length" class="content-area">
<div class="page-tabs">
<div class="page-tabs-scroll">
<div v-for="(page, idx) in courseware.content" :key="idx" class="page-tab" :class="{ active: currentPage === idx }" @click="currentPage = idx">
<span class="page-tab-num">{{ idx + 1 }}</span>
<span class="page-tab-label">{{ page.title || getPageTypeLabel(page.type) }}</span>
<span class="page-tab-actions" v-if="currentPage === idx">
<button type="button" class="tab-mini" title="左移" @click.stop="movePage(idx, -1)" :disabled="idx === 0"><el-icon :size="12"><ArrowLeft /></el-icon></button>
<button type="button" class="tab-mini" title="右移" @click.stop="movePage(idx, 1)" :disabled="idx === courseware.content.length - 1"><el-icon :size="12"><ArrowRight /></el-icon></button>
<button type="button" class="tab-mini" title="复制页" @click.stop="duplicatePage(idx)"><el-icon :size="12"><CopyDocument /></el-icon></button>
<button type="button" class="tab-mini danger" title="删除页" @click.stop="deletePage(idx)" :disabled="courseware.content.length <= 1"><el-icon :size="12"><Delete /></el-icon></button>
</span>
</div>
<button type="button" class="page-tab add-tab" @click="addPage">
<el-icon :size="14"><Plus /></el-icon>
<span>添加页</span>
</button>
</div>
</div>
<div class="page-edit-area">
<div class="page-edit-bar">
<div class="page-edit-fields">
<el-input v-model="courseware.content[currentPage].title" size="default" placeholder="页面标题" class="title-input">
<template #prefix><span style="font-size:12px;color:var(--text-muted)">标题</span></template>
</el-input>
<el-select v-model="courseware.content[currentPage].type" size="default" style="width: 100px">
<el-option label="封面" value="title" />
<el-option label="正文" value="content" />
<el-option label="互动" value="interactive" />
<el-option label="总结" value="summary" />
<el-option label="练习" value="exercise" />
</el-select>
</div>
<div class="undo-redo-group">
<button class="icon-btn" :disabled="!canUndo" @click="undo" title="撤销 (Ctrl+Z)">
<el-icon :size="15"><RefreshLeft /></el-icon>
</button>
<button class="icon-btn" :disabled="!canRedo" @click="redo" title="重做 (Ctrl+Shift+Z)">
<el-icon :size="15"><RefreshRight /></el-icon>
</button>
</div>
<el-dropdown trigger="click" @command="applyTheme" class="theme-dropdown">
<button class="icon-btn theme-btn" title="切换主题">
<el-icon :size="15"><Brush /></el-icon>
<span class="theme-label">主题</span>
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="t in themes" :key="t.id" :command="t.id">
<span class="theme-swatch" :style="{ background: t.swatch }"></span>
<span>{{ t.name }}</span>
<el-icon v-if="currentPageTheme === t.id" color="#10B981" style="margin-left:auto"><Check /></el-icon>
</el-dropdown-item>
<el-dropdown-item divided command="__none">
<span style="color:#6b7280">清除主题</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<button class="icon-btn insert-tpl-btn" @click="showTemplateGallery = true" title="插入模板页">
<el-icon :size="15"><Files /></el-icon>
<span class="theme-label">插入模板</span>
</button>
<button class="edit-toggle-btn" :class="{ active: editingPage }" @click="toggleEdit">
<el-icon :size="15"><Edit /></el-icon>
{{ editingPage ? '完成编辑' : '编辑内容' }}
</button>
</div>
<div class="page-preview-frame">
<HtmlPreview
ref="htmlPreviewRef"
v-model:html="courseware.content[currentPage].content"
:editable="editingPage"
height="100%"
/>
</div>
<div class="notes-edit">
<el-icon :size="14" color="var(--text-muted)"><InfoFilled /></el-icon>
<el-input v-model="courseware.content[currentPage].notes" size="default" placeholder="添加教师备注(不会显示在课件中)..." />
</div>
<div class="page-assist-bar">
<div>
<strong> {{ currentPage + 1 }} </strong>
<span>{{ getPageTypeLabel(courseware.content[currentPage].type) }} · {{ editingPage ? '正在编辑' : '预览模式' }}</span>
</div>
<div class="page-assist-actions">
<button type="button" class="action-btn small" @click="duplicatePage(currentPage)"><el-icon :size="14"><CopyDocument /></el-icon>复制此页</button>
<button type="button" class="action-btn small danger-action" @click="deletePage(currentPage)" :disabled="courseware.content.length <= 1"><el-icon :size="14"><Delete /></el-icon>删除此页</button>
</div>
</div>
</div>
</div>
<div v-else-if="!loading" class="empty-state">
<div class="empty-icon-wrap"><el-icon :size="36" color="#CBD5E1"><Document /></el-icon></div>
<h4>暂无课件内容</h4>
<p>请先创建课件</p>
</div>
<TemplateGallery v-model="showTemplateGallery" kind="animation" @use="handleInsertTemplate" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { exportHtml } from '@/api/ai'
import { createCoursewareShare, exportCoursewareDocx, getCourseware, updateCourseware } from '@/api/courseware'
import { publishResource } from '@/api/resource'
import HtmlPreview from '@/components/common/HtmlPreview.vue'
import TemplateGallery from '@/components/common/TemplateGallery.vue'
import { downloadBlob } from '@/utils/download'
import { coursewareThemes, applyThemeToPage, detectPageTheme, getTheme } from '@/utils/coursewareThemes'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const courseware = ref<any>(null)
const currentPage = ref(0)
const editingTitle = ref(false)
const editingPage = ref(false)
const publishing = ref(false)
const sharing = ref(false)
const exportingDocx = ref(false)
const htmlPreviewRef = ref<InstanceType<typeof HtmlPreview>>()
const showTemplateGallery = ref(false)
const themes = coursewareThemes
const currentPageTheme = computed(() => {
if (!courseware.value?.content?.[currentPage.value]) return null
return detectPageTheme(courseware.value.content[currentPage.value].content || '')
})
const undoStack = ref<Array<{ content: any[]; currentPage: number }>>([])
const redoStack = ref<Array<{ content: any[]; currentPage: number }>>([])
const HISTORY_LIMIT = 50
const canUndo = computed(() => undoStack.value.length > 0)
const canRedo = computed(() => redoStack.value.length > 0)
function snapshotState(): { content: any[]; currentPage: number } {
return {
content: JSON.parse(JSON.stringify(courseware.value?.content || [])),
currentPage: currentPage.value,
}
}
function pushHistory() {
if (!courseware.value?.content) return
undoStack.value.push(snapshotState())
if (undoStack.value.length > HISTORY_LIMIT) undoStack.value.shift()
redoStack.value = []
}
function undo() {
if (!undoStack.value.length) return
redoStack.value.push(snapshotState())
const prev = undoStack.value.pop()!
courseware.value.content = prev.content
currentPage.value = prev.currentPage
editingPage.value = false
}
function redo() {
if (!redoStack.value.length) return
undoStack.value.push(snapshotState())
const next = redoStack.value.pop()!
courseware.value.content = next.content
currentPage.value = next.currentPage
editingPage.value = false
}
function handleKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
e.preventDefault()
if (e.shiftKey) redo()
else undo()
} else if ((e.ctrlKey || e.metaKey) && e.key === 'y') {
e.preventDefault()
redo()
}
}
function getPageTypeLabel(type: string) {
const map: Record<string, string> = { title: '封面', content: '正文', interaction: '互动', interactive: '互动', summary: '总结', exercise: '练习' }
return map[type] || type
}
function applyTheme(themeId: string) {
if (!courseware.value?.content?.length) return
pushHistory()
const theme = themeId === '__none' ? null : (getTheme(themeId) || null)
const page = courseware.value.content[currentPage.value]
if (!page) return
page.content = applyThemeToPage(page.content || '', theme)
}
function handleInsertTemplate(payload: { kind: string; template: any; params: Record<string, any>; html: string; title: string }) {
if (!courseware.value?.content) return
syncCurrentPageEdit()
pushHistory()
const newPage = {
title: payload.title || payload.template.name || '模板页',
type: 'interactive',
content: payload.html,
notes: '由模板插入:' + (payload.template.description || ''),
}
courseware.value.content.splice(currentPage.value + 1, 0, newPage)
currentPage.value = currentPage.value + 1
ElMessage.success('已插入模板页')
}
function toggleEdit() {
if (editingPage.value) {
pushHistory()
syncCurrentPageEdit()
}
editingPage.value = !editingPage.value
}
function addPage() {
if (!courseware.value?.content) return
syncCurrentPageEdit()
pushHistory()
const newPage = { title: '', type: 'content', content: '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;color:#94a3b8;font-size:18px;">点击「编辑内容」开始制作新页面</div>', notes: '' }
courseware.value.content.push(newPage)
currentPage.value = courseware.value.content.length - 1
}
function duplicatePage(idx: number) {
if (!courseware.value?.content || idx < 0 || idx >= courseware.value.content.length) return
syncCurrentPageEdit()
pushHistory()
const src = courseware.value.content[idx]
const copy = { ...src, title: src.title ? src.title + '(副本)' : '' }
courseware.value.content.splice(idx + 1, 0, copy)
currentPage.value = idx + 1
}
function movePage(idx: number, dir: number) {
const pages = courseware.value?.content
if (!pages) return
const target = idx + dir
if (target < 0 || target >= pages.length) return
syncCurrentPageEdit()
pushHistory()
const item = pages.splice(idx, 1)[0]
pages.splice(target, 0, item)
currentPage.value = target
}
async function deletePage(idx: number) {
const pages = courseware.value?.content
if (!pages || pages.length <= 1) return
pushHistory()
try {
await ElMessageBox.confirm('确定删除这一页吗?', '删除页面', { confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' })
} catch { return }
pages.splice(idx, 1)
if (currentPage.value >= pages.length) currentPage.value = pages.length - 1
}
function syncCurrentPageEdit() {
const html = htmlPreviewRef.value?.getHtml()
if (html && courseware.value?.content?.[currentPage.value]) {
courseware.value.content[currentPage.value].content = html
}
}
onMounted(async () => {
window.addEventListener('keydown', handleKeydown)
loading.value = true
try {
const res: any = await getCourseware(Number(route.params.id))
courseware.value = res.data || res
} finally {
loading.value = false
}
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
function openFullPreview() {
if (!courseware.value?.content?.length) return
const data = JSON.stringify({ title: courseware.value.title, pages: courseware.value.content })
localStorage.setItem('preview_courseware', data)
const url = router.resolve({ name: 'CoursewarePreview' }).href
window.open(url, '_blank')
}
async function saveCourseware() {
if (!courseware.value) return
syncCurrentPageEdit()
await updateCourseware(Number(route.params.id), {
title: courseware.value.title,
content: courseware.value.content,
subject: courseware.value.subject,
grade: courseware.value.grade,
description: courseware.value.description,
tags: courseware.value.tags || [],
})
}
async function handleSave() {
if (!courseware.value) return
try {
await saveCourseware()
ElMessage.success('保存成功')
} catch (e: any) {
ElMessage.error('保存失败:' + (e.message || ''))
}
}
async function handlePublish() {
if (!courseware.value) return
publishing.value = true
try {
syncCurrentPageEdit()
const id = Number(route.params.id)
const updated: any = await updateCourseware(id, {
title: courseware.value.title,
subject: courseware.value.subject,
grade: courseware.value.grade,
description: courseware.value.description || buildDescription(),
content: courseware.value.content,
tags: courseware.value.tags || [],
status: 'published',
})
courseware.value = updated.data || updated
const created: any = await publishResource({
resource_type: 'courseware',
title: courseware.value.title,
description: courseware.value.description || buildDescription(),
content_ref: `courseware:${id}`,
file_url: `/preview/${id}`,
tags: courseware.value.tags || ['互动课件'],
subject: courseware.value.subject || '',
grade: courseware.value.grade || '',
is_public: 1,
})
const resource = created.data || created
ElMessage.success('已发布到资源广场')
if (resource?.id) router.push(`/resources/${resource.id}`)
} finally {
publishing.value = false
}
}
async function handleShare() {
if (!courseware.value) return
sharing.value = true
try {
await saveCourseware()
const res: any = await createCoursewareShare(Number(route.params.id))
const share = res.data || res
const url = share.share_url?.startsWith('http') ? share.share_url : `${window.location.origin}${share.share_url || ''}`
await navigator.clipboard?.writeText(url)
ElMessage.success('公开分享链接已复制')
} finally {
sharing.value = false
}
}
function buildDescription() {
const parts = [courseware.value?.subject, courseware.value?.grade].filter(Boolean).join(' · ')
return `${parts || '通用'}互动课件,包含${courseware.value?.content?.length || 0}页,可用于课堂展示、二次改编和校本资源沉淀。`
}
async function handleExportHtml() {
if (!courseware.value) return
syncCurrentPageEdit()
const pages = courseware.value.content || []
const html = pages.map((page: any, index: number) => `
<section style='width:960px;min-height:540px;margin:24px auto;border:1px solid #dce8de;overflow:hidden;'>
<h2 style='font-family:sans-serif;margin:12px 16px;color:#00543d;'>${index + 1}. ${page.title || ''}</h2>
<div style='width:960px;height:540px;'>${page.content || ''}</div>
</section>`).join('\n')
const blob = await exportHtml({ title: courseware.value.title || '课件', html }) as Blob
downloadBlob(blob, `${courseware.value.title || '课件'}.html`)
}
async function handleExportDocx() {
if (!courseware.value) return
exportingDocx.value = true
try {
await saveCourseware()
const blob = await exportCoursewareDocx(Number(route.params.id))
downloadBlob(blob, `${courseware.value.title || '课件'}-讲义.docx`)
} catch (e: any) {
ElMessage.error('导出失败:' + (e.message || ''))
} finally {
exportingDocx.value = false
}
}
</script>
<style scoped>
.detail-page {
padding: 0 !important;
background: var(--bg-color);
display: flex;
flex-direction: column;
height: calc(100vh - var(--header-height));
overflow: hidden;
}
.detail-topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
background: #fff;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.topbar-left { display: flex; align-items: center; gap: 10px; }
.back-btn {
display: flex; align-items: center; gap: 6px;
background: none; border: 1px solid var(--border-color);
border-radius: 8px; padding: 6px 12px;
font-size: 13px; color: var(--text-secondary);
cursor: pointer; transition: all 0.2s;
}
.back-btn:hover { background: var(--primary-bg); color: var(--primary); border-color: var(--primary); }
.topbar-divider { width: 1px; height: 20px; background: var(--border-color); }
.detail-title { font-size: 16px; font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.icon-btn {
width: 28px; height: 28px;
display: flex; align-items: center; justify-content: center;
background: none; border: 1px solid transparent; border-radius: 6px;
color: var(--text-muted); cursor: pointer; transition: all 0.2s; opacity: 0;
}
.topbar-left:hover .icon-btn { opacity: 1; }
.icon-btn:hover { background: var(--primary-bg); color: var(--primary); border-color: var(--primary); }
.topbar-right { display: flex; align-items: center; gap: 10px; }
.action-btn {
display: flex; align-items: center; gap: 6px;
padding: 7px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
cursor: pointer; transition: all 0.2s; border: 1px solid var(--primary);
background: #fff; color: var(--primary);
}
.action-btn:hover { background: var(--primary-bg); }
.action-btn:disabled { opacity: .5; cursor: not-allowed; }
.action-btn.publish { border-color: #0f8a5f; color: #0f8a5f; }
.action-btn.publish:hover:not(:disabled) { background: #e8f7ef; }
.action-btn.primary { background: var(--gradient); color: #fff; border: none; }
.action-btn.primary:hover { opacity: 0.9; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3); }
.meta-bar { padding: 12px 24px; border-bottom: 1px solid var(--border-color); background: #FAFBFC; flex-shrink: 0; }
.meta-chips { display: flex; gap: 12px; }
.meta-chip { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); }
.content-area { flex: 1; overflow-y: auto; padding-bottom: 24px; }
.page-tabs { border-bottom: 1px solid var(--border-color); background: #fff; position: sticky; top: 0; z-index: 5; }
.page-tabs-scroll { display: flex; overflow-x: auto; padding: 0 24px; gap: 2px; }
.page-tabs-scroll::-webkit-scrollbar { height: 0; }
.page-tab { display: flex; align-items: center; gap: 6px; padding: 10px 14px; font-size: 13px; color: var(--text-muted); cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.2s; white-space: nowrap; flex-shrink: 0; }
.page-tab:hover { color: var(--text-secondary); }
.page-tab.active { color: var(--primary); font-weight: 600; border-bottom-color: var(--primary); }
.page-tab-num { width: 20px; height: 20px; border-radius: 5px; background: #E2E8F0; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 600; }
.page-tab.active .page-tab-num { background: var(--primary); color: #fff; }
.page-tab-actions { display: inline-flex; gap: 2px; margin-left: 4px; }
.tab-mini { width: 22px; height: 22px; border: none; border-radius: 5px; background: transparent; color: var(--text-muted); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
.tab-mini:hover:not(:disabled) { background: #E2E8F0; color: var(--text-secondary); }
.tab-mini:disabled { opacity: .35; cursor: not-allowed; }
.tab-mini.danger:hover:not(:disabled) { background: #fee2e2; color: #dc2626; }
.page-tab.add-tab { display: flex; align-items: center; gap: 4px; padding: 10px 14px; font-size: 13px; color: var(--primary); cursor: pointer; border: 1px dashed var(--border-color); border-radius: 8px; margin-left: 6px; flex-shrink: 0; transition: all 0.2s; white-space: nowrap; background: transparent; }
.page-tab.add-tab:hover { border-color: var(--primary); background: #f0fdf4; }
.page-assist-actions { display: flex; gap: 8px; align-items: center; }
.action-btn.small { height: 30px; padding: 0 12px; font-size: 12px; }
.action-btn.danger-action { color: #dc2626; }
.action-btn.danger-action:hover { background: #fef2f2; color: #b91c1c; }
.page-edit-area { padding: 16px 24px; }
.page-edit-bar { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 12px; }
.page-edit-fields { display: flex; gap: 8px; flex: 1; }
.page-edit-fields .title-input { flex: 1; }
.undo-redo-group { display: inline-flex; gap: 2px; }
.undo-redo-group .icon-btn { width: 34px; height: 34px; border: 1px solid var(--border-color); border-radius: 8px; background: #fff; color: var(--text-muted); display: inline-flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s; }
.undo-redo-group .icon-btn:hover:not(:disabled) { border-color: var(--primary); color: var(--primary); background: #f0fdf4; }
.undo-redo-group .icon-btn:disabled { opacity: .35; cursor: not-allowed; }
.theme-dropdown { margin: 0 4px; }
.theme-btn, .insert-tpl-btn { display: inline-flex; align-items: center; gap: 4px; padding: 0 10px; width: auto; }
.theme-label { font-size: 12px; margin-left: 2px; }
.theme-swatch { display: inline-block; width: 16px; height: 16px; border-radius: 4px; border: 1px solid #e5e7eb; margin-right: 8px; vertical-align: middle; }
.edit-toggle-btn {
display: flex; align-items: center; gap: 5px;
padding: 7px 16px; background: #fff; border: 1px solid var(--border-color);
border-radius: 8px; font-size: 13px; font-weight: 500; color: var(--text-secondary);
cursor: pointer; transition: all 0.2s; white-space: nowrap;
}
.edit-toggle-btn:hover { border-color: var(--primary); color: var(--primary); }
.edit-toggle-btn.active { background: var(--primary); border-color: var(--primary); color: #fff; }
.page-preview-frame {
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
overflow: hidden;
background: #F8FAFC;
margin-bottom: 12px;
width: 100%;
aspect-ratio: 16 / 9;
min-height: 0;
}
.notes-edit { display: flex; align-items: center; gap: 10px; }
.notes-edit .el-input { flex: 1; }
.page-assist-bar {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
margin-top: 12px;
border: 1px solid #dce8de;
border-radius: 12px;
background: #fff;
padding: 12px 14px;
}
.page-assist-bar strong {
display: block;
color: #00543d;
font-size: 14px;
margin-bottom: 3px;
}
.page-assist-bar span {
color: #75867f;
font-size: 12px;
}
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; padding: 80px 0; }
.empty-icon-wrap { width: 64px; height: 64px; background: #F8FAFC; border-radius: 18px; display: flex; align-items: center; justify-content: center; }
.empty-state h4 { font-size: 15px; font-weight: 600; color: var(--text-primary); }
.empty-state p { font-size: 13px; color: var(--text-muted); }
@media (max-width: 900px) {
.detail-topbar,
.page-edit-bar,
.page-assist-bar {
align-items: stretch;
flex-direction: column;
}
.topbar-right,
.page-edit-fields {
flex-wrap: wrap;
}
}
</style>
+812
View File
@@ -0,0 +1,812 @@
<template>
<div class="page-container works-page">
<div class="page-header works-header">
<div>
<span class="eyebrow">作品空间</span>
<h2>我的作品</h2>
<p>统一管理课件动画练习教案和试卷继续编辑预览发布或删除</p>
</div>
<div class="header-actions">
<button class="ghost-create" type="button" @click="router.push('/resources')">
<el-icon><FolderOpened /></el-icon>
资源广场
</button>
<el-dropdown trigger="click" @command="startCreate">
<button class="create-btn" type="button">
<el-icon><Plus /></el-icon> 新建任务
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="item in createOptions" :key="item.type" :command="item.type">
{{ item.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<section class="work-stats">
<div>
<strong>{{ works.length }}</strong>
<span>全部作品</span>
</div>
<div>
<strong>{{ publishedCount }}</strong>
<span>已发布</span>
</div>
<div>
<strong>{{ draftCount }}</strong>
<span>草稿</span>
</div>
<div>
<strong>{{ totalUnits }}</strong>
<span>内容单元</span>
</div>
</section>
<section class="works-toolbar">
<div class="segmented">
<button
v-for="item in typeTabs"
:key="item.value"
type="button"
:class="{ active: filters.type === item.value }"
@click="filters.type = item.value"
>
{{ item.label }}
</button>
</div>
<div class="filter-tools">
<el-select v-model="filters.status" placeholder="状态" clearable style="width: 116px">
<el-option label="草稿" value="draft" />
<el-option label="已发布" value="published" />
</el-select>
<el-select v-model="filters.subject" placeholder="筛选学科" clearable style="width: 142px">
<el-option v-for="s in subjectOptions" :key="s" :label="s" :value="s" />
</el-select>
<div class="search-box">
<el-icon><Search /></el-icon>
<input v-model="keyword" placeholder="搜索标题、学科、年级" />
<button v-if="keyword" type="button" title="清空搜索" @click="keyword = ''">×</button>
</div>
</div>
</section>
<div class="card-grid" v-loading="loading">
<article v-for="work in filteredWorks" :key="work.key" class="work-card" @click="openWork(work)">
<div class="work-cover" :class="coverClass(work)">
<span class="type-pill">{{ typeLabel(work.type) }}</span>
<span class="status-pill" :class="{ published: work.status === 'published' }">
{{ work.status === 'published' ? '已发布' : '草稿' }}
</span>
<span class="unit-count">{{ unitText(work) }}</span>
<span class="cover-kicker">{{ work.subject || typeLabel(work.type) }}</span>
<strong>{{ work.title }}</strong>
<em>{{ work.grade || metaText(work) }}</em>
<div class="cover-art" :class="`art-${work.type}`"><span></span><span></span><span></span></div>
</div>
<div class="work-body">
<h3>{{ work.title }}</h3>
<p class="work-desc">{{ work.description || defaultDescription(work) }}</p>
<p class="work-meta">{{ metaLine(work) }}</p>
<div class="tag-row">
<span v-for="tag in visibleTags(work)" :key="tag">{{ tag }}</span>
</div>
<div class="work-footer">
<el-tag :type="work.status === 'published' ? 'success' : 'info'" size="small" effect="plain">
{{ work.status === 'published' ? '已发布' : '草稿' }}
</el-tag>
<div class="work-actions">
<button class="icon-btn" title="快速预览" @click.stop="previewWork(work)">
<el-icon :size="16"><FullScreen /></el-icon>
</button>
<button class="icon-btn" title="继续编辑" @click.stop="openWork(work)">
<el-icon :size="16"><Edit /></el-icon>
</button>
<button v-if="canDownloadWork(work)" class="icon-btn" :title="downloadTitle(work)" :disabled="downloadingKey === work.key" @click.stop="downloadWork(work)">
<el-icon :size="16"><Download /></el-icon>
</button>
<button v-if="work.resource_id" class="icon-btn" title="查看资源" @click.stop="openPublishedResource(work)">
<el-icon :size="16"><View /></el-icon>
</button>
<button v-else class="icon-btn" title="用它生成" @click.stop="reuseWork(work)">
<el-icon :size="16"><MagicStick /></el-icon>
</button>
<el-popconfirm v-if="work.resource_id" title="只下架资源,保留作品草稿?" @confirm="unpublishWork(work)">
<template #reference>
<button class="icon-btn warning" title="下架资源" @click.stop>
<el-icon :size="16"><Download /></el-icon>
</button>
</template>
</el-popconfirm>
<el-popconfirm :title="`确定删除这个${typeLabel(work.type)}?关联资源会同步下架。`" @confirm="deleteWork(work)">
<template #reference>
<button class="icon-btn danger" title="删除" @click.stop>
<el-icon :size="16"><Delete /></el-icon>
</button>
</template>
</el-popconfirm>
</div>
</div>
<div class="card-actions">
<button type="button" @click.stop="reuseWork(work)">用它生成</button>
<button v-if="canDownloadWork(work)" type="button" :disabled="downloadingKey === work.key" @click.stop="downloadWork(work)">
{{ downloadingKey === work.key ? '下载中' : '下载' }}
</button>
<button v-if="work.resource_id" type="button" @click.stop="openPublishedResource(work)">查看资源</button>
<button v-else type="button" @click.stop="previewWork(work)">预览</button>
<button type="button" class="primary" :disabled="publishingKey === work.key" @click.stop="publishWork(work)">
{{ publishingKey === work.key ? '发布中' : work.resource_id ? '更新发布' : '发布资源' }}
</button>
</div>
</div>
</article>
</div>
<div v-if="!loading && filteredWorks.length === 0" class="empty-state">
<el-icon :size="48" color="#CBD5E1"><Document /></el-icon>
<p>{{ works.length ? '没有匹配的作品' : '暂无作品' }}</p>
<button class="create-btn" type="button" @click="router.push('/courseware/create')">创建第一个作品</button>
</div>
<el-dialog v-model="showPreview" :title="previewTitle" width="920px" class="quick-preview-dialog">
<div v-if="previewWorkItem?.type === 'courseware'" class="quick-preview">
<aside class="preview-thumbs">
<button
v-for="(page, index) in previewPages"
:key="index"
type="button"
:class="{ active: previewPage === index }"
@click="previewPage = index"
>
<span>{{ index + 1 }}</span>
<strong>{{ page.title || getPageTypeLabel(page.type) }}</strong>
</button>
</aside>
<main class="preview-stage">
<HtmlPreview
v-if="currentPreviewPage?.content"
:html="currentPreviewPage.content"
height="100%"
:editable="false"
/>
<div v-else class="preview-empty">暂无页面内容</div>
</main>
</div>
<div v-else-if="previewWorkItem?.type === 'animation'" class="single-preview">
<HtmlPreview v-if="previewAnimationHtml" :html="previewAnimationHtml" height="520px" />
<div v-else class="preview-empty">当前动画没有可预览的 HTML</div>
</div>
<div v-else-if="previewWorkItem" class="structured-preview">
<div class="structured-head">
<span>{{ typeLabel(previewWorkItem.type) }}</span>
<strong>{{ previewWorkItem.title }}</strong>
<em>{{ metaLine(previewWorkItem) }}</em>
</div>
<div class="structured-list">
<div v-for="(line, index) in previewLines" :key="index">
<span>{{ index + 1 }}</span>
<p>{{ line }}</p>
</div>
</div>
</div>
<template #footer>
<button type="button" class="dialog-btn" @click="openWork(previewWorkItem)">编辑</button>
<button type="button" class="dialog-btn" @click="reuseWork(previewWorkItem)">用它生成</button>
<button type="button" class="dialog-btn primary" :disabled="!previewWorkItem" @click="publishWork(previewWorkItem)">发布到资源广场</button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { deleteAnimation, exportAnimationHtml, getAnimations, updateAnimation } from '@/api/animation'
import { deleteCourseware, exportCoursewareDocx, getCoursewares, updateCourseware } from '@/api/courseware'
import { deleteExam, exportSavedExamDocx, getExams } from '@/api/exam'
import { deleteExercise, exportExerciseDocx, getExercises } from '@/api/exercise'
import { deleteLessonPlan, exportLessonPlanDocx, getLessonPlans } from '@/api/lessonPlan'
import { deleteResource, getMyResources, publishResource } from '@/api/resource'
import HtmlPreview from '@/components/common/HtmlPreview.vue'
import { downloadBlob } from '@/utils/download'
type WorkType = 'courseware' | 'animation' | 'exercise' | 'lesson_plan' | 'exam'
type WorkItem = {
key: string
id: number
type: WorkType
title: string
subject: string
grade: string
description: string
status: 'draft' | 'published'
resource_id?: number
updated_at?: string
raw: any
}
const router = useRouter()
const loading = ref(false)
const works = ref<WorkItem[]>([])
const keyword = ref('')
const publishingKey = ref('')
const downloadingKey = ref('')
const showPreview = ref(false)
const previewWorkItem = ref<WorkItem | null>(null)
const previewPage = ref(0)
const filters = reactive({ type: '' as '' | WorkType, subject: '', status: '' })
const createOptions = [
{ type: 'courseware', label: 'AI互动课件', path: '/courseware/create' },
{ type: 'animation', label: '教学动画', path: '/animation' },
{ type: 'exercise', label: '教学游戏', path: '/exercise' },
{ type: 'lesson_plan', label: '大单元教案', path: '/lesson-plan' },
{ type: 'exam', label: '智能命题', path: '/exam' },
]
const typeTabs = [
{ value: '', label: '全部' },
{ value: 'courseware', label: '课件' },
{ value: 'animation', label: '动画' },
{ value: 'exercise', label: '练习' },
{ value: 'lesson_plan', label: '教案' },
{ value: 'exam', label: '试卷' },
] as const
const coverClasses = ['cover-green', 'cover-yellow', 'cover-mint', 'cover-blue', 'cover-cream']
const filteredWorks = computed(() => {
const kw = keyword.value.trim().toLowerCase()
return works.value.filter(item => {
if (filters.type && item.type !== filters.type) return false
if (filters.subject && item.subject !== filters.subject) return false
if (filters.status && item.status !== filters.status) return false
if (!kw) return true
return `${item.title}${item.description}${item.subject}${item.grade}${visibleTags(item).join('')}`.toLowerCase().includes(kw)
})
})
const subjectOptions = computed(() => Array.from(new Set(works.value.map(item => item.subject).filter(Boolean))).sort())
const publishedCount = computed(() => works.value.filter(item => item.status === 'published').length)
const draftCount = computed(() => works.value.filter(item => item.status !== 'published').length)
const totalUnits = computed(() => works.value.reduce((sum, item) => sum + unitCount(item), 0))
const previewPages = computed(() => previewWorkItem.value?.raw?.content || [])
const currentPreviewPage = computed(() => previewPages.value[previewPage.value] || null)
const previewTitle = computed(() => previewWorkItem.value?.title || '作品预览')
const previewAnimationHtml = computed(() => previewWorkItem.value?.raw?.config?.html || '')
const previewLines = computed(() => buildPreviewLines(previewWorkItem.value).slice(0, 12))
async function loadData() {
loading.value = true
try {
const [coursewaresRes, animationsRes, exercisesRes, lessonPlansRes, examsRes, resourcesRes]: any[] = await Promise.all([
getCoursewares({ limit: 100 }),
getAnimations({ limit: 100 }),
getExercises({ limit: 100 }),
getLessonPlans({ limit: 100 }),
getExams({ limit: 100 }),
getMyResources({ limit: 100 }),
])
const publishedResources: Map<string, any> = new Map(
normalizeArray(resourcesRes)
.filter((item: any) => typeof item?.content_ref === 'string' && item.content_ref)
.map((item: any) => [String(item.content_ref), item] as [string, any]),
)
const coursewares = normalizeArray(coursewaresRes).map((item: any) => toCoursewareWork(item, publishedResources))
const animations = normalizeArray(animationsRes).map((item: any) => toAnimationWork(item, publishedResources))
const exercises = normalizeArray(exercisesRes).map((item: any) => toExerciseWork(item, publishedResources))
const lessonPlans = normalizeArray(lessonPlansRes).map((item: any) => toLessonPlanWork(item, publishedResources))
const exams = normalizeArray(examsRes).map((item: any) => toExamWork(item, publishedResources))
works.value = [...coursewares, ...animations, ...exercises, ...lessonPlans, ...exams]
.filter((item): item is WorkItem => Boolean(item))
.sort((a, b) => timestamp(b.updated_at) - timestamp(a.updated_at))
} finally {
loading.value = false
}
}
function normalizeArray(res: any) {
return Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : []
}
function resourceFor(item: any, type: WorkType, resources: Map<string, any>) {
return resources.get(`${type}:${item.id}`)
}
function itemStatus(item: any, type: WorkType, resources: Map<string, any>) {
return item.status === 'published' || resourceFor(item, type, resources) ? 'published' : 'draft'
}
function toCoursewareWork(item: any, resources: Map<string, any>): WorkItem {
const resource = resourceFor(item, 'courseware', resources)
return {
key: `courseware:${item.id}`,
id: item.id,
type: 'courseware',
title: item.title || '未命名课件',
subject: item.subject || '',
grade: item.grade || '',
description: item.description || '',
status: itemStatus(item, 'courseware', resources),
resource_id: resource?.id,
updated_at: item.updated_at || item.created_at,
raw: item,
}
}
function toAnimationWork(item: any, resources: Map<string, any>): WorkItem {
const resource = resourceFor(item, 'animation', resources)
return {
key: `animation:${item.id}`,
id: item.id,
type: 'animation',
title: item.title || '未命名动画',
subject: animTypeLabel(item.anim_type),
grade: '',
description: item.description || item.config?.description || '',
status: itemStatus(item, 'animation', resources),
resource_id: resource?.id,
updated_at: item.updated_at || item.created_at,
raw: item,
}
}
function toExerciseWork(item: any, resources: Map<string, any>): WorkItem {
const resource = resourceFor(item, 'exercise', resources)
return {
key: `exercise:${item.id}`,
id: item.id,
type: 'exercise',
title: item.title || '未命名练习',
subject: item.subject || '',
grade: '',
description: '',
status: itemStatus(item, 'exercise', resources),
resource_id: resource?.id,
updated_at: item.updated_at || item.created_at,
raw: item,
}
}
function toLessonPlanWork(item: any, resources: Map<string, any>): WorkItem {
const resource = resourceFor(item, 'lesson_plan', resources)
return {
key: `lesson_plan:${item.id}`,
id: item.id,
type: 'lesson_plan',
title: item.title || '未命名教案',
subject: item.subject || '',
grade: item.grade || '',
description: '',
status: itemStatus(item, 'lesson_plan', resources),
resource_id: resource?.id,
updated_at: item.updated_at || item.created_at,
raw: item,
}
}
function toExamWork(item: any, resources: Map<string, any>): WorkItem {
const resource = resourceFor(item, 'exam', resources)
return {
key: `exam:${item.id}`,
id: item.id,
type: 'exam',
title: item.title || '未命名试卷',
subject: item.subject || '',
grade: item.grade || '',
description: '',
status: itemStatus(item, 'exam', resources),
resource_id: resource?.id,
updated_at: item.updated_at || item.created_at,
raw: item,
}
}
function timestamp(value?: string) {
return value ? new Date(value).getTime() || 0 : 0
}
function typeLabel(type: WorkType | string) {
const map: Record<string, string> = {
courseware: '课件',
animation: '动画',
exercise: '练习',
lesson_plan: '教案',
exam: '试卷',
}
return map[type] || '作品'
}
function animTypeLabel(type: string) {
const map: Record<string, string> = { math: '数学', physics: '物理', chemistry: '化学', general: '通用' }
return map[type] || '通用'
}
function getPageTypeLabel(type: string) {
const map: Record<string, string> = { title: '封面', content: '正文', interaction: '互动', interactive: '互动', summary: '总结', exercise: '练习' }
return map[type] || '页面'
}
function coverClass(work: WorkItem) {
return coverClasses[(work.id + typeTabs.findIndex(item => item.value === work.type)) % coverClasses.length]
}
function unitCount(work: WorkItem) {
if (work.type === 'courseware') return work.raw.content?.length || 0
if (work.type === 'animation') return work.raw.config?.scenes?.length || (work.raw.config?.html ? 1 : 0)
if (work.type === 'exercise' || work.type === 'exam') return work.raw.questions?.length || 0
if (work.type === 'lesson_plan') return work.raw.content?.phases?.length || work.raw.objectives?.length || 0
return 0
}
function unitText(work: WorkItem) {
const count = unitCount(work)
if (work.type === 'courseware') return `${count}`
if (work.type === 'animation') return count ? `${count} 场景` : '动画'
if (work.type === 'lesson_plan') return count ? `${count} 环节` : '教案'
return `${count}`
}
function metaText(work: WorkItem) {
if (work.type === 'animation') return animTypeLabel(work.raw.anim_type)
if (work.type === 'exercise') return exerciseTypeLabel(work.raw.exercise_type)
if (work.type === 'exam') return difficultyLabel(work.raw.difficulty)
return typeLabel(work.type)
}
function metaLine(work: WorkItem) {
const parts = [work.subject, work.grade, metaText(work)].filter(Boolean)
return parts.join(' · ') || typeLabel(work.type)
}
function visibleTags(work: WorkItem) {
if (work.type === 'courseware') return (work.raw.tags || []).slice(0, 3)
if (work.type === 'animation') return ['教学动画', animTypeLabel(work.raw.anim_type)].filter(Boolean)
if (work.type === 'exercise') return [exerciseTypeLabel(work.raw.exercise_type), ...(work.raw.knowledge_points || [])].slice(0, 3)
if (work.type === 'lesson_plan') return ['大单元教案', work.subject, ...(work.raw.key_points || [])].filter(Boolean).slice(0, 3)
return ['智能命题', difficultyLabel(work.raw.difficulty), ...(work.raw.knowledge_points || [])].filter(Boolean).slice(0, 3)
}
function defaultDescription(work: WorkItem) {
if (work.type === 'courseware') {
return `${work.subject || '通用'}互动课件,包含${unitCount(work)}页,可用于课堂展示、二次编辑和发布分享。`
}
if (work.type === 'animation') {
return `${animTypeLabel(work.raw.anim_type)}教学动画,可用于课堂投屏、知识讲解和课件插入。`
}
if (work.type === 'exercise') {
const points = work.raw.knowledge_points?.length ? `,覆盖${work.raw.knowledge_points.join('、')}` : ''
return `${work.subject || '通用'}课堂练习,共${unitCount(work)}${points},支持互动预览和答案解析。`
}
if (work.type === 'lesson_plan') {
return `${work.subject || '通用'}${work.grade ? ` · ${work.grade}` : ''}大单元教案,覆盖目标、重难点、活动和作业。`
}
return `${work.subject || '通用'}${work.grade ? ` · ${work.grade}` : ''}智能试卷,共${unitCount(work)}题,含答案与解析。`
}
function exerciseTypeLabel(type: string) {
const map: Record<string, string> = {
choice: '选择题',
fill_blank: '填空题',
true_false: '判断题',
matching: '匹配题',
drag_sort: '排序题',
game_snake: '贪吃蛇游戏',
game_match: '配对游戏',
game_adventure: '闯关游戏',
}
return map[type] || '互动练习'
}
function difficultyLabel(difficulty: string) {
const map: Record<string, string> = { easy: '简单', medium: '中等', hard: '困难' }
return map[difficulty] || '中等'
}
function safeFilename(value: string) {
return (value || '作品').replace(/[\\/:*?"<>|\r\n]+/g, '_').trim() || '作品'
}
function startCreate(type: string) {
const target = createOptions.find(item => item.type === type)
router.push(target?.path || '/courseware/create')
}
function openPath(work: WorkItem) {
if (work.type === 'courseware') return `/courseware/${work.id}`
if (work.type === 'animation') return `/animation?open=${work.id}`
if (work.type === 'exercise') return `/exercise?open=${work.id}`
if (work.type === 'lesson_plan') return `/lesson-plan?open=${work.id}`
return `/exam?open=${work.id}`
}
function openWork(work?: WorkItem | null) {
if (!work) return
router.push(openPath(work))
}
function openPublishedResource(work?: WorkItem | null) {
if (!work?.resource_id) return
router.push(`/resources/${work.resource_id}`)
}
function canDownloadWork(work?: WorkItem | null) {
return Boolean(work && ['courseware', 'animation', 'exercise', 'lesson_plan', 'exam'].includes(work.type))
}
function downloadTitle(work: WorkItem) {
return work.type === 'animation' ? '下载HTML' : '下载Word'
}
async function downloadWork(work?: WorkItem | null) {
if (!work || !canDownloadWork(work)) return
downloadingKey.value = work.key
try {
let blob: Blob
let suffix = '作品'
if (work.type === 'courseware') {
blob = await exportCoursewareDocx(work.id)
suffix = '讲义'
} else if (work.type === 'animation') {
blob = await exportAnimationHtml(work.id)
suffix = '动画'
} else if (work.type === 'exercise') {
blob = await exportExerciseDocx(work.id)
suffix = '练习'
} else if (work.type === 'lesson_plan') {
blob = await exportLessonPlanDocx(work.id)
suffix = '教案'
} else {
blob = await exportSavedExamDocx(work.id)
suffix = '试卷'
}
downloadBlob(blob, `${safeFilename(work.title)}-${suffix}.${work.type === 'animation' ? 'html' : 'docx'}`)
} catch (e: any) {
ElMessage.error('下载失败:' + (e.response?.data?.detail || e.message || ''))
} finally {
downloadingKey.value = ''
}
}
function previewWork(work?: WorkItem | null) {
if (!work) return
if (work.type === 'courseware' || work.type === 'animation') {
previewWorkItem.value = work
previewPage.value = 0
showPreview.value = true
return
}
previewWorkItem.value = work
showPreview.value = true
}
function buildPreviewLines(work?: WorkItem | null) {
if (!work) return []
if (work.type === 'exercise') {
return (work.raw.questions || []).map((q: any) => q.question || q.stem || q.content || '未命名题目')
}
if (work.type === 'lesson_plan') {
const content = work.raw.content || {}
const phases = content.phases || content.steps || []
return phases.length
? phases.map((p: any) => `${p.name || '教学环节'}${(p.activities || p.teacher_actions || []).join('、') || `${p.duration || ''}分钟`}`)
: [
...(work.raw.objectives || []).map((item: string) => `目标:${item}`),
...(work.raw.key_points || []).map((item: string) => `重点:${item}`),
...(work.raw.homework || []).map((item: string) => `作业:${item}`),
]
}
if (work.type === 'exam') {
return (work.raw.questions || []).map((q: any) => q.content || q.question || q.stem || '未命名题目')
}
return []
}
function reuseWork(work?: WorkItem | null) {
if (!work) return
localStorage.setItem('quick_create_prompt', reusePrompt(work))
router.push(createOptions.find(item => item.type === work.type)?.path || '/courseware/create')
}
function reusePrompt(work: WorkItem) {
const scope = [work.grade, work.subject].filter(Boolean).join('')
const typeName = typeLabel(work.type)
return `参考我的作品《${work.title}》,为${scope || '通用课堂'}重新生成一份原创${typeName}。保留核心知识结构,优化课堂互动、练习反馈和教师引导语。`
}
async function publishWork(work?: WorkItem | null) {
if (!work) return
publishingKey.value = work.key
try {
let item = work.raw
if (work.type === 'courseware') {
const updated: any = await updateCourseware(work.id, {
title: item.title,
subject: item.subject || '',
grade: item.grade || '',
description: item.description || defaultDescription(work),
content: item.content || [],
tags: item.tags || ['互动课件'],
status: 'published',
})
item = updated.data || updated
Object.assign(work.raw, item)
work.status = 'published'
}
if (work.type === 'animation') {
const updated: any = await updateAnimation(work.id, {
title: item.title,
description: item.description || defaultDescription(work),
config: item.config || {},
status: 'published',
})
item = updated.data || updated
Object.assign(work.raw, item)
work.status = 'published'
}
const created: any = await publishResource({
resource_type: work.type,
title: item.title || work.title,
description: item.description || defaultDescription(work),
content_ref: `${work.type}:${work.id}`,
file_url: work.type === 'courseware' ? `/preview/${work.id}` : '',
tags: visibleTags(work),
subject: work.subject || item.subject || '',
grade: work.grade || item.grade || '',
is_public: 1,
})
const resource = created.data || created
work.status = 'published'
work.resource_id = resource?.id
ElMessage.success('已发布到资源广场')
if (resource?.id) router.push(`/resources/${resource.id}`)
} finally {
publishingKey.value = ''
}
}
async function unpublishWork(work: WorkItem) {
if (!work.resource_id) return
await deleteResource(work.resource_id)
if (work.type === 'courseware') {
await updateCourseware(work.id, {
title: work.raw.title,
subject: work.raw.subject || '',
grade: work.raw.grade || '',
description: work.raw.description || defaultDescription(work),
content: work.raw.content || [],
tags: work.raw.tags || [],
status: 'draft',
})
work.raw.status = 'draft'
}
if (work.type === 'animation') {
await updateAnimation(work.id, {
title: work.raw.title,
description: work.raw.description || defaultDescription(work),
config: work.raw.config || {},
status: 'draft',
})
work.raw.status = 'draft'
}
work.status = 'draft'
work.resource_id = undefined
ElMessage.success('资源已下架,作品仍保留在草稿中')
}
async function deleteWork(work: WorkItem) {
if (work.type === 'courseware') await deleteCourseware(work.id)
if (work.type === 'animation') await deleteAnimation(work.id)
if (work.type === 'exercise') await deleteExercise(work.id)
if (work.type === 'lesson_plan') await deleteLessonPlan(work.id)
if (work.type === 'exam') await deleteExam(work.id)
ElMessage.success('作品已删除,关联资源已下架')
await loadData()
}
onMounted(loadData)
</script>
<style scoped>
.works-page { max-width: 1480px; }
.works-header { max-width: 1180px; margin-left: auto; margin-right: auto; }
.page-header p { margin-top: 6px; color: var(--text-muted); font-size: 14px; }
.eyebrow { display: inline-flex; height: 24px; align-items: center; border-radius: 12px; background: #e6f6e5; color: #00543d; padding: 0 10px; font-size: 12px; font-weight: 900; margin-bottom: 10px; }
.header-actions { display: flex; align-items: center; gap: 10px; }
.work-stats { max-width: 1180px; margin: 0 auto 18px; display: grid; grid-template-columns: repeat(4, minmax(150px, 1fr)); gap: 12px; }
.work-stats div { min-height: 78px; display: flex; flex-direction: column; justify-content: center; gap: 5px; border: 1px solid #dce8de; border-radius: 14px; background: #fff; padding: 0 18px; }
.work-stats strong { color: #00543d; font-size: 26px; line-height: 1; }
.work-stats span { color: #6a7f77; font-size: 13px; }
.works-toolbar { max-width: 1180px; margin: 0 auto 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.segmented { display: flex; align-items: center; gap: 8px; overflow-x: auto; scrollbar-width: none; }
.segmented::-webkit-scrollbar { display: none; }
.segmented button { height: 34px; border: 1px solid #dbe8de; border-radius: 17px; background: #fff; color: #51665f; padding: 0 14px; white-space: nowrap; cursor: pointer; }
.segmented button.active, .segmented button:hover { border-color: #9ed0aa; background: #eaf7e9; color: #00543d; font-weight: 800; }
.filter-tools { display: flex; align-items: center; gap: 10px; }
.search-box { width: 280px; height: 36px; display: flex; align-items: center; gap: 8px; border-radius: 18px; background: #eef1ec; color: #9aa7a0; padding: 0 12px; }
.search-box input { min-width: 0; flex: 1; border: none; outline: none; background: transparent; color: #173f35; }
.search-box button { width: 20px; height: 20px; border: none; border-radius: 50%; background: #dce8de; color: #51665f; cursor: pointer; }
.card-grid { max-width: 1180px; margin-left: auto; margin-right: auto; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); }
.create-btn { display: flex; align-items: center; gap: 6px; background: #00543d; border: none; color: #fff; padding: 10px 20px; border-radius: 18px; font-size: 14px; font-weight: 800; cursor: pointer; transition: all 0.2s; }
.create-btn:hover { opacity: 0.9; transform: translateY(-1px); }
.ghost-create { display: flex; align-items: center; gap: 6px; border: 1px solid #d6e4d8; border-radius: 18px; background: #fff; color: #31584d; padding: 9px 16px; font-size: 14px; font-weight: 800; cursor: pointer; }
.ghost-create:hover { border-color: #9ed0aa; background: #f6fbf6; color: #00543d; }
.work-card { background: #fff; border: 1px solid #dce8de; border-radius: 14px; overflow: hidden; cursor: pointer; transition: all 0.2s ease; }
.work-card:hover { box-shadow: 0 12px 24px rgba(17, 69, 52, 0.1); transform: translateY(-3px); }
.work-cover { position: relative; height: 168px; display: flex; flex-direction: column; justify-content: center; gap: 8px; margin: 5px; padding: 22px; border-radius: 10px; color: #fff; overflow: hidden; }
.work-cover strong { position: relative; z-index: 1; max-width: 72%; font-size: 22px; line-height: 1.2; font-weight: 900; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.work-cover em { position: relative; z-index: 1; font-style: normal; font-size: 13px; opacity: .86; }
.cover-kicker { position: relative; z-index: 1; width: fit-content; max-width: 70%; padding: 4px 8px; border-radius: 12px; background: rgba(255,255,255,.22); font-size: 12px; font-weight: 800; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.type-pill, .status-pill, .unit-count { position: absolute; z-index: 2; height: 24px; display: inline-flex; align-items: center; border-radius: 12px; font-size: 12px; font-weight: 900; }
.type-pill { left: 8px; top: 8px; background: rgba(0,0,0,.22); color: #fff; padding: 0 8px; }
.status-pill { left: 8px; bottom: 8px; background: rgba(255,255,255,.88); color: #68756f; padding: 0 8px; }
.status-pill.published { color: #00543d; }
.unit-count { top: 8px; right: 8px; background: rgba(0,0,0,.25); color: #fff; padding: 0 9px; }
.cover-art { position: absolute; right: 22px; bottom: 28px; display: grid; gap: 7px; opacity: .86; }
.cover-art span { width: 86px; height: 34px; border-radius: 7px; background: rgba(255,255,255,.62); box-shadow: 0 6px 14px rgba(0,0,0,.12); }
.art-animation { bottom: 24px; gap: 0; }
.art-animation span { width: 68px; height: 68px; border-radius: 50%; border: 14px solid rgba(255,255,255,.42); background: transparent; grid-area: 1 / 1; }
.art-animation span:nth-child(2) { transform: translate(-28px, 16px) scale(.55); }
.art-animation span:nth-child(3) { transform: translate(22px, -18px) scale(.42); }
.art-exercise span, .art-exam span { width: 68px; height: 10px; border-radius: 10px; }
.art-lesson_plan span { width: 74px; height: 24px; }
.cover-green { background: linear-gradient(135deg, #064231, #00844e); }
.cover-yellow { background: linear-gradient(135deg, #ffd84d, #ffe475); color: #654100; }
.cover-mint { background: linear-gradient(135deg, #e7ffe9, #dff7e5); color: #00543d; }
.cover-blue { background: linear-gradient(135deg, #dbe8ff, #c8ddff); color: #003f68; }
.cover-cream { background: linear-gradient(135deg, #fff3c4, #f7e3a1); color: #7a5600; }
.work-body { padding: 16px 20px; }
.work-body h3 { font-size: 15px; font-weight: 700; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-bottom: 6px; }
.work-desc { min-height: 38px; color: #6a7f77; font-size: 12px; line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: 8px; }
.work-meta { font-size: 13px; color: var(--text-muted); margin-bottom: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tag-row { min-height: 24px; display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
.tag-row span { max-width: 100%; border-radius: 12px; background: #edf7ee; color: #00543d; padding: 4px 8px; font-size: 11px; font-weight: 800; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.work-footer { display: flex; justify-content: space-between; align-items: center; }
.work-actions { display: flex; gap: 5px; }
.icon-btn { width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; background: #F8FAFC; border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-secondary); cursor: pointer; transition: all 0.2s; }
.icon-btn:hover { background: var(--primary-bg); color: var(--primary); border-color: var(--primary-light); }
.icon-btn.warning:hover { background: #fff7ed; color: #c2410c; border-color: #fdba74; }
.icon-btn.danger:hover { background: #FEF2F2; color: #EF4444; border-color: #FCA5A5; }
.card-actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(72px, 1fr)); gap: 8px; margin-top: 13px; }
.card-actions button, .dialog-btn { height: 32px; border: 1px solid #dbe8de; border-radius: 8px; background: #fff; color: #31584d; font-weight: 800; cursor: pointer; }
.card-actions button:hover, .dialog-btn:hover { border-color: #9ed0aa; background: #f6fbf6; color: #00543d; }
.card-actions button.primary, .dialog-btn.primary { border-color: #00543d; background: #00543d; color: #fff; }
.card-actions button.primary:hover, .dialog-btn.primary:hover { background: #004532; color: #fff; }
.card-actions button:disabled { opacity: .58; cursor: not-allowed; }
.empty-state { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 60px 0; }
.empty-state p { color: var(--text-muted); font-size: 15px; }
.quick-preview { height: 520px; display: grid; grid-template-columns: 150px minmax(0, 1fr); gap: 14px; }
.preview-thumbs { overflow-y: auto; display: grid; align-content: start; gap: 8px; border-right: 1px solid #edf3ee; padding-right: 10px; }
.preview-thumbs button { min-height: 62px; display: grid; grid-template-columns: 28px minmax(0, 1fr); align-items: center; gap: 8px; border: 1px solid #dce8de; border-radius: 10px; background: #fff; color: #51665f; padding: 8px; text-align: left; cursor: pointer; }
.preview-thumbs button.active { border-color: #00543d; background: #eaf7e9; color: #00543d; }
.preview-thumbs span { width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; background: #edf3ee; font-weight: 900; }
.preview-thumbs strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.preview-stage { overflow: hidden; border: 1px solid #dce8de; border-radius: 12px; background: #fff; }
.preview-stage :deep(.html-preview) { height: 100%; border-radius: 0; }
.single-preview { min-height: 520px; }
.structured-preview { min-height: 430px; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 14px; }
.structured-head { border: 1px solid #dce8de; border-radius: 12px; background: #f8fbf7; padding: 16px; }
.structured-head span { display: inline-flex; height: 24px; align-items: center; border-radius: 12px; background: #eaf7e9; color: #00543d; padding: 0 10px; font-size: 12px; font-weight: 900; }
.structured-head strong { display: block; margin-top: 10px; color: #173f35; font-size: 20px; }
.structured-head em { display: block; margin-top: 6px; color: #6a7f77; font-style: normal; font-size: 13px; }
.structured-list { display: grid; align-content: start; gap: 10px; max-height: 390px; overflow-y: auto; }
.structured-list div { display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 10px; border: 1px solid #e4eee6; border-radius: 10px; background: #fff; padding: 10px; }
.structured-list span { width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center; border-radius: 9px; background: #edf7ee; color: #00543d; font-weight: 900; }
.structured-list p { min-width: 0; color: #294f45; font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
.preview-empty { height: 100%; min-height: 260px; display: flex; align-items: center; justify-content: center; color: #8a9a93; }
.quick-preview-dialog :deep(.el-dialog__footer) { display: flex; justify-content: flex-end; gap: 8px; }
@media (max-width: 900px) {
.works-toolbar, .filter-tools, .header-actions { align-items: stretch; flex-direction: column; width: 100%; }
.search-box { width: 100%; }
}
@media (max-width: 760px) {
.work-stats { grid-template-columns: 1fr; }
.quick-preview { height: 520px; grid-template-columns: 1fr; }
.preview-thumbs { display: flex; overflow-x: auto; border-right: none; padding-right: 0; }
.preview-thumbs button { flex: 0 0 130px; }
}
</style>
File diff suppressed because it is too large Load Diff
+886
View File
@@ -0,0 +1,886 @@
<template>
<div class="demo-page">
<header class="demo-header">
<div class="demo-brand">
<strong>智教助手</strong>
<span>zhijiao.local</span>
</div>
<div class="demo-search">
<el-icon><Search /></el-icon>
<input placeholder="输入知识点搜索资源" />
</div>
<button class="try-btn" @click="router.push('/')">立即体验</button>
</header>
<main class="demo-main">
<aside class="thumb-rail">
<button
v-for="(page, index) in pages"
:key="page.title"
:class="{ active: currentPage === index }"
type="button"
@click="currentPage = index"
>
<div class="thumb-card" :class="page.theme">
<strong>{{ page.short }}</strong>
<small>{{ page.badge }}</small>
</div>
<span>{{ index + 1 }}</span>
</button>
</aside>
<section class="stage-wrap">
<button class="fullscreen-btn" type="button" @click="isFullscreen = true">
<el-icon><FullScreen /></el-icon>
全屏
</button>
<div class="slide-stage" :class="current.theme">
<div class="slide-page-meta">
<span>{{ currentPage + 1 }} / {{ pages.length }}</span>
<strong>{{ current.badge }}</strong>
</div>
<div class="slide-text">
<h1>{{ current.title }}</h1>
<p>{{ current.subtitle }}</p>
<em>{{ current.badge }}</em>
</div>
<div class="slide-visual">
<div v-if="current.visual === 'tower'" class="big-tower"></div>
<div v-else-if="current.visual === 'chart'" class="big-chart"><span></span><span></span><span></span></div>
<div v-else-if="current.visual === 'dashboard'" class="big-dashboard">
<span></span><span></span><span></span><span></span>
</div>
<div v-else-if="current.visual === 'devices'" class="big-devices">
<span></span><span></span><span></span>
</div>
<div v-else-if="current.visual === 'food'" class="big-food">
<span></span><span></span><span></span><span></span>
</div>
<div v-else-if="current.visual === 'vision'" class="big-vision">
<span></span><span></span><span></span><span></span>
</div>
<div v-else class="big-cards"><span></span><span></span><span></span></div>
</div>
<button class="play-btn" type="button" @click="isPlaying = !isPlaying">
<el-icon><VideoPlay /></el-icon>
{{ isPlaying ? '暂停' : '演示' }}
</button>
</div>
<div class="author-bar">
<div class="author">
<span class="face"></span>
<div>
<strong>{{ resource.author }}</strong>
<small>{{ resource.school }} · {{ resource.type }}</small>
</div>
<button type="button"><el-icon><Star /></el-icon>关注</button>
</div>
<div class="metrics">
<span><el-icon><View /></el-icon>{{ resource.views }}</span>
<span><el-icon><Star /></el-icon>{{ resource.likes }}</span>
<button type="button" @click="share">分享</button>
<button type="button" class="edit" @click="remix">一键改编</button>
<button type="button" class="download" @click="downloadDemo">下载</button>
</div>
</div>
<div class="resource-summary">
<span v-for="tag in resource.tags" :key="tag">{{ tag }}</span>
<p>{{ resource.summary }}</p>
</div>
</section>
<aside class="recommend">
<div class="recommend-head">
<h2>相关推荐</h2>
<button type="button" @click="router.push('/')">更多</button>
</div>
<article v-for="item in recommendations" :key="item.title" @click="switchResource(item.id)">
<div class="rec-cover" :class="item.theme">
<span v-if="item.hot">🔥热门</span>
</div>
<div>
<h3>{{ item.title }}</h3>
<p>{{ item.author }}</p>
<small><el-icon><View /></el-icon>{{ item.views }} <el-icon><Star /></el-icon>{{ item.likes }}</small>
</div>
</article>
</aside>
</main>
<div v-if="isFullscreen" class="fullscreen-layer" @click.self="isFullscreen = false">
<button type="button" @click="isFullscreen = false">退出</button>
<div class="slide-stage large" :class="current.theme">
<div class="slide-text">
<h1>{{ current.title }}</h1>
<p>{{ current.subtitle }}</p>
<em>{{ current.badge }}</em>
</div>
<div class="slide-visual">
<div v-if="current.visual === 'tower'" class="big-tower"></div>
<div v-else-if="current.visual === 'chart'" class="big-chart"><span></span><span></span><span></span></div>
<div v-else-if="current.visual === 'dashboard'" class="big-dashboard">
<span></span><span></span><span></span><span></span>
</div>
<div v-else-if="current.visual === 'devices'" class="big-devices">
<span></span><span></span><span></span>
</div>
<div v-else-if="current.visual === 'food'" class="big-food">
<span></span><span></span><span></span><span></span>
</div>
<div v-else-if="current.visual === 'vision'" class="big-vision">
<span></span><span></span><span></span><span></span>
</div>
<div v-else class="big-cards"><span></span><span></span><span></span></div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { createDemoDraft, isDemoDraftAvailable } from '@/utils/demoDrafts'
const route = useRoute()
const router = useRouter()
const currentPage = ref(0)
const isFullscreen = ref(false)
const isPlaying = ref(false)
const resources: Record<string, any> = {
cylinder: { title: '【互动课件】小学数学六年级圆柱的体积', author: '小叶老师用AI', views: '6.5万', likes: 5509, theme: 'cream', school: '小学数学', type: '互动课件', tags: ['六年级', '圆柱体积', '公式推导'], summary: '围绕圆柱体积公式展开,包含旧知回顾、操作探究、公式推导、巩固练习和拓展应用。' },
guide: { title: '智教助手新手指引', author: 'TeacherYO', views: '8.2万', likes: 6211, theme: 'green', school: '平台指南', type: '新手教程', tags: ['快速入门', '上传材料', '生成课件'], summary: '帮助教师了解从输入需求到生成、预览、改编和发布作品的完整流程。' },
tips: { title: '【玩转互动课件】必看小技巧', author: 'TeacherYO', views: '7.2万', likes: 6065, theme: 'yellow', school: '课堂工具', type: '技巧合集', tags: ['互动课件', '图片替换', '课堂展示'], summary: '整理课件编辑、图片替换、动画插入和课堂展示的高频技巧。' },
data: { title: '【数据回收】新手指引', author: 'TeacherYO', views: '6.4万', likes: 6006, theme: 'mint', school: '课堂工具', type: '数据回收', tags: ['问答收集', '投票', '统计'], summary: '演示如何创建课堂收集任务,并把学生回答汇总成可读数据。' },
food: { title: 'Unit4 Healthy Food 第一课时', author: '海淀老师调AI图', views: '6.6万', likes: 5866, theme: 'yellow', school: '初中英语', type: '教学游戏', tags: ['Healthy Food', '词汇认读', '句型操练'], summary: '围绕健康饮食主题组织单词认读、句型表达、听说操练和闯关反馈。' },
'english-food': { title: 'Unit4 Healthy Food 第一课时', author: '海淀老师调AI图', views: '6.6万', likes: 5866, theme: 'yellow', school: '初中英语', type: '教学游戏', tags: ['Healthy Food', '词汇认读', '句型操练'], summary: '围绕健康饮食主题组织单词认读、句型表达、听说操练和闯关反馈。' },
case: { title: '经典案例解析合集', author: 'TeacherYO', views: '8.7万', likes: 5910, theme: 'blue', school: '案例中心', type: '案例解析', tags: ['优秀案例', '结构拆解', '教学设计'], summary: '用真实课例拆解互动设计、页面结构和课堂节奏。' },
creator: { title: '【AI好课晒一晒】创作者激励活动', author: 'TeacherYO', views: '6.1万', likes: 5776, theme: 'yellow', school: '活动中心', type: '创作者活动', tags: ['发布作品', '资源复用', '教学案例'], summary: '把优秀互动课件发布出来,让更多老师复用、改编和反馈。' },
vision: { title: 'AI如何“看”世界?', author: '蒋老师的AI实验室', views: '5.6万', likes: 5443, theme: 'dark', school: '信息科技', type: '教学动画', tags: ['人工智能', '图像识别', '流程动画'], summary: '通过视觉流程和问题引导解释图像识别的基本工作过程。' },
}
const resource = computed(() => resources[String(route.params.id)] || resources.cylinder)
const pagePresets: Record<string, any[]> = {
cylinder: [
{ title: resources.cylinder.title, short: '封面', theme: 'cream', visual: 'tower', subtitle: '小学数学\n人教版六年级下册', badge: '天坛 · 祈年殿' },
{ title: '知识回顾', short: '回顾', theme: 'cream', visual: 'cards', subtitle: '复习长方体、正方体体积公式,为圆柱体积推导做准备。', badge: '旧知迁移' },
{ title: '操作探究', short: '探究', theme: 'mint', visual: 'chart', subtitle: '拖动滑块观察底面积和高如何影响圆柱体积。', badge: '互动实验' },
{ title: '公式推导', short: '公式', theme: 'blue', visual: 'cards', subtitle: '把圆柱转化为近似长方体,理解 V = S × h。', badge: '转化思想' },
{ title: '巩固练习', short: '练习', theme: 'yellow', visual: 'chart', subtitle: '完成分层练习,系统自动统计正确率。', badge: '课堂检测' },
{ title: '拓展应用', short: '应用', theme: 'green', visual: 'cards', subtitle: '解决生活中的圆柱容积问题。', badge: '真实情境' },
],
data: [
{ title: resources.data.title, short: '封面', theme: 'mint', visual: 'dashboard', subtitle: '课堂工具\n问答、投票、统计一体化', badge: '数据回收' },
{ title: '创建收集任务', short: '创建', theme: 'green', visual: 'cards', subtitle: '选择问答、投票或打卡模板,30 秒生成可投放任务。', badge: '任务配置' },
{ title: '学生扫码提交', short: '提交', theme: 'blue', visual: 'devices', subtitle: '学生使用手机或平板提交答案,教师端实时刷新。', badge: '课堂投放' },
{ title: '自动统计结果', short: '统计', theme: 'mint', visual: 'dashboard', subtitle: '自动统计选项分布、关键词和未提交名单。', badge: '即时反馈' },
{ title: '生成复盘建议', short: '复盘', theme: 'cream', visual: 'cards', subtitle: '根据答题数据生成讲评重点和二次练习建议。', badge: '教学闭环' },
],
food: [
{ title: resources.food.title, short: '封面', theme: 'yellow', visual: 'food', subtitle: '初中英语\nUnit 4 Healthy Food', badge: "Let's speak" },
{ title: 'Warm Up', short: '热身', theme: 'cream', visual: 'cards', subtitle: '观察食物图片,说出 healthy / unhealthy food。', badge: 'Look and say' },
{ title: 'Words Practice', short: '词汇', theme: 'yellow', visual: 'food', subtitle: '听音选择、拖拽分类,巩固 fruit、vegetable、drink 等词汇。', badge: 'Vocabulary' },
{ title: 'Sentence Pattern', short: '句型', theme: 'blue', visual: 'devices', subtitle: '练习 What would you like? I would like... 的问答表达。', badge: 'Speaking' },
{ title: 'Class Game', short: '闯关', theme: 'green', visual: 'cards', subtitle: '小组闯关统计得分,形成课堂即时反馈。', badge: 'Challenge' },
],
vision: [
{ title: resources.vision.title, short: '封面', theme: 'dark', visual: 'vision', subtitle: '信息科技\n揭秘图像识别的工作过程', badge: 'AI 体验课' },
{ title: '输入图像', short: '输入', theme: 'dark', visual: 'devices', subtitle: '把一张图片拆成像素、颜色和边缘等可计算信息。', badge: 'Image input' },
{ title: '提取特征', short: '特征', theme: 'blue', visual: 'vision', subtitle: '模型寻找轮廓、纹理、形状等关键特征。', badge: 'Feature' },
{ title: '分类判断', short: '分类', theme: 'mint', visual: 'chart', subtitle: '比较相似度并输出最可能的类别和置信度。', badge: 'Prediction' },
{ title: '讨论边界', short: '讨论', theme: 'green', visual: 'cards', subtitle: '分析 AI 会出错的场景,理解数据和模型偏差。', badge: 'Reflection' },
],
guide: [
{ title: resources.guide.title, short: '封面', theme: 'green', visual: 'cards', subtitle: '一句话生成专业级互动课件\n让教学更生动', badge: '新手指引' },
{ title: '输入教学需求', short: '输入', theme: 'cream', visual: 'cards', subtitle: '输入知识点、年级、教材版本和课堂目标。', badge: 'Prompt' },
{ title: '选择生成类型', short: '类型', theme: 'mint', visual: 'dashboard', subtitle: '选择互动课件、教学动画、课堂游戏或数据回收。', badge: 'Workflow' },
{ title: '编辑并发布', short: '发布', theme: 'blue', visual: 'devices', subtitle: '预览、微调、导出,并发布到资源广场供二次改编。', badge: 'Publish' },
],
tips: [
{ title: resources.tips.title, short: '封面', theme: 'yellow', visual: 'cards', subtitle: '快速掌握互动课件改编技巧', badge: '技巧合集' },
{ title: '替换图片素材', short: '图片', theme: 'cream', visual: 'devices', subtitle: '在预览中选中图片即可替换或调整大小。', badge: 'Image' },
{ title: '插入动画片段', short: '动画', theme: 'green', visual: 'cards', subtitle: '把动画、提问、练习加入同一节课的节奏里。', badge: 'Animation' },
{ title: '复用优秀结构', short: '复用', theme: 'blue', visual: 'dashboard', subtitle: '参考热门资源结构,改写成自己的原创课堂内容。', badge: 'Remix' },
],
case: [
{ title: resources.case.title, short: '封面', theme: 'blue', visual: 'devices', subtitle: '拆解优秀互动课件\n学习结构和课堂节奏', badge: '案例解析' },
{ title: '看封面定位', short: '封面', theme: 'cream', visual: 'cards', subtitle: '用标题、学段、教材和视觉主题快速建立课堂期待。', badge: 'Cover' },
{ title: '看互动设计', short: '互动', theme: 'mint', visual: 'dashboard', subtitle: '拆解点击、拖拽、投票、反馈等互动环节。', badge: 'Interaction' },
{ title: '看复盘沉淀', short: '复盘', theme: 'green', visual: 'cards', subtitle: '把课堂结果沉淀成下一轮备课素材。', badge: 'Review' },
],
creator: [
{ title: resources.creator.title, short: '封面', theme: 'yellow', visual: 'cards', subtitle: '把好课发布出来\n让更多老师复用和改编', badge: '活动中心' },
{ title: '发布作品', short: '发布', theme: 'green', visual: 'devices', subtitle: '从课件详情一键发布到资源广场。', badge: 'Publish' },
{ title: '获得反馈', short: '反馈', theme: 'mint', visual: 'dashboard', subtitle: '查看浏览、收藏、下载和改编数据。', badge: 'Stats' },
{ title: '持续迭代', short: '迭代', theme: 'blue', visual: 'cards', subtitle: '根据教师反馈持续优化资源。', badge: 'Iterate' },
],
}
pagePresets['english-food'] = pagePresets.food
const pages = computed(() => pagePresets[String(route.params.id)] || pagePresets.cylinder)
const current = computed(() => pages.value[currentPage.value])
const recommendations = [
{ id: 'cylinder', title: '小学数学六年级圆柱的体积', author: '小叶老师用AI', views: '6.5万', likes: 5509, theme: 'cream', hot: true },
{ id: 'food', title: 'Unit4 Healthy Food 第一课时', author: '海淀老师调AI图', views: '6.6万', likes: 5866, theme: 'yellow', hot: true },
{ id: 'data', title: '课堂数据回收新手指引', author: 'TeacherYO', views: '6.4万', likes: 6006, theme: 'mint', hot: true },
{ id: 'vision', title: 'AI如何“看”世界?', author: '蒋老师的AI实验室', views: '5.6万', likes: 5443, theme: 'dark', hot: false },
{ id: 'guide', title: '智教助手新手指引', author: 'TeacherYO', views: '8.2万', likes: 6211, theme: 'green', hot: false },
]
watch(() => route.params.id, () => {
currentPage.value = 0
})
function switchResource(id: string) {
currentPage.value = 0
router.push(`/demo/${id}`)
}
async function remix() {
if (isDemoDraftAvailable(route.params.id)) {
if (!localStorage.getItem('access_token')) {
ElMessage.warning('请先登录后再改编示例资源')
router.push({ path: '/login', query: { redirect: route.fullPath } })
return
}
try {
const result = await createDemoDraft(route.params.id)
ElMessage.success('已创建可编辑示例草稿')
router.push(result.openPath)
return
} catch {
ElMessage.warning('示例草稿创建失败,已为你带入创作指令')
}
}
localStorage.setItem('quick_create_prompt', `基于《${resource.value.title}》改编一份同风格互动课件`)
router.push('/courseware/create')
}
function share() {
navigator.clipboard?.writeText(window.location.href)
ElMessage.success('链接已复制')
}
function downloadDemo() {
ElMessage.success('演示文件已加入下载队列')
}
</script>
<style scoped>
.demo-page {
min-height: 100vh;
background: #f8faf7;
color: #233c35;
}
.demo-header {
height: 58px;
display: grid;
grid-template-columns: 330px minmax(240px, 440px) 1fr;
align-items: center;
gap: 20px;
padding: 0 16px;
border-bottom: 1px solid #e1e8e2;
background: #fff;
}
.demo-brand {
display: flex;
align-items: center;
gap: 18px;
}
.demo-brand strong {
color: #00543d;
font-size: 19px;
}
.demo-brand span {
color: #75867f;
letter-spacing: 1px;
}
.demo-search {
height: 36px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 14px;
border-radius: 18px;
background: #f0f2ee;
color: #a2aaa5;
}
.demo-search input {
flex: 1;
border: none;
outline: none;
background: transparent;
}
.try-btn {
justify-self: end;
width: 92px;
height: 34px;
border: none;
border-radius: 18px;
background: #00543d;
color: #fff;
font-weight: 800;
cursor: pointer;
}
.demo-main {
display: grid;
grid-template-columns: 156px minmax(0, 1fr) 330px;
gap: 18px;
padding: 0 16px 20px;
}
.thumb-rail {
height: calc(100vh - 58px);
overflow-y: auto;
border-right: 1px solid #d8e2da;
padding: 20px 14px 18px 0;
}
.thumb-rail button {
position: relative;
width: 122px;
display: block;
border: none;
background: transparent;
cursor: pointer;
margin-bottom: 10px;
}
.thumb-card {
height: 68px;
border: 1px solid #d6dfd8;
border-radius: 4px;
padding: 8px;
text-align: left;
font-size: 12px;
overflow: hidden;
}
.thumb-card strong {
display: block;
}
.thumb-card small {
display: block;
margin-top: 6px;
color: rgba(0, 0, 0, 0.48);
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.thumb-rail button.active .thumb-card {
border: 2px solid #ff8a00;
}
.thumb-rail button > span {
position: absolute;
right: 1px;
bottom: 1px;
width: 20px;
height: 20px;
border-radius: 5px 0 4px 0;
background: #7b9787;
color: #fff;
font-size: 12px;
font-weight: 800;
}
.stage-wrap {
position: relative;
min-width: 0;
padding-top: 58px;
}
.fullscreen-btn {
position: absolute;
top: 16px;
right: 14px;
z-index: 2;
height: 36px;
display: inline-flex;
align-items: center;
gap: 7px;
border: none;
border-radius: 8px;
background: rgba(0, 0, 0, 0.34);
color: #fff;
padding: 0 14px;
cursor: pointer;
}
.slide-stage {
position: relative;
aspect-ratio: 16 / 9;
border: 1px solid #e1e8e2;
border-radius: 14px;
overflow: hidden;
display: grid;
grid-template-columns: 1fr 1fr;
padding: 58px 42px;
}
.slide-page-meta {
position: absolute;
left: 22px;
right: 22px;
bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
color: rgba(0, 0, 0, 0.45);
font-size: 13px;
}
.slide-page-meta strong {
color: inherit;
font-size: 13px;
}
.slide-stage.large {
width: min(92vw, 1280px);
}
.slide-text {
display: flex;
flex-direction: column;
justify-content: center;
}
.slide-text h1 {
color: inherit;
font-size: clamp(36px, 4vw, 72px);
line-height: 1.12;
font-weight: 900;
margin-bottom: 34px;
}
.slide-text p {
white-space: pre-line;
font-size: clamp(18px, 1.9vw, 30px);
line-height: 1.65;
font-weight: 700;
}
.slide-text em {
position: absolute;
top: 44px;
right: 44px;
padding: 8px 14px;
border-radius: 5px;
background: rgba(120, 83, 0, 0.92);
color: #fff;
font-style: normal;
font-size: 22px;
font-weight: 900;
}
.slide-visual {
display: flex;
align-items: center;
justify-content: center;
}
.cream { color: #755300; background: #fff4c8; }
.mint { color: #00543d; background: #e9f8eb; }
.blue { color: #003e6b; background: #dceaff; }
.yellow { color: #654100; background: #ffe37a; }
.green { color: #fff; background: linear-gradient(135deg, #064231, #00844e); }
.dark { color: #fff; background: #080b12; }
.big-tower {
width: 340px;
height: 360px;
background:
linear-gradient(#0a6b7b, #0a6b7b) 105px 0 / 130px 32px no-repeat,
linear-gradient(#ba5a2a, #ba5a2a) 70px 80px / 200px 42px no-repeat,
linear-gradient(#0a6b7b, #0a6b7b) 44px 122px / 252px 54px no-repeat,
linear-gradient(#ba5a2a, #ba5a2a) 86px 210px / 168px 48px no-repeat,
linear-gradient(#e5dfd0, #e5dfd0) 0 282px / 340px 70px no-repeat;
border-radius: 50% 50% 10px 10px;
}
.big-chart {
display: flex;
align-items: flex-end;
gap: 26px;
}
.big-chart span {
width: 70px;
border-radius: 18px 18px 0 0;
background: rgba(0, 84, 61, 0.55);
}
.big-chart span:nth-child(1) { height: 120px; }
.big-chart span:nth-child(2) { height: 240px; }
.big-chart span:nth-child(3) { height: 180px; }
.big-cards {
display: grid;
gap: 22px;
}
.big-cards span {
width: 320px;
height: 78px;
border-radius: 14px;
background: rgba(255,255,255,0.55);
}
.big-dashboard {
width: min(360px, 90%);
display: grid;
grid-template-columns: 1.25fr .75fr;
gap: 14px;
}
.big-dashboard span {
min-height: 92px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.72);
box-shadow: 0 16px 28px rgba(17, 69, 52, 0.1);
}
.big-dashboard span:first-child {
grid-row: span 2;
background:
radial-gradient(circle at 50% 46%, transparent 0 38px, rgba(0, 84, 61, .24) 39px 52px, transparent 53px),
conic-gradient(from 30deg, #0f8a5f 0 38%, #fed766 38% 64%, #7fb3ff 64% 100%);
}
.big-dashboard span:nth-child(2) { background: linear-gradient(135deg, rgba(255,255,255,.84), rgba(198,238,207,.8)); }
.big-dashboard span:nth-child(3) { background: linear-gradient(135deg, rgba(255,255,255,.84), rgba(207,223,255,.8)); }
.big-dashboard span:nth-child(4) { grid-column: span 2; min-height: 58px; background: rgba(255,255,255,.58); }
.big-devices {
position: relative;
width: 360px;
height: 290px;
}
.big-devices span {
position: absolute;
border-radius: 18px;
background: rgba(255,255,255,.72);
box-shadow: 0 18px 34px rgba(17, 69, 52, .14);
}
.big-devices span:first-child {
left: 18px;
top: 34px;
width: 250px;
height: 158px;
}
.big-devices span:nth-child(2) {
right: 32px;
bottom: 24px;
width: 92px;
height: 168px;
}
.big-devices span:nth-child(3) {
left: 78px;
bottom: 42px;
width: 150px;
height: 22px;
border-radius: 11px;
background: rgba(0, 84, 61, .28);
}
.big-food {
width: 360px;
height: 280px;
position: relative;
}
.big-food span {
position: absolute;
border-radius: 50%;
box-shadow: 0 16px 28px rgba(117, 83, 0, .16);
}
.big-food span:first-child { left: 24px; top: 92px; width: 106px; height: 106px; background: #f46f45; }
.big-food span:nth-child(2) { right: 52px; top: 54px; width: 126px; height: 126px; background: #58b96f; }
.big-food span:nth-child(3) { left: 132px; bottom: 34px; width: 150px; height: 70px; border-radius: 36px; background: #ffcf4c; }
.big-food span:nth-child(4) { right: 18px; bottom: 52px; width: 84px; height: 84px; background: #fff; }
.big-vision {
position: relative;
width: 380px;
height: 300px;
}
.big-vision span {
position: absolute;
border: 1px solid rgba(91, 226, 255, .36);
border-radius: 18px;
background: rgba(45, 79, 128, .34);
box-shadow: 0 0 34px rgba(74, 210, 255, .18);
}
.big-vision span:first-child { left: 128px; top: 84px; width: 118px; height: 118px; border-radius: 50%; background: radial-gradient(circle, #6be7ff 0 9px, rgba(45,79,128,.36) 10px); }
.big-vision span:nth-child(2) { left: 16px; top: 42px; width: 108px; height: 68px; transform: rotate(-10deg); }
.big-vision span:nth-child(3) { right: 28px; top: 30px; width: 124px; height: 76px; transform: rotate(8deg); }
.big-vision span:nth-child(4) { left: 98px; bottom: 28px; width: 188px; height: 74px; }
.play-btn {
position: absolute;
right: 24px;
bottom: 20px;
display: inline-flex;
align-items: center;
gap: 7px;
border: 1px solid #a9d2b4;
border-radius: 9px;
background: #d7f0d9;
color: #00543d;
padding: 9px 17px;
font-weight: 800;
cursor: pointer;
}
.author-bar {
min-height: 76px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 18px 0;
}
.resource-summary {
margin-bottom: 18px;
padding: 16px 18px;
border: 1px solid #dfe8e1;
border-radius: 12px;
background: #fff;
}
.resource-summary span {
display: inline-flex;
align-items: center;
height: 24px;
margin: 0 8px 8px 0;
padding: 0 10px;
border-radius: 12px;
background: #edf7ee;
color: #00543d;
font-size: 12px;
font-weight: 800;
}
.resource-summary p {
color: #50665e;
font-size: 14px;
line-height: 1.7;
}
.author,
.metrics {
display: flex;
align-items: center;
gap: 12px;
}
.face {
width: 42px;
height: 42px;
border-radius: 50%;
background: linear-gradient(135deg, #f7c7b0, #5b8def);
}
.author small {
display: block;
color: #8a9a93;
margin-top: 2px;
}
.author button,
.metrics button {
border: none;
border-radius: 9px;
background: #e8f5ea;
color: #00543d;
padding: 9px 16px;
font-weight: 800;
cursor: pointer;
}
.metrics span {
display: inline-flex;
align-items: center;
gap: 5px;
color: #4c625a;
}
.metrics .edit {
background: #dceee0;
}
.metrics .download {
background: #00543d;
color: #fff;
}
.recommend {
height: calc(100vh - 58px);
overflow-y: auto;
padding: 0 0 20px;
}
.recommend-head {
display: flex;
align-items: center;
justify-content: space-between;
margin: 28px 0 14px;
}
.recommend h2 {
font-size: 18px;
}
.recommend-head button {
border: none;
background: transparent;
color: #00543d;
font-size: 13px;
font-weight: 800;
cursor: pointer;
}
.recommend article {
display: grid;
grid-template-columns: 154px 1fr;
gap: 12px;
cursor: pointer;
margin-bottom: 14px;
}
.rec-cover {
position: relative;
height: 84px;
border-radius: 7px;
overflow: hidden;
}
.rec-cover span {
position: absolute;
top: 6px;
left: 6px;
background: #fff;
color: #ff4b20;
font-size: 12px;
font-weight: 800;
border-radius: 10px;
padding: 3px 7px;
}
.recommend h3 {
font-size: 16px;
line-height: 1.35;
font-weight: 500;
margin-bottom: 8px;
}
.recommend p,
.recommend small {
color: #7d8d86;
font-size: 12px;
}
.recommend small {
display: flex;
align-items: center;
gap: 5px;
margin-top: 8px;
}
.fullscreen-layer {
position: fixed;
inset: 0;
z-index: 30;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.85);
}
.fullscreen-layer > button {
position: fixed;
top: 20px;
right: 24px;
border: none;
border-radius: 18px;
background: #fff;
color: #00543d;
padding: 9px 18px;
font-weight: 800;
cursor: pointer;
}
@media (max-width: 1180px) {
.demo-main {
grid-template-columns: 132px minmax(0, 1fr);
}
.recommend {
display: none;
}
}
@media (max-width: 760px) {
.demo-header {
grid-template-columns: 1fr auto;
}
.demo-search {
display: none;
}
.demo-main {
grid-template-columns: 1fr;
}
.thumb-rail {
height: auto;
display: flex;
gap: 8px;
border-right: none;
overflow-x: auto;
padding: 12px 0;
}
.stage-wrap {
padding-top: 8px;
}
.slide-stage {
grid-template-columns: 1fr;
padding: 28px 22px;
}
.author-bar {
align-items: flex-start;
flex-direction: column;
}
}
</style>
+518
View File
@@ -0,0 +1,518 @@
<template>
<div class="page-container">
<div class="gen-hero">
<div class="gen-hero-icon" style="background: var(--gradient-cyan)">
<el-icon :size="28" color="#fff"><EditPen /></el-icon>
</div>
<div>
<h2>作文智能批改</h2>
<p>AI多维度智能批改即时反馈作文质量</p>
</div>
</div>
<div class="essay-workflow">
<button
v-for="sample in essaySamples"
:key="sample.title"
type="button"
class="sample-card"
@click="applySample(sample)"
>
<strong>{{ sample.title }}</strong>
<span>{{ sample.desc }}</span>
</button>
</div>
<div class="essay-layout">
<div class="essay-input-panel">
<div class="panel-header">
<h3>输入作文</h3>
<div class="settings-row">
<el-select v-model="form.grade_level" size="small" style="width: 90px">
<el-option label="小学" value="小学" />
<el-option label="初中" value="初中" />
<el-option label="高中" value="高中" />
</el-select>
<el-select v-model="form.essay_type" size="small" style="width: 90px">
<el-option label="记叙文" value="记叙文" />
<el-option label="议论文" value="议论文" />
<el-option label="说明文" value="说明文" />
<el-option label="散文" value="散文" />
</el-select>
<el-input-number v-model="form.total_score" :min="10" :max="100" size="small" style="width: 100px" />
</div>
</div>
<el-input
v-model="form.essay_text"
type="textarea"
:rows="20"
placeholder="请在此粘贴学生作文内容..."
resize="none"
class="essay-input"
/>
<button class="grade-btn" :disabled="!form.essay_text || grading" @click="handleGrade">
<el-icon v-if="!grading"><EditPen /></el-icon>
<span v-if="grading" class="btn-loading"></span>
{{ grading ? 'AI批改中...' : '开始批改' }}
</button>
<div class="grading-dimensions">
<div v-for="item in gradingDimensions" :key="item.title">
<strong>{{ item.title }}</strong>
<span>{{ item.desc }}</span>
</div>
</div>
</div>
<div class="essay-result-panel">
<div v-if="grading && !result" class="grading-section">
<div class="grading-spinner"></div>
<p>AI正在多维度批改作文...</p>
<p style="font-size:13px;opacity:.6;margin-top:8px">模型深度推理中通常需要 1-2 分钟请耐心等待</p>
</div>
<div v-if="result" class="result-content">
<div class="result-actions">
<button type="button" @click="downloadReport">
<el-icon><Download /></el-icon>
下载报告
</button>
</div>
<div class="score-banner" :class="result.level">
<div class="score-main">
<span class="score-value">{{ result.total_score }}</span>
<span class="score-total">/ {{ form.total_score }}</span>
</div>
<el-tag :type="(getLevelType(result.level) as any)" size="large" effect="dark">{{ result.level }}</el-tag>
</div>
<div class="score-details">
<div class="score-item" v-for="(item, key) in (result.scores as Record<string, any>)" :key="key">
<div class="score-ring">
<svg viewBox="0 0 36 36">
<path d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" fill="none" stroke="#E2E8F0" stroke-width="3" />
<path d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" fill="none" stroke="#6366F1" stroke-width="3" :stroke-dasharray="`${(item.score / item.max) * 100}, 100`" />
</svg>
<span class="ring-text">{{ item.score }}/{{ item.max }}</span>
</div>
<span class="score-name">{{ getScoreLabel(String(key)) }}</span>
</div>
</div>
<div class="comment-block">
<h4>总评</h4>
<MarkdownContent :content="result.overall_comment" />
</div>
<div v-if="result.strengths?.length" class="comment-block">
<h4 class="good">优点</h4>
<ul><li v-for="(s, i) in result.strengths" :key="i"><MarkdownContent :content="s" /></li></ul>
</div>
<div v-if="result.weaknesses?.length" class="comment-block">
<h4 class="bad">不足</h4>
<ul><li v-for="(w, i) in result.weaknesses" :key="i"><MarkdownContent :content="w" /></li></ul>
</div>
<div v-if="result.suggestions?.length" class="comment-block">
<h4 class="warn">改进建议</h4>
<ul><li v-for="(s, i) in result.suggestions" :key="i"><MarkdownContent :content="s" /></li></ul>
</div>
<div v-if="result.annotations?.length" class="comment-block">
<h4>逐句批注</h4>
<div class="annotation-list">
<div v-for="(a, i) in result.annotations" :key="i" class="annotation-item" :class="a.type">
<el-tag :type="a.type === 'error' ? 'danger' : a.type === 'good' ? 'success' : 'warning'" size="small" effect="plain">
{{ a.type === 'error' ? '错误' : a.type === 'good' ? '佳句' : '建议' }}
</el-tag>
<span class="annotation-text">{{ a.text }}</span>
<span class="annotation-comment">{{ a.comment }}</span>
</div>
</div>
</div>
</div>
<div v-else class="empty-result">
<el-icon :size="48" color="#CBD5E1"><EditPen /></el-icon>
<p>输入作文内容后点击"开始批改"</p>
<div class="empty-points">
<span>总分档位</span>
<span>分项得分</span>
<span>亮点不足</span>
<span>逐句批注</span>
</div>
</div>
</div>
</div>
<section class="saved-section">
<div class="saved-header">
<div>
<h3>已批改作文</h3>
<p>每次批改会自动保存可再次打开下载报告或删除</p>
</div>
<button type="button" class="refresh-btn" @click="refreshSavedGrades">
<el-icon><Refresh /></el-icon>
刷新
</button>
</div>
<div v-if="savedLoading" class="saved-loading">正在加载批改记录...</div>
<div v-else-if="savedGrades.length === 0" class="saved-empty">
<el-icon><EditPen /></el-icon>
<span>暂无批改记录</span>
</div>
<div v-else class="saved-grid">
<article v-for="item in savedGrades" :key="item.id" class="saved-card">
<div>
<span>{{ item.grade_level }} · {{ item.essay_type }}</span>
<strong>{{ item.title }}</strong>
<small>{{ item.result?.total_score ?? '-' }}/{{ item.total_score }} · {{ item.result?.level || '已批改' }}</small>
</div>
<p>{{ item.essay_text }}</p>
<div class="saved-actions">
<button type="button" @click="openSaved(item)"><el-icon><View /></el-icon>打开</button>
<button type="button" @click="downloadSaved(item)"><el-icon><Download /></el-icon>下载</button>
<el-popconfirm title="确定删除这条批改记录?" @confirm="removeSaved(item.id)">
<template #reference>
<button type="button" class="danger"><el-icon><Delete /></el-icon>删除</button>
</template>
</el-popconfirm>
</div>
</article>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { gradeEssay } from '@/api/ai'
import { createEssayGrade, deleteEssayGrade, getEssayGrade, getEssayGrades } from '@/api/essay'
import MarkdownContent from '@/components/common/MarkdownContent.vue'
import { useUserStore } from '@/stores/user'
import { downloadBlob } from '@/utils/download'
const grading = ref(false)
const savedLoading = ref(false)
const result = ref<any>(null)
const savedGrades = ref<any[]>([])
const userStore = useUserStore()
const route = useRoute()
const form = reactive({ essay_text: '', grade_level: '初中', essay_type: '记叙文', total_score: 50 })
const gradingDimensions = [
{ title: '立意内容', desc: '审题、中心、材料选择' },
{ title: '结构表达', desc: '段落层次、过渡衔接' },
{ title: '语言文采', desc: '词句准确度与感染力' },
{ title: '修改建议', desc: '给出可直接训练的提升点' },
]
const essaySamples = [
{
title: '记叙文样例',
desc: '适合日常写人记事训练',
grade: '初中',
type: '记叙文',
text: '那天放学后,雨下得很急。我站在校门口,看见同桌把伞递给了一个低年级的同学,自己却把书包顶在头上往公交站跑。后来我才知道,那把伞是他妈妈特意送来的。这个小小的动作让我明白,真正的善良不是说出来的,而是在别人需要时自然地伸出手。',
},
{
title: '议论文样例',
desc: '适合观点论证与素材训练',
grade: '高中',
type: '议论文',
text: '成长需要独立思考。信息越丰富,越需要我们保持判断力。面对网络上的观点,如果只是简单转发和接受,就容易失去自己的立场。独立思考并不意味着固执己见,而是在倾听之后追问依据,在比较之后形成判断。这样的能力会帮助我们在复杂环境中做出更稳妥的选择。',
},
]
function getLevelType(level: string) {
const map: Record<string, string> = { '优秀': 'success', '良好': '', '中等': 'warning', '及格': 'info', '不及格': 'danger' }
return map[level] || 'info'
}
function getScoreLabel(key: string) {
const map: Record<string, string> = { content: '内容立意', structure: '结构条理', language: '语言表达', writing: '书写规范' }
return map[key] || key
}
async function handleGrade() {
grading.value = true
try {
const res: any = await gradeEssay(form)
userStore.refreshCreditsFromResponse(res)
result.value = res.data
await saveCurrentResult()
ElMessage.success('批改完成')
} catch { ElMessage.error('批改失败') }
finally { grading.value = false }
}
async function saveCurrentResult() {
if (!result.value) return
try {
await createEssayGrade({
title: essayTitle(),
essay_text: form.essay_text,
grade_level: form.grade_level,
essay_type: form.essay_type,
total_score: form.total_score,
result: result.value,
})
await loadSavedGrades({ silent: true })
} catch {
ElMessage.warning('批改结果已生成,但保存记录失败')
}
}
async function loadSavedGrades(options: { silent?: boolean } = {}) {
savedLoading.value = true
try {
const data: any = await getEssayGrades({ limit: 50 })
savedGrades.value = data.data || data || []
} catch (e: any) {
if (!options.silent) ElMessage.warning('未能加载批改记录')
if (e?.response?.status === 401 || e?.response?.status === 403) savedGrades.value = []
} finally {
savedLoading.value = false
}
}
function refreshSavedGrades() {
loadSavedGrades()
}
function openSaved(item: any) {
form.essay_text = item.essay_text || ''
form.grade_level = item.grade_level || '初中'
form.essay_type = item.essay_type || '记叙文'
form.total_score = item.total_score || 50
result.value = item.result || null
window.scrollTo({ top: 0, behavior: 'smooth' })
}
async function removeSaved(id: number) {
await deleteEssayGrade(id)
ElMessage.success('批改记录已删除')
await loadSavedGrades({ silent: true })
}
function applySample(sample: typeof essaySamples[0]) {
form.grade_level = sample.grade
form.essay_type = sample.type
form.essay_text = sample.text
}
function downloadReport() {
if (!result.value) return
const html = buildReportHtml({
result: result.value,
essayText: form.essay_text,
gradeLevel: form.grade_level,
essayType: form.essay_type,
totalScore: form.total_score,
})
const blob = new Blob([html], { type: 'application/msword;charset=utf-8' })
downloadBlob(blob, `${safeFilename(`作文批改报告-${result.value.level || '结果'}`)}.doc`)
ElMessage.success('已开始下载')
}
function downloadSaved(item: any) {
const html = buildReportHtml({
result: item.result || {},
essayText: item.essay_text || '',
gradeLevel: item.grade_level || '',
essayType: item.essay_type || '',
totalScore: item.total_score || 50,
})
const blob = new Blob([html], { type: 'application/msword;charset=utf-8' })
downloadBlob(blob, `${safeFilename(item.title || '作文批改报告')}.doc`)
ElMessage.success('已开始下载')
}
function buildReportHtml(data: { result: any; essayText: string; gradeLevel: string; essayType: string; totalScore: number }) {
const gradeResult = data.result || {}
const scores = gradeResult.scores || {}
const scoreRows = Object.entries(scores).map(([key, value]: any) => `
<tr>
<td>${escapeHtml(getScoreLabel(key))}</td>
<td>${escapeHtml(`${value?.score ?? ''}/${value?.max ?? ''}`)}</td>
<td>${escapeHtml(value?.comment || '')}</td>
</tr>
`).join('')
const list = (title: string, items: string[] = []) => items.length
? `<h2>${escapeHtml(title)}</h2><ul>${items.map(item => `<li>${escapeHtml(item)}</li>`).join('')}</ul>`
: ''
const annotations = (gradeResult.annotations || []).length
? `<h2>逐句批注</h2><table><tr><th>类型</th><th>原文</th><th>批注</th></tr>${gradeResult.annotations.map((item: any) => `
<tr><td>${escapeHtml(annotationTypeLabel(item.type))}</td><td>${escapeHtml(item.text || '')}</td><td>${escapeHtml(item.comment || '')}</td></tr>
`).join('')}</table>`
: ''
return `<!doctype html>
<html><head><meta charset="utf-8"><title>作文批改报告</title>
<style>
body{font-family:"Microsoft YaHei",Arial,sans-serif;color:#173f35;line-height:1.75;padding:28px;}
h1{font-size:24px;margin:0 0 12px;}h2{font-size:18px;margin:24px 0 10px;color:#00543d;}
table{width:100%;border-collapse:collapse;margin:12px 0;}th,td{border:1px solid #cfe0d3;padding:8px;text-align:left;vertical-align:top;}
.score{font-size:20px;font-weight:700;color:#00543d;}
</style></head><body>
<h1>作文批改报告</h1>
<p>学段${escapeHtml(data.gradeLevel)} 文体${escapeHtml(data.essayType)} 满分${data.totalScore}</p>
<p class="score">总分${escapeHtml(`${gradeResult.total_score ?? ''}/${data.totalScore}`)} 等级${escapeHtml(gradeResult.level || '')}</p>
<h2>原文</h2><p>${escapeHtml(data.essayText).replace(/\n/g, '<br>')}</p>
<h2>分项得分</h2><table><tr><th>维度</th><th>得分</th><th>评语</th></tr>${scoreRows}</table>
<h2>总评</h2><p>${escapeHtml(gradeResult.overall_comment || '')}</p>
${list('优点', gradeResult.strengths)}
${list('不足', gradeResult.weaknesses)}
${list('改进建议', gradeResult.suggestions)}
${annotations}
</body></html>`
}
function essayTitle() {
const text = form.essay_text.replace(/\s+/g, '').slice(0, 18)
return `${form.grade_level}${form.essay_type}批改-${text || '未命名作文'}`
}
function annotationTypeLabel(type: string) {
const map: Record<string, string> = { error: '错误', good: '佳句', suggestion: '建议' }
return map[type] || '批注'
}
function escapeHtml(value: any) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function safeFilename(value: string) {
return (value || '作文批改报告').replace(/[\\/:*?"<>|\r\n]+/g, '_').trim() || '作文批改报告'
}
onMounted(async () => {
const quickPrompt = localStorage.getItem('quick_create_prompt')
if (quickPrompt) {
form.essay_text = quickPrompt
localStorage.removeItem('quick_create_prompt')
}
await loadSavedGrades({ silent: true })
await openSavedFromRoute()
})
async function openSavedFromRoute() {
const openId = Number(route.query.open)
if (!Number.isFinite(openId) || openId <= 0) return
try {
const data: any = await getEssayGrade(openId)
openSaved(data.data || data)
} catch {
ElMessage.warning('未能打开批改记录')
}
}
</script>
<style scoped>
.gen-hero { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
.gen-hero-icon { width: 52px; height: 52px; border-radius: 14px; display: flex; align-items: center; justify-content: center; }
.gen-hero h2 { font-size: 24px; font-weight: 700; color: var(--text-primary); }
.gen-hero p { font-size: 14px; color: var(--text-muted); }
.essay-workflow { max-width: 1120px; margin: 0 auto 16px; display: grid; grid-template-columns: repeat(2, minmax(220px, 1fr)); gap: 12px; }
.sample-card { text-align: left; border: 1px solid #dce8de; border-radius: 14px; background: #fff; padding: 14px 16px; cursor: pointer; }
.sample-card strong { display: block; color: #00543d; font-size: 15px; margin-bottom: 6px; }
.sample-card span { color: #6a7f77; font-size: 12px; }
.essay-layout { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
.essay-input-panel { background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 24px; display: flex; flex-direction: column; }
.panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.panel-header h3 { font-size: 16px; font-weight: 600; color: var(--text-primary); }
.settings-row { display: flex; gap: 8px; align-items: center; }
.essay-input :deep(.el-textarea__inner) { background: #F8FAFC; border-radius: var(--radius-sm); padding: 16px; font-size: 15px; line-height: 2; border: none; }
.grade-btn { display: flex; align-items: center; justify-content: center; gap: 8px; background: var(--gradient-cyan); border: none; color: #fff; padding: 12px; border-radius: var(--radius-sm); font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.3s; margin-top: 16px; }
.grade-btn:hover:not(:disabled) { opacity: 0.9; }
.grade-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.grading-dimensions { display: grid; grid-template-columns: repeat(2, minmax(160px, 1fr)); gap: 10px; margin-top: 14px; }
.grading-dimensions div { border: 1px solid #dce8de; border-radius: 10px; background: #f8faf8; padding: 10px 12px; }
.grading-dimensions strong { display: block; color: #00543d; font-size: 13px; margin-bottom: 4px; }
.grading-dimensions span { color: #75867f; font-size: 12px; line-height: 1.45; }
.essay-result-panel { background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 24px; min-height: 500px; }
.result-content { animation: fadeIn 0.5s ease; }
.result-actions { display: flex; justify-content: flex-end; margin-bottom: 12px; }
.result-actions button { height: 34px; display: inline-flex; align-items: center; gap: 6px; border: 1px solid #cfe0d3; border-radius: 8px; background: #f7faf6; color: #00543d; padding: 0 13px; font-weight: 800; cursor: pointer; }
.result-actions button:hover { border-color: #9ed0aa; background: #eaf7e9; }
.score-banner { display: flex; align-items: center; justify-content: space-between; padding: 20px 24px; border-radius: var(--radius-md); margin-bottom: 20px; background: #EEF2FF; }
.score-banner.优秀 { background: #ECFDF5; }
.score-banner.良好 { background: #EEF2FF; }
.score-banner.中等 { background: #FFFBEB; }
.score-banner.不及格 { background: #FEF2F2; }
.score-main { display: flex; align-items: baseline; gap: 4px; }
.score-value { font-size: 36px; font-weight: 800; color: var(--primary); }
.score-total { font-size: 16px; color: var(--text-muted); }
.score-details { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
.score-item { display: flex; flex-direction: column; align-items: center; gap: 8px; }
.score-ring { position: relative; width: 64px; height: 64px; }
.score-ring svg { width: 100%; height: 100%; transform: rotate(-90deg); }
.ring-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 11px; font-weight: 600; color: var(--text-primary); }
.score-name { font-size: 12px; color: var(--text-muted); }
.comment-block { margin-bottom: 16px; }
.comment-block h4 { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; }
.comment-block h4.good { color: #10B981; }
.comment-block h4.bad { color: #EF4444; }
.comment-block h4.warn { color: #F59E0B; }
.comment-block p { font-size: 14px; color: var(--text-secondary); line-height: 1.8; }
.comment-block ul { padding-left: 20px; }
.comment-block li { font-size: 14px; color: var(--text-secondary); line-height: 1.8; }
.annotation-list { display: flex; flex-direction: column; gap: 8px; }
.annotation-item { display: flex; align-items: flex-start; gap: 8px; padding: 10px 12px; background: #F8FAFC; border-radius: var(--radius-sm); }
.annotation-item.error { background: #FEF2F2; }
.annotation-item.good { background: #ECFDF5; }
.annotation-text { font-size: 13px; color: var(--text-primary); }
.annotation-comment { font-size: 13px; color: var(--text-muted); }
.empty-result { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; min-height: 400px; }
.empty-result p { color: var(--text-muted); font-size: 14px; }
.empty-points { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; max-width: 320px; }
.empty-points span { border: 1px solid #dce8de; border-radius: 999px; background: #f8faf8; color: #51665f; padding: 5px 10px; font-size: 12px; font-weight: 700; }
.saved-section { margin-top: 24px; border: 1px solid #dce8de; border-radius: var(--radius-lg); background: #fff; padding: 22px; }
.saved-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 16px; }
.saved-header h3 { font-size: 18px; color: var(--text-primary); margin-bottom: 4px; }
.saved-header p { color: var(--text-muted); font-size: 13px; }
.refresh-btn { height: 34px; display: inline-flex; align-items: center; gap: 6px; border: 1px solid #cfe0d3; border-radius: 17px; background: #fff; color: #00543d; padding: 0 14px; font-weight: 800; cursor: pointer; }
.saved-loading,
.saved-empty { min-height: 110px; display: flex; align-items: center; justify-content: center; gap: 8px; border: 1px dashed #cfe0d3; border-radius: var(--radius-md); background: #f8faf8; color: var(--text-muted); }
.saved-grid { display: grid; grid-template-columns: repeat(3, minmax(220px, 1fr)); gap: 14px; }
.saved-card { border: 1px solid #dce8de; border-radius: 12px; background: #fff; padding: 14px; }
.saved-card > div:first-child { display: grid; gap: 6px; }
.saved-card span { width: fit-content; border-radius: 999px; background: #eaf7e9; color: #00543d; padding: 3px 8px; font-size: 12px; font-weight: 900; }
.saved-card strong { color: var(--text-primary); line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.saved-card small { color: var(--text-muted); font-size: 12px; }
.saved-card p { min-height: 56px; margin-top: 10px; color: var(--text-muted); font-size: 13px; line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.saved-actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
.saved-actions button { height: 30px; display: inline-flex; align-items: center; gap: 4px; border: 1px solid #cfe0d3; border-radius: 8px; background: #f7faf6; color: #00543d; padding: 0 10px; font-weight: 800; cursor: pointer; }
.saved-actions button.danger { color: #b42318; background: #fff7f7; border-color: #f0d2d2; }
.btn-loading { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
.grading-section { text-align: center; padding: 60px 20px; }
.grading-spinner { width: 36px; height: 36px; border: 3px solid #e0e0e0; border-top-color: #6366f1; border-radius: 50%; animation: spin 0.8s linear infinite; margin: 0 auto 16px; }
.grading-section p { font-size: 15px; color: var(--text-secondary, #666); margin: 4px 0; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 980px) {
.essay-layout,
.essay-workflow,
.saved-grid { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
.panel-header { align-items: stretch; flex-direction: column; gap: 10px; }
.settings-row,
.grading-dimensions { grid-template-columns: 1fr; flex-wrap: wrap; }
}
</style>
+647
View File
@@ -0,0 +1,647 @@
<template>
<div class="page-container">
<div class="gen-hero">
<div class="gen-hero-icon" style="background: var(--gradient-red)">
<el-icon :size="28" color="#fff"><Finished /></el-icon>
</div>
<div>
<h2>智能命题</h2>
<p>按学科年级知识点和难度自动生成结构化试卷</p>
</div>
</div>
<div class="gen-input-section">
<div v-if="quickPromptNotice" class="quick-notice">
<el-icon><MagicStick /></el-icon>
<span>{{ quickPromptNotice }}</span>
</div>
<div class="settings-grid">
<div class="setting-item">
<label>学科</label>
<el-input v-model="form.subject" placeholder="学科" size="default" />
</div>
<div class="setting-item">
<label>年级</label>
<el-input v-model="form.grade" placeholder="年级" size="default" />
</div>
<div class="setting-item">
<label>难度</label>
<el-select v-model="form.difficulty" size="default" style="width: 100%">
<el-option label="简单" value="easy" />
<el-option label="中等" value="medium" />
<el-option label="困难" value="hard" />
</el-select>
</div>
<div class="setting-item">
<label>题目数量</label>
<el-input-number v-model="form.count" :min="1" :max="50" size="default" style="width: 100%" />
</div>
</div>
<div class="type-row">
<label>题型</label>
<div class="type-group">
<label v-for="t in questionTypes" :key="t.value" class="type-check" :class="{ checked: form.question_types.includes(t.value) }">
<input type="checkbox" :value="t.value" v-model="form.question_types" hidden />
{{ t.label }}
</label>
</div>
</div>
<div class="kp-row">
<label>知识点</label>
<el-select v-model="form.knowledge_points" multiple filterable allow-create placeholder="输入知识点后回车" size="default" style="flex: 1">
</el-select>
</div>
<div class="material-row">
<MaterialPicker v-model="selectedMaterials" />
</div>
<div class="gen-action">
<button class="gen-btn" style="background: var(--gradient-red)" :disabled="!form.subject || generating" @click="handleGenerate">
<el-icon v-if="!generating"><MagicStick /></el-icon>
<span v-if="generating" class="btn-loading"></span>
{{ generating ? 'AI生成中...' : '生成试卷' }}
</button>
</div>
</div>
<div v-if="generating" class="loading-section">
<div class="loading-bar"><div class="loading-bar-fill"></div></div>
<p>AI正在生成试卷...</p>
<p style="font-size:13px;color:var(--text-secondary);opacity:.65;margin-top:10px">模型深度推理中通常需要 1-2 分钟请耐心等待</p>
</div>
<div v-else-if="!result" class="guide-section">
<div class="capability-row">
<div v-for="item in capabilities" :key="item.title" class="capability-item">
<strong>{{ item.title }}</strong>
<span>{{ item.desc }}</span>
</div>
</div>
<div class="guide-cards">
<div class="guide-card" v-for="g in guideExamples" :key="g.title" @click="applyGuide(g)">
<div class="guide-icon" :style="{ background: g.bg }">
<el-icon :size="18" color="#fff"><component :is="g.icon" /></el-icon>
</div>
<div class="guide-info">
<h4>{{ g.title }}</h4>
<p>{{ g.desc }}</p>
</div>
<span class="guide-arrow">试试 </span>
</div>
</div>
</div>
<div v-if="result" class="result-section">
<div class="result-header">
<h3>{{ result.title }}</h3>
<div class="result-actions">
<button class="save-btn ghost" type="button" @click="reuseCurrent">
<el-icon><MagicStick /></el-icon>
用它生成
</button>
<button class="save-btn ghost" type="button" :disabled="publishing" @click="publishCurrent">
<el-icon><Share /></el-icon>
{{ publishing ? '发布中' : '发布资源' }}
</button>
<button class="save-btn" type="button" @click="handleSave">
<el-icon><Check /></el-icon>
保存试卷
</button>
<button class="save-btn ghost" type="button" @click="handleExportDocx">
<el-icon><Upload /></el-icon>
导出Word
</button>
<button class="save-btn ghost" type="button" @click="printExam">
<el-icon><Printer /></el-icon>
打印PDF
</button>
</div>
</div>
<div class="exam-questions">
<div v-for="(q, idx) in result.questions" :key="idx" class="question-card">
<div class="q-header">
<span class="q-num">{{ idx + 1 }}</span>
<el-tag size="small" effect="plain">{{ q.type }}</el-tag>
<el-tag size="small" type="warning" effect="plain">{{ q.score }}</el-tag>
</div>
<MarkdownContent :content="q.content" class="q-content" />
<div v-if="q.options" class="q-options">
<div v-for="(opt, oi) in q.options" :key="oi" class="q-option" :class="{ correct: opt === q.answer }">
<span class="opt-letter">{{ String.fromCharCode(65 + oi) }}</span>
<span>{{ opt }}</span>
</div>
</div>
<div class="q-analysis">
<div class="q-answer"><strong>答案</strong>{{ q.answer }}</div>
<MarkdownContent :content="q.analysis" class="q-detail" />
</div>
</div>
</div>
</div>
<section class="saved-section">
<div class="saved-header">
<div>
<h3>已保存试卷</h3>
<p>保存后的智能命题结果会出现在这里方便回看和复用</p>
</div>
<button class="refresh-btn" type="button" @click="loadSavedExams">
<el-icon><Refresh /></el-icon>
刷新
</button>
</div>
<div v-if="savedLoading" class="saved-loading">正在加载已保存试卷...</div>
<div v-else-if="savedExams.length === 0" class="saved-empty">
<el-icon><Finished /></el-icon>
<span>暂无已保存试卷</span>
</div>
<div v-else class="saved-grid">
<article v-for="item in savedExams" :key="item.id" class="saved-card">
<div class="saved-cover">
<el-icon><Finished /></el-icon>
<strong>{{ item.title }}</strong>
<span>{{ item.subject || '未设置学科' }} · {{ item.questions?.length || 0 }}</span>
</div>
<div class="saved-body">
<h4>{{ item.title }}</h4>
<p>{{ item.grade || '未设置年级' }} · {{ difficultyLabel(item.difficulty) }} · {{ item.total_score || 100 }}</p>
<div class="saved-actions">
<button type="button" @click="previewSaved(item)"><el-icon><View /></el-icon>预览</button>
<button type="button" @click="publishSaved(item)"><el-icon><Share /></el-icon>发布</button>
<button type="button" @click="downloadSaved(item)"><el-icon><Download /></el-icon>下载</button>
<el-popconfirm title="确定删除这个试卷?关联资源会同步下架。" @confirm="handleDeleteSaved(item.id)">
<template #reference>
<button type="button" class="danger"><el-icon><Delete /></el-icon>删除</button>
</template>
</el-popconfirm>
</div>
</div>
</article>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { exportExamDocx, generateExam } from '@/api/ai'
import { createExam, deleteExam, exportSavedExamDocx, getExam, getExams, updateExam } from '@/api/exam'
import { publishResource } from '@/api/resource'
import MarkdownContent from '@/components/common/MarkdownContent.vue'
import MaterialPicker from '@/components/common/MaterialPicker.vue'
import { downloadBlob } from '@/utils/download'
import { buildMaterialPromptBlock, type MaterialContextItem } from '@/utils/materialContext'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const generating = ref(false)
const result = ref<any>(null)
const savedLoading = ref(false)
const savedExams = ref<any[]>([])
const quickPromptNotice = ref('')
const savedResultId = ref<number | null>(null)
const publishing = ref(false)
const selectedMaterials = ref<MaterialContextItem[]>([])
const questionTypes = [
{ value: 'choice', label: '选择题' },
{ value: 'fill_blank', label: '填空题' },
{ value: 'true_false', label: '判断题' },
{ value: 'short_answer', label: '简答题' },
{ value: 'calculation', label: '计算题' },
]
const form = reactive({
subject: '', grade: '', difficulty: 'medium', count: 10, total_score: 100,
question_types: ['choice'] as string[], knowledge_points: [] as string[],
})
const guideExamples = [
{ title: '数学单元测试', desc: '初一数学有理数运算,10道选择题和5道计算题', icon: 'Finished', bg: 'var(--gradient-red)', subject_val: '数学', grade_val: '初一' },
{ title: '英语语法专项', desc: '高二英语定语从句专项练习,难度中等', icon: 'Document', bg: 'var(--gradient-blue)', subject_val: '英语', grade_val: '高二' },
{ title: '物理综合检测', desc: '初三物理电学综合,包含选择、填空和计算', icon: 'DataLine', bg: 'var(--gradient-green)', subject_val: '物理', grade_val: '初三' },
]
const capabilities = [
{ title: '按知识点组卷', desc: '支持输入多个知识点,自动匹配题型、难度和解析口径。' },
{ title: '答案解析同步生成', desc: '每道题附带答案与讲解,便于课堂订正和课后复盘。' },
{ title: '一键保存导出', desc: '生成结果可保存到题库,也可导出 Word 继续编辑。' },
]
function applyGuide(g: typeof guideExamples[0]) {
form.subject = g.subject_val
form.grade = g.grade_val
}
async function handleGenerate() {
if (!(await ensureLoggedIn())) return
generating.value = true
try {
const res: any = await generateExam(buildExamGeneratePayload())
userStore.refreshCreditsFromResponse(res)
result.value = res.data
savedResultId.value = null
ElMessage.success('试卷生成成功')
} catch (e: any) {
if (handleAuthError(e)) return
ElMessage.error('生成失败:' + (e.response?.data?.detail || e.message || ''))
}
finally { generating.value = false }
}
function buildExamGeneratePayload() {
const materialBlock = buildMaterialPromptBlock(selectedMaterials.value, '参考素材')
return {
...form,
knowledge_points: materialBlock
? [...form.knowledge_points, materialBlock]
: form.knowledge_points,
}
}
async function handleSave() {
if (!result.value) return
if (!(await ensureLoggedIn())) return
const payload = buildExamPayload()
const isUpdate = !!savedResultId.value
const saved: any = isUpdate
? await updateExam(savedResultId.value!, payload)
: await createExam(payload)
const item = saved.data || saved
savedResultId.value = item?.id || null
ElMessage.success(isUpdate ? '试卷已更新' : '试卷已保存')
await loadSavedExams()
}
function printExam() {
const r = result.value
if (!r) return
const w = window.open('', '_blank', 'width=800,height=600')
if (!w) { ElMessage.warning('请允许弹出窗口以打印试卷'); return }
const qHtml = (r.questions || []).map((q: any, i: number) => {
const opts = (q.options || []).map((o: any, oi: number) => '<li>' + String.fromCharCode(65 + oi) + '. ' + (o || '') + '</li>').join('')
return '<div class="q"><p><b>' + (i+1) + '. [' + (q.type||'') + ']</b>' + (q.score ? ''+q.score+'分)' : '') + '</p><p>' + (q.content||q.question||'') + '</p>' + (opts ? '<ol>' + opts + '</ol>' : '') + '</div>'
}).join('')
const ansHtml = (r.answers || []).map((a: any, i: number) => {
const q = r.questions[i]
const an = (typeof a === 'string' || typeof a === 'number') ? a : (a?.answer || '')
const ex = q?.analysis || q?.explanation || ''
return '<p>' + (i+1) + '. ' + an + (ex ? ' 解析:' + ex : '') + '</p>'
}).join('')
w.document.write('<html><head><title>' + (r.title||'试卷') + '</title><style>' +
'body{font-family:SimSun,serif;padding:40px;max-width:720px;margin:0 auto;color:#1f2937}' +
'h1{text-align:center;border-bottom:2px solid #333;padding-bottom:10px}' +
'.meta{text-align:center;color:#666;margin:8px 0 24px}' +
'.q{margin:16px 0;padding:8px 0;border-bottom:1px dashed #ddd}' +
'.q ol{padding-left:30px}h2{margin-top:30px;border-left:4px solid #4f46e5;padding-left:8px}' +
'@media print{button{display:none}}' +
'</style></head><body>' +
'<button onclick="window.print()" style="position:fixed;top:10px;right:10px;padding:8px 20px;background:#4f46e5;color:#fff;border:none;border-radius:6px;cursor:pointer">打印 / 另存为PDF</button>' +
'<h1>' + (r.title||'智能试卷') + '</h1>' +
'<p class="meta">' + [r.subject,r.grade,r.duration?(r.duration+'分钟'):null,r.total_score?('总分'+r.total_score+'分'):null].filter(Boolean).join(' / ') + '</p>' +
qHtml +
(ansHtml ? '<h2>参考答案与解析</h2>' + ansHtml : '') +
'</body></html>')
w.document.close()
}
async function handleExportDocx() {
if (!result.value) return
try {
const blob = await exportExamDocx({
title: result.value.title || '试卷',
subject: form.subject,
grade: form.grade,
questions: result.value.questions || [],
answers: (result.value.questions || []).map((q: any) => q.answer),
}) as Blob
downloadBlob(blob, `${safeFilename(result.value.title || '试卷')}.docx`)
ElMessage.success('已开始下载')
} catch (e: any) {
ElMessage.error('导出失败:' + (e.response?.data?.detail || e.message || ''))
}
}
async function downloadSaved(item: any) {
if (!(await ensureLoggedIn())) return
try {
const blob = await exportSavedExamDocx(item.id)
downloadBlob(blob, `${safeFilename(item.title || '智能试卷')}-试卷.docx`)
ElMessage.success('已开始下载')
} catch (e: any) {
ElMessage.error('下载失败:' + (e.response?.data?.detail || e.message || ''))
}
}
async function loadSavedExams() {
if (!(await ensureLoggedIn({ silent: true }))) {
savedExams.value = []
savedLoading.value = false
return
}
savedLoading.value = true
try {
const res: any = await getExams({ limit: 50 })
savedExams.value = res.data || res || []
} catch (e: any) {
if (handleAuthError(e)) {
savedExams.value = []
return
}
ElMessage.error('加载已保存试卷失败:' + (e.response?.data?.detail || e.message || ''))
} finally {
savedLoading.value = false
}
}
function previewSaved(item: any) {
result.value = {
title: item.title,
questions: item.questions || [],
}
form.subject = item.subject || ''
form.grade = item.grade || ''
form.difficulty = item.difficulty || 'medium'
form.total_score = item.total_score || 100
form.knowledge_points = item.knowledge_points || []
savedResultId.value = item.id
window.scrollTo({ top: 0, behavior: 'smooth' })
}
async function handleDeleteSaved(id: number) {
if (!(await ensureLoggedIn())) return
await deleteExam(id)
ElMessage.success('试卷已删除,关联资源已下架')
await loadSavedExams()
}
function buildExamPayload() {
return {
title: result.value?.title || `${form.subject || '通用'}试卷`,
subject: form.subject,
grade: form.grade,
questions: result.value?.questions || [],
answers: (result.value?.questions || []).map((q: any) => q.answer),
duration: 90,
total_score: form.total_score,
difficulty: form.difficulty,
knowledge_points: form.knowledge_points,
}
}
async function ensureSavedCurrent() {
if (!result.value) return null
if (!(await ensureLoggedIn())) return null
const payload = buildExamPayload()
const saved: any = savedResultId.value
? await updateExam(savedResultId.value, payload)
: await createExam(payload)
const item = saved.data || saved
savedResultId.value = item?.id || null
await loadSavedExams()
return savedResultId.value
}
async function publishCurrent() {
if (!result.value) return
publishing.value = true
try {
const id = await ensureSavedCurrent()
if (!id) return
await publishExamAsset({ id, ...buildExamPayload() })
} finally {
publishing.value = false
}
}
async function publishSaved(item: any) {
if (!(await ensureLoggedIn())) return
await publishExamAsset(item)
}
async function publishExamAsset(item: any) {
const created: any = await publishResource({
resource_type: 'exam',
title: item.title,
description: buildExamDescription(item),
content_ref: `exam:${item.id}`,
file_url: '',
tags: ['智能命题', difficultyLabel(item.difficulty), ...(item.knowledge_points || []).slice(0, 3)],
subject: item.subject || '',
grade: item.grade || '',
is_public: 1,
})
const resource = created.data || created
ElMessage.success('已发布到资源广场')
if (resource?.id) router.push(`/resources/${resource.id}`)
}
function buildExamDescription(item: any) {
const points = item.knowledge_points?.length ? `,覆盖${item.knowledge_points.join('、')}` : ''
return `${item.subject || '通用'}${item.grade ? ` · ${item.grade}` : ''}智能试卷,共${item.questions?.length || 0}题,${difficultyLabel(item.difficulty)}难度${points},含答案与解析。`
}
function reuseCurrent() {
if (!result.value) return
localStorage.setItem('quick_create_prompt', `参考试卷《${result.value.title || '智能试卷'}》,重新生成一套原创命题。要求保留知识点覆盖,优化题型结构、难度梯度和答案解析。`)
result.value = null
savedResultId.value = null
window.scrollTo({ top: 0, behavior: 'smooth' })
}
async function ensureLoggedIn(options: { silent?: boolean } = {}) {
if (!userStore.isLoggedIn()) {
if (!options.silent) ElMessage.warning('请先登录后再使用智能命题功能')
await router.push('/login')
return false
}
if (!userStore.user) {
try {
await userStore.fetchProfile()
} catch {
if (!options.silent) ElMessage.warning('登录状态失效,请重新登录')
await router.push('/login')
return false
}
}
return true
}
function isAuthError(error: any) {
return error?.response?.status === 401 ||
(error?.response?.status === 403 && error?.response?.data?.detail === 'Not authenticated')
}
function handleAuthError(error: any) {
if (!isAuthError(error)) return false
userStore.clearToken()
ElMessage.warning('登录状态失效,请重新登录')
router.push('/login')
return true
}
function difficultyLabel(difficulty: string) {
const map: Record<string, string> = {
easy: '简单',
medium: '中等',
hard: '困难',
}
return map[difficulty] || '中等'
}
function safeFilename(value: string) {
return (value || '资源').replace(/[\\/:*?"<>|\r\n]+/g, '_').trim() || '资源'
}
onMounted(async () => {
const quickPrompt = localStorage.getItem('quick_create_prompt')
if (quickPrompt) {
form.knowledge_points = [quickPrompt]
form.subject = inferSubject(quickPrompt) || '通用'
form.grade = inferGrade(quickPrompt)
if (/简单|基础/.test(quickPrompt)) form.difficulty = 'easy'
if (/困难|拔高|综合/.test(quickPrompt)) form.difficulty = 'hard'
quickPromptNotice.value = '已带入首页命题指令,可继续调整题型、数量和难度。'
localStorage.removeItem('quick_create_prompt')
}
await loadSavedExams()
await openSavedFromRoute()
})
async function openSavedFromRoute() {
const openId = Number(route.query.open)
if (!Number.isFinite(openId) || openId <= 0) return
try {
const data: any = await getExam(openId)
const item = data.data || data
previewSaved(item)
} catch {
ElMessage.warning('未能打开改编后的试卷')
}
}
function inferSubject(text: string) {
const subjects = ['语文', '数学', '英语', '物理', '化学', '生物', '历史', '地理', '政治', '信息科技']
return subjects.find(item => text.includes(item)) || ''
}
function inferGrade(text: string) {
const grades = ['一年级', '二年级', '三年级', '四年级', '五年级', '六年级', '初一', '初二', '初三', '高一', '高二', '高三']
return grades.find(item => text.includes(item)) || ''
}
</script>
<style scoped>
.gen-hero { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
.gen-hero-icon { width: 52px; height: 52px; border-radius: 14px; display: flex; align-items: center; justify-content: center; }
.gen-hero h2 { font-size: 24px; font-weight: 700; color: var(--text-primary); }
.gen-hero p { font-size: 14px; color: var(--text-muted); }
.gen-input-section { background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 24px; margin-bottom: 24px; }
.quick-notice { min-height: 38px; display: flex; align-items: center; gap: 8px; border: 1px solid #dce8de; border-radius: 12px; background: #f0fbef; color: #00543d; padding: 8px 12px; margin-bottom: 14px; font-size: 13px; font-weight: 800; }
.settings-grid { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 16px; margin-bottom: 16px; }
.setting-item { display: flex; flex-direction: column; gap: 6px; }
.setting-item label { font-size: 13px; font-weight: 500; color: var(--text-secondary); }
.type-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.type-row > label { font-size: 13px; font-weight: 500; color: var(--text-secondary); min-width: 40px; }
.type-group { display: flex; gap: 8px; }
.type-check { padding: 6px 14px; border: 1px solid var(--border-color); border-radius: 20px; font-size: 13px; color: var(--text-secondary); cursor: pointer; transition: all 0.2s; }
.type-check.checked { background: #FEF2F2; border-color: #EF4444; color: #EF4444; }
.type-check:hover { border-color: #EF4444; }
.kp-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.kp-row > label { font-size: 13px; font-weight: 500; color: var(--text-secondary); min-width: 50px; }
.material-row { display: flex; flex-wrap: wrap; gap: 8px; padding: 14px 0; border-top: 1px solid var(--border-color); }
.gen-action { display: flex; justify-content: flex-end; padding-top: 16px; border-top: 1px solid var(--border-color); }
.gen-btn { display: flex; align-items: center; gap: 8px; border: none; color: #fff; padding: 10px 28px; border-radius: var(--radius-sm); font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.3s; }
.gen-btn:hover:not(:disabled) { opacity: 0.9; transform: translateY(-1px); }
.gen-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.loading-section { text-align: center; padding: 48px 0; }
.loading-bar { width: 300px; height: 4px; background: #E2E8F0; border-radius: 2px; margin: 0 auto 20px; overflow: hidden; }
.loading-bar-fill { height: 100%; width: 40%; background: var(--gradient-red); border-radius: 2px; animation: loadingSlide 1.5s ease-in-out infinite; }
@keyframes loadingSlide { 0% { transform: translateX(-100%); } 100% { transform: translateX(350%); } }
.loading-section p { font-size: 14px; color: var(--text-secondary); }
.guide-section { animation: fadeIn 0.4s ease; }
.capability-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 14px; }
.capability-item { min-height: 78px; border: 1px solid #f0d2d2; border-radius: 12px; background: #fff; padding: 14px 16px; }
.capability-item strong { display: block; color: #991b1b; font-size: 15px; margin-bottom: 6px; }
.capability-item span { color: #6a7f77; font-size: 12px; line-height: 1.45; }
.guide-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.guide-card { display: flex; flex-direction: column; gap: 10px; background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 18px; cursor: pointer; transition: all 0.3s; }
.guide-card:hover { box-shadow: var(--shadow-md); border-color: #EF4444; transform: translateY(-2px); }
.guide-icon { width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; }
.guide-info h4 { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.guide-info p { font-size: 12px; color: var(--text-muted); line-height: 1.5; }
.guide-arrow { font-size: 12px; color: #EF4444; font-weight: 500; margin-top: auto; }
.result-section { animation: fadeInUp 0.5s ease; }
.result-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
.result-header h3 { font-size: 20px; font-weight: 600; color: var(--text-primary); }
.result-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
.save-btn { display: inline-flex; align-items: center; gap: 6px; border: none; border-radius: var(--radius-sm); background: var(--gradient-red); color: #fff; padding: 8px 20px; font-size: 14px; font-weight: 600; cursor: pointer; }
.save-btn.ghost { background: #fff7f7; color: #b91c1c; border: 1px solid #f0d2d2; }
.save-btn:hover { opacity: .9; }
.save-btn:disabled { opacity: .58; cursor: not-allowed; }
.exam-questions { display: flex; flex-direction: column; gap: 20px; }
.question-card { background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 24px; transition: all 0.3s; }
.question-card:hover { box-shadow: var(--shadow-md); }
.q-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.q-num { width: 28px; height: 28px; background: var(--gradient-red); border-radius: 8px; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 13px; font-weight: 700; }
.q-content { font-size: 15px; line-height: 1.7; color: var(--text-primary); margin-bottom: 14px; }
.q-options { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 14px; }
.q-option { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border: 1px solid var(--border-color); border-radius: var(--radius-sm); font-size: 14px; transition: all 0.2s; }
.q-option.correct { border-color: #10B981; background: #ECFDF5; }
.opt-letter { width: 22px; height: 22px; background: #F1F5F9; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; color: var(--text-secondary); flex-shrink: 0; }
.q-option.correct .opt-letter { background: #10B981; color: #fff; }
.q-analysis { padding-top: 14px; border-top: 1px dashed var(--border-color); }
.q-answer { font-size: 14px; color: var(--text-primary); margin-bottom: 6px; }
.q-detail { font-size: 13px; color: var(--text-muted); line-height: 1.6; }
.btn-loading { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.saved-section { margin-top: 28px; background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 22px; }
.saved-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 18px; }
.saved-header h3 { font-size: 18px; font-weight: 700; color: var(--text-primary); margin-bottom: 4px; }
.saved-header p { font-size: 13px; color: var(--text-muted); }
.refresh-btn { height: 34px; display: inline-flex; align-items: center; gap: 6px; border: 1px solid #f0d2d2; border-radius: 17px; background: #fff; color: #b91c1c; padding: 0 14px; font-weight: 700; cursor: pointer; }
.saved-loading,
.saved-empty { min-height: 120px; display: flex; align-items: center; justify-content: center; gap: 8px; color: var(--text-muted); border: 1px dashed #f0d2d2; border-radius: var(--radius-md); background: #fff7f7; }
.saved-empty .el-icon { font-size: 24px; color: #ef4444; }
.saved-grid { display: grid; grid-template-columns: repeat(3, minmax(220px, 1fr)); gap: 16px; }
.saved-card { overflow: hidden; border: 1px solid #f0d2d2; border-radius: 12px; background: #fff; }
.saved-cover { height: 132px; display: flex; flex-direction: column; justify-content: center; gap: 8px; margin: 5px; padding: 18px; border-radius: 9px; color: #fff; background: linear-gradient(135deg, #991b1b, #ef4444); }
.saved-cover .el-icon { font-size: 22px; opacity: .85; }
.saved-cover strong { font-size: 18px; line-height: 1.25; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.saved-cover span { width: fit-content; padding: 3px 8px; border-radius: 12px; background: rgba(255,255,255,.2); font-size: 12px; font-weight: 800; }
.saved-body { padding: 12px 16px 16px; }
.saved-body h4 { font-size: 15px; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 6px; }
.saved-body p { min-height: 36px; color: var(--text-muted); font-size: 12px; line-height: 1.5; }
.saved-actions { display: flex; gap: 8px; margin-top: 12px; }
.saved-actions button { height: 32px; display: inline-flex; align-items: center; justify-content: center; gap: 5px; border: 1px solid #f0d2d2; border-radius: 8px; background: #fff7f7; color: #b91c1c; padding: 0 12px; cursor: pointer; font-weight: 700; }
.saved-actions .danger:hover { border-color: #fca5a5; background: #fef2f2; color: #ef4444; }
@media (max-width: 1080px) {
.settings-grid,
.saved-grid { grid-template-columns: repeat(2, minmax(220px, 1fr)); }
}
@media (max-width: 760px) {
.settings-grid,
.capability-row,
.guide-cards,
.saved-grid,
.q-options { grid-template-columns: 1fr; }
.type-row,
.kp-row,
.result-header,
.saved-header,
.result-actions { flex-direction: column; align-items: stretch; }
.type-group { flex-wrap: wrap; }
}
</style>
+873
View File
@@ -0,0 +1,873 @@
<template>
<div class="page-container">
<div class="gen-hero">
<div class="gen-hero-icon" style="background: var(--gradient-orange)">
<el-icon :size="28" color="#fff"><Trophy /></el-icon>
</div>
<div>
<h2>教学游戏</h2>
<p>AI生成闯关选择填空等互动练习学生可以直接在页面作答</p>
</div>
</div>
<div class="gen-input-section">
<div v-if="quickPromptNotice" class="quick-notice">
<el-icon><MagicStick /></el-icon>
<span>{{ quickPromptNotice }}</span>
</div>
<div class="prompt-box">
<el-input
v-model="form.prompt"
type="textarea"
:rows="4"
placeholder="描述你需要的练习,例如:生成10道初中数学一元二次方程的选择题..."
resize="none"
/>
<div class="prompt-footer">
<div class="prompt-tags">
<span class="prompt-tag" v-for="t in suggestions" :key="t" @click="form.prompt = t">{{ t }}</span>
</div>
<button class="tpl-btn" type="button" @click="showTemplateGallery = true">
<el-icon><Files /></el-icon>
<span>用模板</span>
</button>
<button class="gen-btn" style="background: var(--gradient-orange)" :disabled="!form.prompt || generating" @click="handleGenerate">
<el-icon v-if="!generating"><MagicStick /></el-icon>
<span v-if="generating" class="btn-loading"></span>
{{ generating ? 'AI生成中...' : '生成游戏' }}
</button>
</div>
</div>
<div class="material-row">
<MaterialPicker v-model="selectedMaterials" />
</div>
<div class="settings-row">
<div class="setting-item">
<label>练习类型</label>
<el-select v-model="form.exercise_type" size="default">
<el-option label="选择题" value="choice" />
<el-option label="填空题" value="fill_blank" />
<el-option label="判断题" value="true_false" />
<el-option label="匹配题" value="matching" />
<el-option label="排序拖拽" value="drag_sort" />
<el-option label="贪吃蛇闯关" value="game_snake" />
<el-option label="翻牌配对" value="game_match" />
<el-option label="冒险闯关" value="game_adventure" />
</el-select>
</div>
<div class="setting-item">
<label>学科</label>
<el-input v-model="form.subject" placeholder="学科" size="default" style="width: 120px" />
</div>
<div class="setting-item">
<label>题数</label>
<el-input-number v-model="form.count" :min="1" :max="50" size="default" />
</div>
</div>
</div>
<div v-if="generating" class="loading-section">
<div class="loading-bar"><div class="loading-bar-fill"></div></div>
<p>AI正在生成练习题...</p>
<p style="font-size:13px;color:var(--text-secondary);opacity:.65;margin-top:10px">模型深度推理中通常需要 1-2 分钟请耐心等待</p>
</div>
<div v-else-if="!result" class="guide-section">
<div class="capability-row">
<div v-for="item in capabilities" :key="item.title">
<strong>{{ item.title }}</strong>
<span>{{ item.desc }}</span>
</div>
</div>
<div class="guide-cards">
<div class="guide-card" v-for="g in guideExamples" :key="g.title" @click="form.prompt = g.prompt">
<div class="guide-icon" :style="{ background: g.bg }">
<el-icon :size="18" color="#fff"><component :is="g.icon" /></el-icon>
</div>
<div class="guide-info">
<h4>{{ g.title }}</h4>
<p>{{ g.prompt }}</p>
</div>
<span class="guide-arrow">试试 </span>
</div>
</div>
</div>
<div v-if="result" class="result-section">
<div class="result-header">
<div>
<h3>{{ result.title }}</h3>
<span class="result-meta"> {{ result.questions?.length || 0 }} · {{ typeLabel(form.exercise_type) }}</span>
</div>
<div class="header-actions">
<button class="mode-btn" :class="{ active: viewMode === 'interactive' }" @click="viewMode = 'interactive'">
<el-icon><Monitor /></el-icon> 互动
</button>
<button class="mode-btn" :class="{ active: viewMode === 'detail' }" @click="viewMode = 'detail'">
<el-icon><List /></el-icon> 详情
</button>
<button class="mode-btn" @click="reuseCurrent">
<el-icon><MagicStick /></el-icon> 用它生成
</button>
<button class="mode-btn publish" :disabled="publishing" @click="publishCurrent">
<el-icon><Share /></el-icon> {{ publishing ? '发布中' : '发布资源' }}
</button>
<button class="save-btn" style="background: var(--gradient-orange)" @click="handleSave"><el-icon><Check /></el-icon> 保存练习</button>
<button class="mode-btn" @click="downloadCurrent"><el-icon><Download /></el-icon> 下载Word</button>
</div>
</div>
<!-- Interactive Mode: answer questions and get feedback -->
<div v-if="viewMode === 'interactive' && result.questions?.length" class="interactive-mode">
<!-- Score bar -->
<div class="quiz-score-bar">
<span> {{ currentQIdx + 1 }} / {{ result.questions.length }} </span>
<span v-if="answeredCount > 0">已答 {{ answeredCount }} · 正确 {{ correctCount }} · 正确率 {{ scorePercent }}%</span>
<span v-if="attemptSummary" class="attempt-summary">已有 {{ attemptSummary.count }} 人完成 · 平均正确率 {{ attemptSummary.avgScore }}%</span>
<button v-if="answeredCount > 0" type="button" class="reset-btn" @click="resetQuiz">重新作答</button>
</div>
<!-- Question -->
<div class="quiz-question-card">
<div class="quiz-q-header">
<span class="q-num">{{ currentQIdx + 1 }}</span>
<el-tag size="small" effect="plain">{{ currentQuestion.type || form.exercise_type }}</el-tag>
</div>
<MarkdownContent :content="currentQuestion.question" class="q-text" />
<div v-if="currentQuestion.html" class="quiz-html-preview">
<HtmlPreview :html="currentQuestion.html" height="280px" />
</div>
<!-- Choice / True-False: option buttons -->
<div v-if="currentQuestion.options?.length" class="quiz-options">
<button
v-for="(opt, oi) in currentQuestion.options"
:key="oi"
type="button"
class="quiz-option"
:class="optionClass(oi)"
:disabled="isRevealed(currentQIdx)"
@click="selectOption(opt)"
>
<span class="opt-letter">{{ String.fromCharCode(65 + oi) }}</span>
<span>{{ opt }}</span>
<el-icon v-if="isRevealed(currentQIdx) && opt === currentQuestion.answer" class="opt-correct"><CircleCheckFilled /></el-icon>
<el-icon v-else-if="isRevealed(currentQIdx) && opt === getUserAnswer(currentQIdx) && opt !== currentQuestion.answer" class="opt-wrong"><CircleCloseFilled /></el-icon>
</button>
</div>
<!-- Fill-blank: text input -->
<div v-else class="quiz-fill-input">
<input
v-model="fillAnswer"
type="text"
class="fill-input"
placeholder="输入你的答案..."
:disabled="isRevealed(currentQIdx)"
@keyup.enter="submitFillAnswer"
/>
<button v-if="!isRevealed(currentQIdx)" type="button" class="submit-answer-btn" @click="submitFillAnswer">提交答案</button>
</div>
<!-- Feedback after answering -->
<div v-if="isRevealed(currentQIdx)" class="quiz-feedback" :class="{ correct: isCurrentCorrect, wrong: !isCurrentCorrect }">
<div class="feedback-head">
<el-icon v-if="isCurrentCorrect" :size="20"><CircleCheckFilled /></el-icon>
<el-icon v-else :size="20"><CircleCloseFilled /></el-icon>
<strong>{{ isCurrentCorrect ? '回答正确!' : '回答错误' }}</strong>
<span v-if="!isCurrentCorrect">正确答案{{ currentQuestion.answer }}</span>
</div>
<div v-if="currentQuestion.explanation" class="feedback-explain">
<MarkdownContent :content="currentQuestion.explanation" />
</div>
</div>
</div>
<!-- Navigation -->
<div class="interactive-nav">
<button class="nav-btn" :disabled="currentQIdx <= 0" @click="currentQIdx--">
<el-icon><ArrowLeft /></el-icon>
</button>
<div class="nav-dots">
<span
v-for="(q, idx) in result.questions"
:key="idx"
class="dot"
:class="{
active: currentQIdx === idx,
correct: isRevealed(idx) && isCorrect(idx),
wrong: isRevealed(idx) && !isCorrect(idx),
}"
@click="currentQIdx = idx"
>{{ idx + 1 }}</span>
</div>
<button class="nav-btn" :disabled="currentQIdx >= result.questions.length - 1" @click="currentQIdx++">
<el-icon><ArrowRight /></el-icon>
</button>
</div>
</div>
<!-- Detail Mode: show all questions -->
<div v-if="viewMode === 'detail'" class="detail-mode">
<div v-for="(q, idx) in result.questions" :key="idx" class="question-card">
<div class="q-header">
<span class="q-num">{{ idx + 1 }}</span>
<el-tag size="small" effect="plain">{{ q.type }}</el-tag>
<el-tag v-if="q.difficulty" size="small" effect="plain" :type="q.difficulty === 'easy' ? 'success' : q.difficulty === 'hard' ? 'danger' : 'warning'">
{{ q.difficulty === 'easy' ? '简单' : q.difficulty === 'hard' ? '困难' : '中等' }}
</el-tag>
</div>
<MarkdownContent :content="q.question" class="q-text" />
<div v-if="q.html" class="q-preview">
<HtmlPreview :html="q.html" height="300px" />
</div>
<div v-if="q.options" class="q-options">
<div v-for="(opt, oi) in q.options" :key="oi" class="q-option" :class="{ correct: opt === q.answer }">
<span class="opt-letter">{{ String.fromCharCode(65 + oi) }}</span>
<span>{{ opt }}</span>
</div>
</div>
<div class="q-answer">
<el-icon :size="14" color="#10B981"><Check /></el-icon>
<span>答案{{ q.answer }}</span>
</div>
<div v-if="q.explanation" class="q-explanation"><MarkdownContent :content="q.explanation" /></div>
</div>
</div>
</div>
<section class="saved-section">
<div class="saved-header">
<div>
<h3>已保存练习</h3>
<p>保存后的课堂练习会出现在这里方便二次打开和管理</p>
</div>
<button class="refresh-btn" type="button" @click="loadSavedExercises">
<el-icon><Refresh /></el-icon>
刷新
</button>
</div>
<div v-if="savedLoading" class="saved-loading">正在加载已保存练习...</div>
<div v-else-if="savedExercises.length === 0" class="saved-empty">
<el-icon><Trophy /></el-icon>
<span>暂无已保存练习</span>
</div>
<div v-else class="saved-grid">
<article v-for="item in savedExercises" :key="item.id" class="saved-card">
<div class="saved-cover">
<el-icon><Trophy /></el-icon>
<strong>{{ item.title }}</strong>
<span>{{ typeLabel(item.exercise_type) }} · {{ item.questions?.length || 0 }}</span>
</div>
<div class="saved-body">
<h4>{{ item.title }}</h4>
<p>{{ item.subject || '未设置学科' }} · {{ item.knowledge_points?.join('、') || '未设置知识点' }}</p>
<div class="saved-actions">
<button type="button" @click="previewSaved(item)"><el-icon><View /></el-icon>预览</button>
<button type="button" @click="publishSaved(item)"><el-icon><Share /></el-icon>发布</button>
<button type="button" @click="downloadSaved(item)"><el-icon><Download /></el-icon>下载</button>
<el-popconfirm title="确定删除这个练习?关联资源会同步下架。" @confirm="handleDeleteSaved(item.id)">
<template #reference>
<button type="button" class="danger"><el-icon><Delete /></el-icon>删除</button>
</template>
</el-popconfirm>
</div>
</div>
</article>
</div>
<TemplateGallery v-model="showTemplateGallery" kind="game" @use="handleUseTemplate" />
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { aiGenerateExercise, createExercise, deleteExercise, exportExerciseDocx, getExercise, getExercises, getExerciseAttempts, submitExerciseAttempt, updateExercise } from '@/api/exercise'
import { publishResource } from '@/api/resource'
import MarkdownContent from '@/components/common/MarkdownContent.vue'
import HtmlPreview from '@/components/common/HtmlPreview.vue'
import TemplateGallery from '@/components/common/TemplateGallery.vue'
import MaterialPicker from '@/components/common/MaterialPicker.vue'
import { useUserStore } from '@/stores/user'
import { downloadBlob } from '@/utils/download'
import { buildMaterialPromptBlock, type MaterialContextItem } from '@/utils/materialContext'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const generating = ref(false)
const result = ref<any>(null)
const savedLoading = ref(false)
const savedExercises = ref<any[]>([])
const quickPromptNotice = ref('')
const viewMode = ref<'interactive' | 'detail'>('interactive')
const currentQIdx = ref(0)
const userAnswers = reactive<Record<number, string>>({})
const revealedSet = reactive<Set<number>>(new Set())
const fillAnswer = ref('')
const attemptRecorded = ref(false)
const attemptSummary = ref<{ count: number; avgScore: number } | null>(null)
const answeredCount = computed(() => Object.keys(userAnswers).length)
const correctCount = computed(() => [...revealedSet].filter(idx => isCorrect(idx)).length)
const scorePercent = computed(() => answeredCount.value > 0 ? Math.round((correctCount.value / answeredCount.value) * 100) : 0)
const isCurrentCorrect = computed(() => isCorrect(currentQIdx.value))
watch(currentQIdx, () => {
fillAnswer.value = isRevealed(currentQIdx.value) ? getUserAnswer(currentQIdx.value) : ''
})
watch(answeredCount, (n) => {
const total = result.value?.questions?.length || 0
if (total > 0 && n >= total) recordAttempt()
})
const savedResultId = ref<number | null>(null)
const publishing = ref(false)
const selectedMaterials = ref<MaterialContextItem[]>([])
const showTemplateGallery = ref(false)
const suggestions = ['一元二次方程选择题', '英语语法填空题', '历史知识判断题']
const guideExamples = [
{ title: '数学选择题', prompt: '生成10道初中数学一元二次方程的选择题,难度中等,包含详细解析', icon: 'Trophy', bg: 'var(--gradient-orange)' },
{ title: '英语语法填空', prompt: '生成8道英语语法填空题,考查时态和语态的用法', icon: 'MagicStick', bg: 'var(--gradient-green)' },
{ title: '历史知识判断', prompt: '生成15道中国古代史判断题,涵盖秦汉至明清', icon: 'Flag', bg: 'var(--gradient-blue)' },
]
const capabilities = [
{ title: '即时作答', desc: '生成可直接在页面完成的互动题目' },
{ title: '答案解析', desc: '每道题保留答案、难度和解析说明' },
{ title: '课堂复用', desc: '保存后可按题型和知识点再次打开' },
]
const form = reactive({ prompt: '', exercise_type: 'choice', subject: '', count: 5, knowledge_points: [] as string[] })
const currentQuestion = computed(() => {
if (!result.value?.questions?.length) return { answer: '', explanation: '', html: '' }
return result.value.questions[currentQIdx.value] || { answer: '', explanation: '', html: '' }
})
function getUserAnswer(idx: number): string {
return userAnswers[idx] || ''
}
function isRevealed(idx: number): boolean {
return revealedSet.has(idx)
}
function isCorrect(idx: number): boolean {
if (!isRevealed(idx)) return false
const q = result.value?.questions?.[idx]
if (!q) return false
const ua = String(userAnswers[idx] || '').trim().toLowerCase()
const ca = String(q.answer || '').trim().toLowerCase()
return ua === ca
}
function selectOption(opt: string) {
if (isRevealed(currentQIdx.value)) return
userAnswers[currentQIdx.value] = opt
revealedSet.add(currentQIdx.value)
}
function submitFillAnswer() {
if (isRevealed(currentQIdx.value)) return
const ans = fillAnswer.value.trim()
if (!ans) return
userAnswers[currentQIdx.value] = ans
revealedSet.add(currentQIdx.value)
}
function optionClass(oi: number): string {
if (!isRevealed(currentQIdx.value)) return ''
const opt = currentQuestion.value.options?.[oi] || ''
if (opt === currentQuestion.value.answer) return 'is-correct'
if (opt === getUserAnswer(currentQIdx.value) && opt !== currentQuestion.value.answer) return 'is-wrong'
return 'is-dimmed'
}
function resetQuiz() {
recordAttempt()
Object.keys(userAnswers).forEach(k => delete userAnswers[Number(k)])
revealedSet.clear()
fillAnswer.value = ''
currentQIdx.value = 0
attemptRecorded.value = false
}
async function recordAttempt() {
if (attemptRecorded.value || answeredCount.value === 0 || !savedResultId.value) return
const total = result.value?.questions?.length || 0
attemptRecorded.value = true
try {
await submitExerciseAttempt(savedResultId.value, {
answers: Object.entries(userAnswers).map(([idx, ans]) => ({ q: Number(idx) + 1, a: ans })),
score: correctCount.value,
total: total || answeredCount.value,
})
} catch {
attemptRecorded.value = false
}
}
function handleUseTemplate(payload: { kind: string; template: any; params: Record<string, any>; html: string; title: string }) {
result.value = {
title: payload.title,
questions: [{
question: payload.template.name || '教学游戏',
html: payload.html,
answer: '',
explanation: payload.template.description || '',
options: [],
difficulty: '简单',
}],
}
savedResultId.value = null
resetQuiz()
const catMap: Record<string, string> = {
game_snake: 'game_snake', game_match: 'game_match', game_adventure: 'game_adventure',
drag_sort: 'drag_sort', matching: 'matching', memory: 'game_match',
}
if (payload.template.category && catMap[payload.template.category]) {
form.exercise_type = catMap[payload.template.category]
}
ElMessage.success('已应用模板,可直接预览或保存')
}
async function handleGenerate() {
if (!(await ensureLoggedIn())) return
generating.value = true
currentQIdx.value = 0
resetQuiz()
try {
const res: any = await aiGenerateExercise({
...form,
prompt: buildPromptWithMaterials(form.prompt),
})
userStore.refreshCreditsFromResponse(res)
result.value = res.data
savedResultId.value = null
ElMessage.success('练习生成成功')
} catch (e: any) {
if (handleAuthError(e)) return
ElMessage.error('生成失败:' + (e.response?.data?.detail || e.message || ''))
}
finally { generating.value = false }
}
function buildPromptWithMaterials(prompt: string) {
return `${prompt}${buildMaterialPromptBlock(selectedMaterials.value, '参考素材')}`.trim()
}
async function handleSave() {
if (!result.value) return
if (!(await ensureLoggedIn())) return
const payload = {
title: result.value.title,
exercise_type: form.exercise_type,
subject: form.subject,
knowledge_points: form.knowledge_points,
questions: result.value.questions,
}
const isUpdate = !!savedResultId.value
const saved: any = isUpdate
? await updateExercise(savedResultId.value!, payload)
: await createExercise(payload)
const item = saved.data || saved
savedResultId.value = item?.id || null
ElMessage.success(isUpdate ? '练习已更新' : '练习已保存')
await loadSavedExercises()
}
async function downloadCurrent() {
const id = await ensureSavedCurrent()
if (id) await downloadSaved({ id, title: result.value?.title || '课堂练习' })
}
async function downloadSaved(item: any) {
if (!(await ensureLoggedIn())) return
try {
const blob = await exportExerciseDocx(item.id)
downloadBlob(blob, `${safeFilename(item.title || '课堂练习')}-练习.docx`)
ElMessage.success('已开始下载')
} catch (e: any) {
ElMessage.error('下载失败:' + (e.response?.data?.detail || e.message || ''))
}
}
async function loadSavedExercises() {
if (!(await ensureLoggedIn({ silent: true }))) {
savedExercises.value = []
savedLoading.value = false
return
}
savedLoading.value = true
try {
const res: any = await getExercises({ limit: 50 })
savedExercises.value = res.data || res || []
} catch (e: any) {
if (handleAuthError(e)) {
savedExercises.value = []
return
}
ElMessage.error('加载已保存练习失败:' + (e.response?.data?.detail || e.message || ''))
} finally {
savedLoading.value = false
}
}
async function previewSaved(item: any) {
result.value = {
title: item.title,
questions: item.questions || [],
}
savedResultId.value = item.id
form.exercise_type = item.exercise_type || 'choice'
form.subject = item.subject || ''
form.knowledge_points = item.knowledge_points || []
currentQIdx.value = 0
viewMode.value = 'interactive'
attemptSummary.value = null
attemptRecorded.value = false
window.scrollTo({ top: 0, behavior: 'smooth' })
try {
const attempts: any = await getExerciseAttempts(item.id)
if (Array.isArray(attempts) && attempts.length > 0) {
const avg = Math.round(attempts.reduce((s: number, a: any) => s + (a.total > 0 ? (a.score / a.total) * 100 : 0), 0) / attempts.length)
attemptSummary.value = { count: attempts.length, avgScore: avg }
}
} catch { /* ignore */ }
}
async function handleDeleteSaved(id: number) {
if (!(await ensureLoggedIn())) return
await deleteExercise(id)
ElMessage.success('练习已删除,关联资源已下架')
await loadSavedExercises()
}
async function ensureSavedCurrent() {
if (!result.value) return null
if (!(await ensureLoggedIn())) return null
const payload = {
title: result.value.title || '课堂练习',
exercise_type: form.exercise_type,
subject: form.subject,
knowledge_points: form.knowledge_points,
questions: result.value.questions || [],
}
const saved: any = savedResultId.value
? await updateExercise(savedResultId.value, payload)
: await createExercise(payload)
const item = saved.data || saved
savedResultId.value = item?.id || null
await loadSavedExercises()
return savedResultId.value
}
async function publishCurrent() {
if (!result.value) return
publishing.value = true
try {
const id = await ensureSavedCurrent()
if (!id) return
await publishExerciseAsset({
id,
title: result.value.title || '课堂练习',
exercise_type: form.exercise_type,
subject: form.subject,
knowledge_points: form.knowledge_points,
questions: result.value.questions || [],
})
} finally {
publishing.value = false
}
}
async function publishSaved(item: any) {
if (!(await ensureLoggedIn())) return
await publishExerciseAsset(item)
}
async function publishExerciseAsset(item: any) {
const created: any = await publishResource({
resource_type: 'exercise',
title: item.title,
description: buildExerciseDescription(item),
content_ref: `exercise:${item.id}`,
file_url: '',
tags: ['教学游戏', typeLabel(item.exercise_type), ...(item.knowledge_points || []).slice(0, 3)],
subject: item.subject || '',
grade: '',
is_public: 1,
})
const resource = created.data || created
ElMessage.success('已发布到资源广场')
if (resource?.id) router.push(`/resources/${resource.id}`)
}
function buildExerciseDescription(item: any) {
const points = item.knowledge_points?.length ? `,覆盖${item.knowledge_points.join('、')}` : ''
return `${item.subject || '通用'}${typeLabel(item.exercise_type)}课堂练习,共${item.questions?.length || 0}${points},支持互动预览、答案解析和二次改编。`
}
function reuseCurrent() {
if (!result.value) return
localStorage.setItem('quick_create_prompt', `参考练习《${result.value.title || '课堂练习'}》,重新生成一套原创教学游戏。要求保留核心知识点,优化题目梯度、即时反馈和解析。`)
result.value = null
savedResultId.value = null
window.scrollTo({ top: 0, behavior: 'smooth' })
}
async function ensureLoggedIn(options: { silent?: boolean } = {}) {
if (!userStore.isLoggedIn()) {
if (!options.silent) ElMessage.warning('请先登录后再使用教学游戏功能')
await router.push('/login')
return false
}
if (!userStore.user) {
try {
await userStore.fetchProfile()
} catch {
if (!options.silent) ElMessage.warning('登录状态失效,请重新登录')
await router.push('/login')
return false
}
}
return true
}
function isAuthError(error: any) {
return error?.response?.status === 401 ||
(error?.response?.status === 403 && error?.response?.data?.detail === 'Not authenticated')
}
function handleAuthError(error: any) {
if (!isAuthError(error)) return false
userStore.clearToken()
ElMessage.warning('登录状态失效,请重新登录')
router.push('/login')
return true
}
function typeLabel(type: string) {
const map: Record<string, string> = {
choice: '选择题',
fill_blank: '填空题',
true_false: '判断题',
matching: '匹配题',
drag_sort: '排序拖拽',
game_snake: '贪吃蛇闯关',
game_match: '翻牌配对',
game_adventure: '冒险闯关',
}
return map[type] || '练习'
}
function safeFilename(value: string) {
return (value || '资源').replace(/[\\/:*?"<>|\r\n]+/g, '_').trim() || '资源'
}
onMounted(async () => {
const quickPrompt = localStorage.getItem('quick_create_prompt')
if (quickPrompt) {
form.prompt = quickPrompt
form.subject = inferSubject(quickPrompt)
form.exercise_type = inferExerciseType(quickPrompt)
form.count = inferCount(quickPrompt)
quickPromptNotice.value = '已带入首页游戏/练习指令,可直接生成或继续调整题型。'
localStorage.removeItem('quick_create_prompt')
}
await loadSavedExercises()
await openSavedFromRoute()
})
async function openSavedFromRoute() {
const openId = Number(route.query.open)
if (!Number.isFinite(openId) || openId <= 0) return
try {
const data: any = await getExercise(openId)
const item = data.data || data
previewSaved(item)
} catch {
ElMessage.warning('未能打开改编后的练习')
}
}
function inferSubject(text: string) {
const subjects = ['数学', '英语', '语文', '物理', '化学', '历史', '信息科技']
return subjects.find(item => text.includes(item)) || ''
}
function inferExerciseType(text: string) {
if (/填空/.test(text)) return 'fill_blank'
if (/判断|对错/.test(text)) return 'true_false'
if (/匹配|连线/.test(text)) return 'matching'
return 'choice'
}
function inferCount(text: string) {
const match = text.match(/(\d+)\s*道/)
const count = match ? Number(match[1]) : 5
return Number.isFinite(count) ? Math.min(Math.max(count, 1), 50) : 5
}
</script>
<style scoped>
.gen-hero { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
.gen-hero-icon { width: 52px; height: 52px; border-radius: 14px; display: flex; align-items: center; justify-content: center; }
.gen-hero h2 { font-size: 24px; font-weight: 700; color: var(--text-primary); }
.gen-hero p { font-size: 14px; color: var(--text-muted); }
.gen-input-section { background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 24px; margin-bottom: 24px; }
.quick-notice { min-height: 38px; display: flex; align-items: center; gap: 8px; border: 1px solid #dce8de; border-radius: 12px; background: #f0fbef; color: #00543d; padding: 8px 12px; margin-bottom: 14px; font-size: 13px; font-weight: 800; }
.prompt-box { margin-bottom: 16px; }
.prompt-box :deep(.el-textarea__inner) { border: none; background: #F8FAFC; border-radius: var(--radius-md); padding: 16px; font-size: 15px; line-height: 1.6; }
.prompt-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; }
.prompt-tags { display: flex; gap: 8px; }
.prompt-tag { padding: 4px 12px; background: #FFFBEB; color: #F59E0B; border-radius: 20px; font-size: 12px; cursor: pointer; transition: all 0.2s; }
.prompt-tag:hover { background: #F59E0B; color: #fff; }
.material-row { display: flex; flex-wrap: wrap; gap: 8px; padding: 14px 0 0; margin-top: 4px; border-top: 1px solid var(--border-color); }
.tpl-btn { display: inline-flex; align-items: center; gap: 6px; height: 40px; padding: 0 16px; border: 1px solid #fed7aa; border-radius: var(--radius-sm); background: #fff; color: #c2410c; cursor: pointer; font-size: 14px; font-weight: 600; transition: all 0.2s; }
.tpl-btn:hover { border-color: #ea580c; background: #fff7ed; }
.gen-btn { display: flex; align-items: center; gap: 8px; border: none; color: #fff; padding: 10px 28px; border-radius: var(--radius-sm); font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.3s; }
.gen-btn:hover:not(:disabled) { opacity: 0.9; transform: translateY(-1px); }
.gen-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.settings-row { display: flex; gap: 24px; align-items: center; padding-top: 16px; border-top: 1px solid var(--border-color); }
.setting-item { display: flex; align-items: center; gap: 8px; }
.setting-item label { font-size: 13px; color: var(--text-muted); white-space: nowrap; }
.loading-section { text-align: center; padding: 48px 0; }
.loading-bar { width: 300px; height: 4px; background: #E2E8F0; border-radius: 2px; margin: 0 auto 20px; overflow: hidden; }
.loading-bar-fill { height: 100%; width: 40%; background: var(--gradient-orange); border-radius: 2px; animation: loadingSlide 1.5s ease-in-out infinite; }
@keyframes loadingSlide { 0% { transform: translateX(-100%); } 100% { transform: translateX(350%); } }
.loading-section p { font-size: 14px; color: var(--text-secondary); }
.guide-section { animation: fadeIn 0.4s ease; }
.capability-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 14px; }
.capability-row div { min-height: 78px; border: 1px solid #eadfcb; border-radius: 12px; background: #fff; padding: 14px 16px; }
.capability-row strong { display: block; color: #9a5f00; font-size: 15px; margin-bottom: 6px; }
.capability-row span { color: #7a6850; font-size: 12px; line-height: 1.45; }
.guide-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.guide-card { display: flex; flex-direction: column; gap: 10px; background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 18px; cursor: pointer; transition: all 0.3s; }
.guide-card:hover { box-shadow: var(--shadow-md); border-color: #F59E0B; transform: translateY(-2px); }
.guide-icon { width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; }
.guide-info h4 { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.guide-info p { font-size: 12px; color: var(--text-muted); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.guide-arrow { font-size: 12px; color: #F59E0B; font-weight: 500; margin-top: auto; }
.result-section { animation: fadeInUp 0.5s ease; }
.result-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
.result-header h3 { font-size: 20px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.result-meta { font-size: 13px; color: var(--text-muted); }
.header-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
.mode-btn { display: flex; align-items: center; gap: 4px; padding: 6px 14px; border: 1px solid var(--border-color); border-radius: var(--radius-sm); background: #fff; font-size: 13px; color: var(--text-secondary); cursor: pointer; transition: all 0.2s; }
.mode-btn.active { background: #FFF7ED; border-color: #F59E0B; color: #F59E0B; }
.mode-btn:hover { border-color: #F59E0B; }
.mode-btn.publish { border-color: #f59e0b; color: #9a5f00; font-weight: 800; }
.mode-btn:disabled { opacity: .58; cursor: not-allowed; }
.save-btn { display: flex; align-items: center; gap: 6px; border: none; color: #fff; padding: 8px 20px; border-radius: var(--radius-sm); font-size: 14px; font-weight: 500; cursor: pointer; transition: opacity 0.2s; }
.save-btn:hover { opacity: 0.9; }
/* Interactive Mode */
.interactive-mode { }
.interactive-stage { background: #F1F5F9; border-radius: var(--radius-lg); padding: 24px; margin-bottom: 16px; }
.interactive-stage :deep(.html-preview) { border-radius: var(--radius-md); box-shadow: 0 4px 24px rgba(0,0,0,0.08); }
.interactive-nav { display: flex; align-items: center; justify-content: center; gap: 16px; margin-bottom: 16px; }
.nav-btn { width: 40px; height: 40px; border-radius: 50%; border: 1px solid var(--border-color); background: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s; }
.nav-btn:hover:not(:disabled) { border-color: #F59E0B; color: #F59E0B; }
.nav-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.nav-dots { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; }
.dot { width: 32px; height: 32px; border-radius: 8px; border: 1px solid var(--border-color); display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; color: var(--text-muted); cursor: pointer; transition: all 0.2s; }
.dot.active { background: var(--gradient-orange); border-color: transparent; color: #fff; }
.dot:hover:not(.active) { border-color: #F59E0B; }
.interactive-answer { background: #F8FAFC; border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 16px; }
.quiz-score-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px; border-radius: 10px; background: #f0fdf4; border: 1px solid #dce8de; margin-bottom: 14px; font-size: 13px; font-weight: 700; color: #00543d; flex-wrap: wrap; }
.quiz-score-bar .reset-btn { border: 1px solid #dbe8de; border-radius: 8px; background: #fff; color: #00543d; padding: 4px 12px; font-size: 12px; cursor: pointer; font-weight: 700; }
.quiz-score-bar .attempt-summary { color: #6b7f76; font-weight: 600; font-size: 12px; padding: 2px 10px; background: #fff; border-radius: 6px; border: 1px solid #e2ece4; }
.quiz-question-card { background: #fff; border: 1px solid var(--border-color); border-radius: 14px; padding: 20px; margin-bottom: 16px; }
.quiz-q-header { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.quiz-html-preview { margin: 12px 0; border-radius: 10px; overflow: hidden; border: 1px solid var(--border-color); }
.quiz-options { display: flex; flex-direction: column; gap: 8px; margin-top: 14px; }
.quiz-option { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border: 2px solid var(--border-color); border-radius: 10px; background: #fff; cursor: pointer; transition: all 0.2s; font-size: 14px; text-align: left; }
.quiz-option:hover:not(:disabled) { border-color: #F59E0B; background: #fffbeb; }
.quiz-option .opt-letter { width: 28px; height: 28px; border-radius: 50%; background: #f1f5f9; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 13px; color: var(--text-muted); flex-shrink: 0; }
.quiz-option.is-correct { border-color: #10B981; background: #ecfdf5; }
.quiz-option.is-correct .opt-letter { background: #10B981; color: #fff; }
.quiz-option.is-wrong { border-color: #EF4444; background: #fef2f2; }
.quiz-option.is-wrong .opt-letter { background: #EF4444; color: #fff; }
.quiz-option.is-dimmed { opacity: 0.5; }
.quiz-option:disabled { cursor: default; }
.opt-correct { color: #10B981; margin-left: auto; }
.opt-wrong { color: #EF4444; margin-left: auto; }
.quiz-fill-input { display: flex; gap: 8px; margin-top: 14px; }
.fill-input { flex: 1; height: 42px; border: 2px solid var(--border-color); border-radius: 10px; padding: 0 14px; font-size: 14px; outline: none; }
.fill-input:focus { border-color: #F59E0B; }
.fill-input:disabled { background: #f8fafc; }
.submit-answer-btn { height: 42px; padding: 0 18px; border: none; border-radius: 10px; background: #F59E0B; color: #fff; font-weight: 700; cursor: pointer; }
.quiz-feedback { margin-top: 16px; border-radius: 10px; padding: 14px 16px; }
.quiz-feedback.correct { background: #ecfdf5; border: 1px solid #a7f3d0; }
.quiz-feedback.wrong { background: #fef2f2; border: 1px solid #fecaca; }
.feedback-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; font-size: 14px; }
.feedback-head strong { font-size: 15px; }
.quiz-feedback.correct .feedback-head { color: #065f46; }
.quiz-feedback.wrong .feedback-head { color: #991b1b; }
.feedback-explain { padding-top: 8px; border-top: 1px solid rgba(0,0,0,0.06); font-size: 13px; line-height: 1.6; }
.dot.correct { background: #10B981; border-color: transparent; color: #fff; }
.dot.wrong { background: #EF4444; border-color: transparent; color: #fff; }
.answer-explain { margin-top: 8px; font-size: 13px; color: var(--text-muted); line-height: 1.6; }
/* Detail Mode */
.detail-mode { display: flex; flex-direction: column; gap: 16px; }
.question-card { background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 20px; transition: all 0.3s; }
.question-card:hover { box-shadow: var(--shadow-md); }
.q-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.q-num { width: 28px; height: 28px; background: var(--gradient-orange); border-radius: 8px; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 13px; font-weight: 700; }
.q-text { font-size: 15px; line-height: 1.7; color: var(--text-primary); margin-bottom: 14px; }
.q-preview { margin-bottom: 14px; border-radius: var(--radius-sm); overflow: hidden; }
.q-options { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 14px; }
.q-option { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border: 1px solid var(--border-color); border-radius: var(--radius-sm); font-size: 14px; transition: all 0.2s; }
.q-option.correct { border-color: #10B981; background: #ECFDF5; color: #059669; }
.opt-letter { width: 22px; height: 22px; background: #F1F5F9; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; color: var(--text-secondary); flex-shrink: 0; }
.q-option.correct .opt-letter { background: #10B981; color: #fff; }
.q-answer { display: flex; align-items: center; gap: 6px; color: #10B981; font-size: 14px; font-weight: 500; }
.q-explanation { margin-top: 10px; padding: 10px 14px; background: #F8FAFC; border-radius: var(--radius-sm); font-size: 13px; color: var(--text-muted); line-height: 1.6; }
.btn-loading { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
.saved-section { margin-top: 28px; background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 22px; }
.saved-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 18px; }
.saved-header h3 { font-size: 18px; font-weight: 700; color: var(--text-primary); margin-bottom: 4px; }
.saved-header p { font-size: 13px; color: var(--text-muted); }
.refresh-btn { height: 34px; display: inline-flex; align-items: center; gap: 6px; border: 1px solid #eadfcb; border-radius: 17px; background: #fff; color: #9a5f00; padding: 0 14px; font-weight: 700; cursor: pointer; }
.saved-loading,
.saved-empty { min-height: 120px; display: flex; align-items: center; justify-content: center; gap: 8px; color: var(--text-muted); border: 1px dashed #eadfcb; border-radius: var(--radius-md); background: #fffaf0; }
.saved-empty .el-icon { font-size: 24px; color: #d89a2a; }
.saved-grid { display: grid; grid-template-columns: repeat(3, minmax(220px, 1fr)); gap: 16px; }
.saved-card { overflow: hidden; border: 1px solid #eadfcb; border-radius: 12px; background: #fff; }
.saved-cover { height: 132px; display: flex; flex-direction: column; justify-content: center; gap: 8px; margin: 5px; padding: 18px; border-radius: 9px; color: #fff; background: linear-gradient(135deg, #c2410c, #f59e0b); }
.saved-cover .el-icon { font-size: 22px; opacity: .85; }
.saved-cover strong { font-size: 18px; line-height: 1.25; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.saved-cover span { width: fit-content; padding: 3px 8px; border-radius: 12px; background: rgba(255,255,255,.2); font-size: 12px; font-weight: 800; }
.saved-body { padding: 12px 16px 16px; }
.saved-body h4 { font-size: 15px; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 6px; }
.saved-body p { min-height: 36px; color: var(--text-muted); font-size: 12px; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.saved-actions { display: flex; gap: 8px; margin-top: 12px; }
.saved-actions button { height: 32px; display: inline-flex; align-items: center; justify-content: center; gap: 5px; border: 1px solid #eadfcb; border-radius: 8px; background: #fffaf0; color: #9a5f00; padding: 0 12px; cursor: pointer; font-weight: 700; }
.saved-actions .danger:hover { border-color: #fca5a5; background: #fef2f2; color: #ef4444; }
@media (max-width: 1080px) {
.saved-grid { grid-template-columns: repeat(2, minmax(220px, 1fr)); }
}
@media (max-width: 760px) {
.prompt-footer,
.settings-row,
.result-header,
.saved-header,
.header-actions { flex-direction: column; align-items: stretch; }
.capability-row,
.guide-cards,
.saved-grid,
.q-options { grid-template-columns: 1fr; }
}
</style>
+266
View File
@@ -0,0 +1,266 @@
<template>
<div class="favorites-page">
<section class="fav-hero">
<div>
<span class="eyebrow">个人收藏</span>
<h1>我的收藏</h1>
<p>统一管理你收藏的教学资源和社区帖子随时回顾改编或取消收藏</p>
</div>
<div class="fav-stats">
<span><el-icon><Collection /></el-icon>{{ resourceTotal }} 个资源</span>
<span><el-icon><ChatDotRound /></el-icon>{{ postTotal }} 篇帖子</span>
</div>
</section>
<section class="tab-panel">
<div class="segmented">
<button type="button" :class="{ active: activeTab === 'resource' }" @click="switchTab('resource')">
<el-icon><Collection /></el-icon>教学资源
</button>
<button type="button" :class="{ active: activeTab === 'post' }" @click="switchTab('post')">
<el-icon><ChatDotRound /></el-icon>社区帖子
</button>
</div>
<div class="sort-bar" v-if="activeTab === 'resource'">
<div class="sort-segmented">
<button v-for="opt in sortOptions" :key="opt.value" type="button" :class="{ active: sort === opt.value }" @click="setSort(opt.value)">{{ opt.label }}</button>
</div>
</div>
</section>
<!-- 资源收藏 -->
<section v-if="activeTab === 'resource'" class="fav-grid" v-loading="loading">
<article v-for="res in resources" :key="'r' + res.id" class="fav-card" @click="openResource(res)">
<div class="cover" :class="coverClass(res.resource_type)">
<span class="type-pill">{{ typeLabel(res.resource_type) }}</span>
<span class="fav-pill active"><el-icon><Star /></el-icon></span>
<div class="cover-text">
<small>{{ res.subject || '通用' }}</small>
<strong>{{ res.title }}</strong>
<em>{{ res.grade || res.resource_type || 'AI教学资源' }}</em>
</div>
</div>
<div class="card-body">
<h3>{{ res.title }}</h3>
<p>{{ res.description || 'AI 生成的教学资源,可直接预览或改编复用。' }}</p>
<div class="card-meta">
<span><el-icon><User /></el-icon>{{ res.name || '匿名教师' }}</span>
<span><el-icon><View /></el-icon>{{ formatCount(res.views) }}</span>
<span><el-icon><Download /></el-icon>{{ formatCount(res.downloads) }}</span>
</div>
<div class="card-actions">
<button type="button" @click.stop="previewResource(res)">预览</button>
<button type="button" class="primary" @click.stop="reuseResource(res)">改编</button>
<button type="button" class="danger" @click.stop="unfavResource(res)">取消收藏</button>
</div>
</div>
</article>
</section>
<div v-if="activeTab === 'resource' && !loading && resources.length === 0" class="empty-state">
<el-icon><Star /></el-icon>
<strong>还没有收藏任何资源</strong>
<span>去资源广场逛逛点击星标即可收藏到这里</span>
<button type="button" @click="$router.push('/resources')">浏览资源广场</button>
</div>
<!-- 帖子收藏 -->
<section v-if="activeTab === 'post'" class="post-list" v-loading="loading">
<article v-for="post in posts" :key="'p' + post.id" class="post-card" @click="openPost(post)">
<div class="post-head">
<div class="post-avatar">{{ (post.name || '匿')[0] }}</div>
<div class="post-author">
<strong>{{ post.name || '匿名教师' }}</strong>
<small>{{ post.subject || '通用' }}{{ post.school ? ' · ' + post.school : '' }}</small>
</div>
<span class="post-type">{{ postTypeLabel(post.post_type) }}</span>
</div>
<h3 class="post-title">{{ post.title }}</h3>
<p class="post-content">{{ post.content }}</p>
<div class="post-tags" v-if="post.tags && post.tags.length">
<span v-for="tag in post.tags.slice(0, 5)" :key="tag" class="tag">#{{ tag }}</span>
</div>
<div class="post-foot">
<span><el-icon><View /></el-icon>{{ formatCount(post.views) }}</span>
<span><el-icon><ChatLineRound /></el-icon>{{ formatCount(post.comments_count) }}</span>
<span><el-icon><Star /></el-icon>{{ formatCount(post.likes) }}</span>
<button type="button" class="danger" @click.stop="unfavPost(post)">取消收藏</button>
</div>
</article>
</section>
<div v-if="activeTab === 'post' && !loading && posts.length === 0" class="empty-state">
<el-icon><ChatDotRound /></el-icon>
<strong>还没有收藏任何帖子</strong>
<span>在模板社区找到感兴趣的内容点击星标收藏</span>
<button type="button" @click="$router.push('/community')">逛逛社区</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getFavoriteResources, unlikeResource } from '@/api/resource'
import { getFavoritePosts, unlikePost } from '@/api/community'
const router = useRouter()
const activeTab = ref<'resource' | 'post'>('resource')
const loading = ref(false)
const sort = ref('latest')
const resources = ref<any[]>([])
const posts = ref<any[]>([])
const resourceTotal = ref(0)
const postTotal = ref(0)
const sortOptions = [
{ value: 'latest', label: '最新收藏' },
{ value: 'popular', label: '最多下载' },
]
const typeLabels: Record<string, string> = {
courseware: '课件', animation: '动画', exercise: '练习',
lesson_plan: '教案', exam: '试卷', essay: '作文', mindmap: '思维导图', other: '资源',
}
const postTypeLabels: Record<string, string> = {
discussion: '讨论', share: '分享', question: '提问', template: '模板',
}
function typeLabel(t: string) { return typeLabels[t] || '资源' }
function postTypeLabel(t: string) { return postTypeLabels[t] || '帖子' }
function coverClass(t: string) { return `cover-${t || 'other'}` }
function formatCount(n?: number) {
if (!n || n <= 0) return '0'
if (n >= 10000) return (n / 10000).toFixed(1) + 'w'
return String(n)
}
function setSort(v: string) { sort.value = v; loadResources() }
function switchTab(tab: 'resource' | 'post') {
activeTab.value = tab
if (tab === 'resource' && resources.value.length === 0) loadResources()
if (tab === 'post' && posts.value.length === 0) loadPosts()
}
async function loadResources() {
loading.value = true
try {
const res = await getFavoriteResources({ sort: sort.value, limit: 60 })
const data = res.data?.data ?? res.data ?? []
resources.value = Array.isArray(data) ? data : []
resourceTotal.value = resources.value.length
} catch { resources.value = [] } finally { loading.value = false }
}
async function loadPosts() {
loading.value = true
try {
const res = await getFavoritePosts({ limit: 60 })
const data = res.data?.data ?? res.data ?? []
posts.value = Array.isArray(data) ? data : []
postTotal.value = posts.value.length
} catch { posts.value = [] } finally { loading.value = false }
}
function openResource(res: any) { router.push(`/resources/${res.id}`) }
function openPost(post: any) { router.push(`/community/${post.id}`) }
function previewResource(res: any) { router.push(`/resources/${res.id}`) }
function reuseResource(res: any) {
const map: Record<string, string> = {
courseware: '/courseware/create', animation: '/animation',
exercise: '/exercise', lesson_plan: '/lesson-plan', exam: '/exam', mindmap: '/mindmap',
}
router.push(map[res.resource_type] || '/resources/' + res.id)
}
async function unfavResource(res: any) {
try {
await unlikeResource(res.id)
resources.value = resources.value.filter(r => r.id !== res.id)
resourceTotal.value = resources.value.length
ElMessage.success('已取消收藏')
} catch { ElMessage.error('操作失败') }
}
async function unfavPost(post: any) {
try {
await unlikePost(post.id)
posts.value = posts.value.filter(p => p.id !== post.id)
postTotal.value = posts.value.length
ElMessage.success('已取消收藏')
} catch { ElMessage.error('操作失败') }
}
onMounted(loadResources)
</script>
<style scoped>
.favorites-page { max-width: 1200px; margin: 0 auto; padding: 24px 28px 60px; }
.fav-hero { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 24px; }
.fav-hero .eyebrow { font-size: 12px; color: #0f9d6e; font-weight: 700; letter-spacing: .5px; }
.fav-hero h1 { font-size: 28px; font-weight: 900; color: #0f2e22; margin: 4px 0; }
.fav-hero p { font-size: 14px; color: #5a7268; margin: 0; }
.fav-stats { display: flex; gap: 16px; }
.fav-stats span { display: inline-flex; align-items: center; gap: 5px; font-size: 13px; font-weight: 700; color: #3a5a50; background: #e8f5ef; padding: 7px 14px; border-radius: 20px; }
.tab-panel { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 12px; }
.segmented { display: inline-flex; background: #e8f0ec; border-radius: 10px; padding: 4px; }
.segmented button { display: inline-flex; align-items: center; gap: 6px; border: none; background: transparent; padding: 8px 20px; border-radius: 8px; font-size: 14px; font-weight: 700; color: #4a6a60; cursor: pointer; transition: all .2s; }
.segmented button.active { background: #fff; color: #0f9d6e; box-shadow: 0 1px 4px rgba(0,0,0,.08); }
.sort-segmented { display: inline-flex; gap: 4px; }
.sort-segmented button { border: 1px solid #cde0d6; background: #fff; padding: 6px 14px; border-radius: 7px; font-size: 13px; font-weight: 600; color: #4a6a60; cursor: pointer; }
.sort-segmented button.active { background: #0f9d6e; color: #fff; border-color: #0f9d6e; }
.fav-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 18px; }
.fav-card { border: 1px solid #e0ebe5; border-radius: 12px; overflow: hidden; background: #fff; cursor: pointer; transition: box-shadow .2s, transform .2s; }
.fav-card:hover { box-shadow: 0 6px 20px rgba(15,80,60,.12); transform: translateY(-2px); }
.cover { position: relative; height: 130px; display: flex; flex-direction: column; justify-content: flex-end; padding: 14px; color: #fff; overflow: hidden; }
.cover-courseware { background: linear-gradient(135deg,#2e7d6b,#0f4f3e); }
.cover-animation { background: linear-gradient(135deg,#5b3fa8,#3a2575); }
.cover-exercise { background: linear-gradient(135deg,#d97706,#92400e); }
.cover-lesson_plan { background: linear-gradient(135deg,#2563eb,#1e3a8a); }
.cover-exam { background: linear-gradient(135deg,#dc2626,#7f1d1d); }
.cover-mindmap { background: linear-gradient(135deg,#0891b2,#155e75); }
.cover-other { background: linear-gradient(135deg,#4a6a60,#2d4640); }
.type-pill { position: absolute; top: 10px; left: 12px; background: rgba(255,255,255,.92); color: #0f4f3e; font-size: 11px; font-weight: 800; padding: 3px 9px; border-radius: 5px; }
.fav-pill { position: absolute; top: 10px; right: 12px; color: #ffd700; font-size: 18px; }
.cover-text small { font-size: 11px; opacity: .85; }
.cover-text strong { display: block; font-size: 16px; font-weight: 800; margin: 2px 0; line-height: 1.3; }
.cover-text em { font-size: 12px; opacity: .8; font-style: normal; }
.card-body { padding: 14px; }
.card-body h3 { font-size: 15px; font-weight: 700; color: #1a3a30; margin: 0 0 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.card-body p { font-size: 13px; color: #6b8580; margin: 0 0 12px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.card-meta { display: flex; gap: 12px; font-size: 12px; color: #8aa39c; margin-bottom: 12px; }
.card-meta span { display: inline-flex; align-items: center; gap: 3px; }
.card-actions { display: flex; gap: 8px; }
.card-actions button { flex: 1; border: 1px solid #d0e0d8; background: #fff; color: #3a5a50; font-size: 13px; font-weight: 600; padding: 7px 0; border-radius: 7px; cursor: pointer; }
.card-actions button:hover { border-color: #0f9d6e; color: #0f9d6e; }
.card-actions button.primary { background: #0f9d6e; color: #fff; border-color: #0f9d6e; }
.card-actions button.danger { color: #c0392b; }
.post-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 18px; }
.post-card { border: 1px solid #e0ebe5; border-radius: 12px; background: #fff; padding: 18px; cursor: pointer; transition: box-shadow .2s; }
.post-card:hover { box-shadow: 0 6px 20px rgba(15,80,60,.1); }
.post-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.post-avatar { width: 36px; height: 36px; border-radius: 50%; background: linear-gradient(135deg,#2e7d6b,#0f4f3e); color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 15px; }
.post-author { flex: 1; }
.post-author strong { display: block; font-size: 14px; color: #1a3a30; }
.post-author small { font-size: 12px; color: #8aa39c; }
.post-type { font-size: 11px; font-weight: 700; background: #e8f5ef; color: #0f9d6e; padding: 3px 9px; border-radius: 5px; }
.post-title { font-size: 16px; font-weight: 800; color: #0f2e22; margin: 0 0 8px; }
.post-content { font-size: 13px; color: #5a7268; line-height: 1.6; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; margin: 0 0 10px; }
.post-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.tag { font-size: 12px; color: #0f9d6e; background: #f0f8f4; padding: 2px 8px; border-radius: 4px; }
.post-foot { display: flex; align-items: center; gap: 14px; font-size: 12px; color: #8aa39c; padding-top: 10px; border-top: 1px solid #f0f5f2; }
.post-foot span { display: inline-flex; align-items: center; gap: 3px; }
.post-foot button { margin-left: auto; border: none; background: transparent; color: #c0392b; font-size: 12px; font-weight: 600; cursor: pointer; }
.empty-state { text-align: center; padding: 60px 20px; color: #8aa39c; }
.empty-state .el-icon { font-size: 48px; color: #c8d8d0; }
.empty-state strong { display: block; font-size: 16px; color: #4a6a60; margin: 12px 0 4px; }
.empty-state span { display: block; font-size: 13px; margin-bottom: 16px; }
.empty-state button { border: none; background: #0f9d6e; color: #fff; font-size: 14px; font-weight: 600; padding: 9px 24px; border-radius: 8px; cursor: pointer; }
</style>
+179
View File
@@ -0,0 +1,179 @@
<template>
<div class="help-page">
<section class="help-hero">
<div>
<span class="eyebrow">使用帮助</span>
<h1>快速上手智教助手</h1>
<p>从一句话生成互动课件到命题组卷教案设计几分钟掌握全部核心功能</p>
</div>
<div class="hero-art">
<el-icon><Reading /></el-icon>
</div>
</section>
<section class="quick-nav">
<button v-for="s in sections" :key="s.id" type="button" :class="{ active: activeSection === s.id }" @click="scrollTo(s.id)">
<el-icon><component :is="s.icon" /></el-icon>{{ s.title }}
</button>
</section>
<section :id="'sec-start'" class="help-section">
<h2><el-icon><Promotion /></el-icon>三步生成你的第一份课件</h2>
<div class="steps">
<div class="step-card">
<span class="step-num">1</span>
<strong>描述需求</strong>
<p>在首页输入框用一句话描述教学想法例如六年级分数的意义与性质可上传教材材料辅助 AI 理解</p>
</div>
<div class="step-card">
<span class="step-num">2</span>
<strong>选择类型</strong>
<p>在左侧导航选择创作模式互动课件教学动画AI组题教案思维导图等不同模式产出不同格式</p>
</div>
<div class="step-card">
<span class="step-num">3</span>
<strong>生成与保存</strong>
<p>点击生成按钮AI 在数秒内完成创作预览满意后保存到我的作品随时编辑导出或分享给学生</p>
</div>
</div>
</section>
<section :id="'sec-modules'" class="help-section">
<h2><el-icon><Grid /></el-icon>核心功能模块</h2>
<div class="module-grid">
<article v-for="m in modules" :key="m.title" class="module-card" @click="$router.push(m.path)">
<div class="module-icon" :style="{ background: m.color }"><el-icon><component :is="m.icon" /></el-icon></div>
<div>
<h3>{{ m.title }}</h3>
<p>{{ m.desc }}</p>
</div>
</article>
</div>
</section>
<section :id="'sec-credits'" class="help-section">
<h2><el-icon><GoldMedal /></el-icon>积分与会员</h2>
<div class="credit-info">
<div class="credit-card">
<strong>每日签到</strong>
<p>每天可在个人中心签到领取积分连续签到奖励递增</p>
</div>
<div class="credit-card">
<strong>生成消耗</strong>
<p>AI 生成课件动画教案等内容时会消耗对应积分余额实时同步</p>
</div>
<div class="credit-card">
<strong>积分流水</strong>
<p>个人中心可查看完整的积分收支记录包括签到生成充值等</p>
</div>
</div>
</section>
<section :id="'sec-faq'" class="help-section">
<h2><el-icon><QuestionFilled /></el-icon>常见问题</h2>
<div class="faq-list">
<details v-for="(item, i) in faqs" :key="i" class="faq-item" :open="openFaq === i">
<summary><span>{{ item.q }}</span><el-icon><ArrowDown /></el-icon></summary>
<p>{{ item.a }}</p>
</details>
</div>
</section>
<section class="help-footer">
<el-icon><Headset /></el-icon>
<div>
<strong>还有疑问</strong>
<p>可通过 AI 教学助手随时提问或在模板社区与其他教师交流经验</p>
</div>
<button type="button" class="cta-btn" @click="$router.push('/assistant')">咨询AI助手</button>
</section>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const activeSection = ref('start')
const openFaq = ref(0)
const sections = [
{ id: 'start', title: '快速上手', icon: 'Promotion' },
{ id: 'modules', title: '功能模块', icon: 'Grid' },
{ id: 'credits', title: '积分说明', icon: 'GoldMedal' },
{ id: 'faq', title: '常见问题', icon: 'QuestionFilled' },
]
const modules = [
{ title: 'AI互动课件', desc: '一句话生成多页可交互 HTML 课件,支持编辑、导出和分享。', icon: 'MagicStick', color: 'linear-gradient(135deg,#2e7d6b,#0f4f3e)', path: '/courseware/create' },
{ title: '教学动画', desc: '生成数学公式、物理实验等可视化教学动画,36+ 学科模板。', icon: 'Film', color: 'linear-gradient(135deg,#5b3fa8,#3a2575)', path: '/animation' },
{ title: 'AI命题', desc: '按知识点、题型、难度自动组卷,支持 Word 导出与打印。', icon: 'Finished', color: 'linear-gradient(135deg,#dc2626,#7f1d1d)', path: '/exam' },
{ title: 'AI组题', desc: '8 种题型的互动练习生成,含游戏化模板和作答统计。', icon: 'List', color: 'linear-gradient(135deg,#d97706,#92400e)', path: '/exercise' },
{ title: 'AI教案', desc: '按课题、学科、年级生成结构化教学设计。', icon: 'Tickets', color: 'linear-gradient(135deg,#db2777,#831843)', path: '/lesson-plan' },
{ title: '作文批改', desc: '多维度智能批改作文,提供评分、评语和修改建议。', icon: 'EditPen', color: 'linear-gradient(135deg,#2563eb,#1e3a8a)', path: '/essay-grade' },
{ title: 'AI思维导图', desc: '生成层次化知识思维导图,可折叠交互、HTML 导出。', icon: 'Connection', color: 'linear-gradient(135deg,#0891b2,#155e75)', path: '/mindmap' },
{ title: '课堂工具', desc: '投票、问答、测验、签到,支持学生扫码实时提交与统计。', icon: 'Monitor', color: 'linear-gradient(135deg,#16a34a,#14532d)', path: '/classroom' },
]
const faqs = [
{ q: '生成的内容会自动保存吗?', a: '课件、动画、练习等在生成后会进入预览状态。你需要点击「保存」按钮才会存入「我的作品」。未保存的内容刷新页面后会丢失。' },
{ q: '积分不够了怎么办?', a: '每日签到可领取免费积分,连续签到奖励递增。也可联系管理员获取更多积分。所有积分变动均可在个人中心查看流水。' },
{ q: '如何把课件分享给学生?', a: '在课件详情页点击「分享」生成专属链接或投放码,学生无需登录即可打开预览。你随时可以撤销分享。' },
{ q: '生成的动画/课件可以编辑吗?', a: '可以。课件支持完整的页面编辑器(增删页面、撤销重做、主题换肤)。动画和练习可在保存后二次生成或改编。' },
{ q: '课堂工具怎么收集学生答题数据?', a: '创建课堂活动后,系统生成投放大屏和加入链接/投放码。学生扫码提交答案后,教师端实时查看统计图表,支持 Excel 导出。' },
{ q: '支持哪些格式的材料上传?', a: '素材库支持 docx、pptx、pdf、xlsx、txt、md、csv、json 文档解析,以及 png/jpg 等图片(经 AI 视觉 OCR 提取文字)。' },
{ q: '忘记密码怎么办?', a: '在登录页点击「忘记密码」,输入手机号获取验证码即可重置。验证码 5 分钟内有效。' },
]
function scrollTo(id: string) {
activeSection.value = id
document.getElementById('sec-' + id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
</script>
<style scoped>
.help-page { max-width: 960px; margin: 0 auto; padding: 24px 28px 60px; }
.help-hero { display: flex; justify-content: space-between; align-items: center; margin-bottom: 28px; }
.help-hero .eyebrow { font-size: 12px; color: #0f9d6e; font-weight: 700; letter-spacing: .5px; }
.help-hero h1 { font-size: 30px; font-weight: 900; color: #0f2e22; margin: 4px 0; }
.help-hero p { font-size: 14px; color: #5a7268; margin: 0; max-width: 520px; }
.hero-art { width: 80px; height: 80px; border-radius: 20px; background: linear-gradient(135deg,#d9f0d6,#b8e6cc); display: flex; align-items: center; justify-content: center; font-size: 40px; color: #0f9d6e; flex-shrink: 0; }
.quick-nav { display: flex; gap: 8px; margin-bottom: 32px; flex-wrap: wrap; position: sticky; top: 0; z-index: 10; background: #f7faf8; padding: 10px 0; }
.quick-nav button { display: inline-flex; align-items: center; gap: 6px; border: 1px solid #d0e0d8; background: #fff; padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; color: #4a6a60; cursor: pointer; transition: all .2s; }
.quick-nav button.active { background: #0f9d6e; color: #fff; border-color: #0f9d6e; }
.help-section { margin-bottom: 40px; scroll-margin-top: 70px; }
.help-section h2 { display: flex; align-items: center; gap: 8px; font-size: 20px; font-weight: 800; color: #0f2e22; margin: 0 0 18px; }
.help-section h2 .el-icon { color: #0f9d6e; }
.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.step-card { border: 1px solid #e0ebe5; border-radius: 12px; padding: 20px; background: #fff; position: relative; }
.step-num { display: inline-flex; width: 32px; height: 32px; border-radius: 50%; background: #0f9d6e; color: #fff; font-weight: 900; font-size: 15px; align-items: center; justify-content: center; margin-bottom: 12px; }
.step-card strong { display: block; font-size: 16px; color: #0f2e22; margin-bottom: 6px; }
.step-card p { font-size: 13px; color: #5a7268; line-height: 1.6; margin: 0; }
.module-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; }
.module-card { display: flex; gap: 14px; border: 1px solid #e0ebe5; border-radius: 12px; padding: 16px; background: #fff; cursor: pointer; transition: box-shadow .2s, transform .2s; }
.module-card:hover { box-shadow: 0 4px 16px rgba(15,80,60,.1); transform: translateY(-2px); }
.module-icon { width: 44px; height: 44px; border-radius: 11px; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 22px; flex-shrink: 0; }
.module-card h3 { font-size: 15px; font-weight: 700; color: #0f2e22; margin: 2px 0 4px; }
.module-card p { font-size: 13px; color: #6b8580; line-height: 1.5; margin: 0; }
.credit-info { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.credit-card { border: 1px solid #e0ebe5; border-radius: 12px; padding: 18px; background: linear-gradient(180deg,#f0f9f4,#fff); }
.credit-card strong { display: flex; align-items: center; gap: 6px; font-size: 15px; color: #0f4f3e; margin-bottom: 8px; }
.credit-card p { font-size: 13px; color: #5a7268; line-height: 1.6; margin: 0; }
.faq-list { display: grid; gap: 10px; }
.faq-item { border: 1px solid #e0ebe5; border-radius: 10px; background: #fff; overflow: hidden; transition: border-color .2s; }
.faq-item[open] { border-color: #0f9d6e; }
.faq-item summary { display: flex; justify-content: space-between; align-items: center; padding: 14px 18px; font-size: 14px; font-weight: 700; color: #1a3a30; cursor: pointer; list-style: none; }
.faq-item summary::-webkit-details-marker { display: none; }
.faq-item summary .el-icon { transition: transform .2s; color: #0f9d6e; }
.faq-item[open] summary .el-icon { transform: rotate(180deg); }
.faq-item p { padding: 0 18px 16px; font-size: 13px; color: #5a7268; line-height: 1.7; margin: 0; }
.help-footer { display: flex; align-items: center; gap: 16px; border-radius: 14px; background: linear-gradient(135deg,#0f4f3e,#0f9d6e); color: #fff; padding: 22px 26px; }
.help-footer .el-icon { font-size: 36px; opacity: .85; }
.help-footer strong { display: block; font-size: 17px; }
.help-footer p { font-size: 13px; opacity: .85; margin: 4px 0 0; }
.cta-btn { margin-left: auto; border: none; background: #fff; color: #0f9d6e; font-size: 14px; font-weight: 700; padding: 11px 24px; border-radius: 9px; cursor: pointer; white-space: nowrap; }
@media (max-width: 768px) {
.steps, .module-grid, .credit-info { grid-template-columns: 1fr; }
.help-footer { flex-wrap: wrap; }
.cta-btn { margin-left: 0; }
}
</style>
File diff suppressed because it is too large Load Diff
+650
View File
@@ -0,0 +1,650 @@
<template>
<div class="page-container">
<div class="gen-hero">
<div class="gen-hero-icon" style="background: var(--gradient-pink)">
<el-icon :size="28" color="#fff"><Tickets /></el-icon>
</div>
<div>
<h2>大单元教案</h2>
<p>AI生成结构化教学设计支持目标重难点活动作业和评价环节</p>
</div>
</div>
<div class="gen-input-section">
<div v-if="quickPromptNotice" class="quick-notice">
<el-icon><MagicStick /></el-icon>
<span>{{ quickPromptNotice }}</span>
</div>
<div class="settings-grid">
<div class="setting-item">
<label>课题名称</label>
<el-input v-model="form.title" placeholder="例如:一元二次方程" size="default" />
</div>
<div class="setting-item">
<label>学科</label>
<el-select v-model="form.subject" placeholder="选择学科" size="default" style="width: 100%">
<el-option v-for="s in subjects" :key="s" :label="s" :value="s" />
</el-select>
</div>
<div class="setting-item">
<label>年级</label>
<el-select v-model="form.grade" placeholder="选择年级" size="default" style="width: 100%">
<el-option v-for="g in grades" :key="g" :label="g" :value="g" />
</el-select>
</div>
<div class="setting-item">
<label>课时(分钟)</label>
<el-input-number v-model="form.duration" :min="20" :max="120" size="default" style="width: 100%" />
</div>
</div>
<div class="setting-full">
<label>教学目标</label>
<el-select v-model="form.objectives" multiple filterable allow-create placeholder="输入教学目标后回车" size="default" style="width: 100%">
</el-select>
</div>
<div class="setting-full">
<label>附加要求</label>
<el-input v-model="form.extra_requirements" type="textarea" :rows="2" placeholder="其他特殊要求..." resize="none" />
</div>
<div class="material-row">
<MaterialPicker v-model="selectedMaterials" />
</div>
<div class="gen-action">
<button class="gen-btn" style="background: var(--gradient-pink)" :disabled="!form.title || generating" @click="handleGenerate">
<el-icon v-if="!generating"><MagicStick /></el-icon>
<span v-if="generating" class="btn-loading"></span>
{{ generating ? 'AI生成中...' : '生成教案' }}
</button>
</div>
</div>
<div v-if="generating" class="loading-section">
<div class="loading-bar"><div class="loading-bar-fill"></div></div>
<p>AI正在生成教案...</p>
<p style="font-size:13px;color:var(--text-secondary);opacity:.65;margin-top:10px">模型深度推理中通常需要 1-2 分钟请耐心等待</p>
</div>
<div v-else-if="!result" class="guide-section">
<div class="capability-row">
<div v-for="item in capabilities" :key="item.title" class="capability-item">
<strong>{{ item.title }}</strong>
<span>{{ item.desc }}</span>
</div>
</div>
<div class="guide-cards">
<div class="guide-card" v-for="g in guideExamples" :key="g.title" @click="applyGuide(g)">
<div class="guide-icon" :style="{ background: g.bg }">
<el-icon :size="18" color="#fff"><component :is="g.icon" /></el-icon>
</div>
<div class="guide-info">
<h4>{{ g.title }}</h4>
<p>{{ g.desc }}</p>
</div>
<span class="guide-arrow">试试 </span>
</div>
</div>
</div>
<div v-if="result" class="result-section">
<div class="result-header">
<h3>{{ result.title }}</h3>
<div class="result-actions">
<button class="tool-btn" type="button" @click="reuseCurrent">
<el-icon><MagicStick /></el-icon>
用它生成
</button>
<button class="tool-btn publish" type="button" :disabled="publishing" @click="publishCurrent">
<el-icon><Share /></el-icon>
{{ publishing ? '发布中' : '发布资源' }}
</button>
<button class="save-btn" style="background: var(--gradient-pink)" @click="handleSave"><el-icon><Check /></el-icon> 保存教案</button>
<button class="tool-btn" type="button" @click="downloadCurrent">
<el-icon><Download /></el-icon>
下载Word
</button>
<button class="tool-btn" type="button" @click="printCurrent">
<el-icon><Printer /></el-icon>
打印PDF
</button>
</div>
</div>
<div class="info-tags">
<div class="info-group">
<span class="info-label">教学目标</span>
<div class="tag-list">
<el-tag v-for="(obj, i) in result.objectives" :key="i" size="small" effect="plain">{{ obj }}</el-tag>
</div>
</div>
<div class="info-group">
<span class="info-label">教学重点</span>
<div class="tag-list">
<el-tag v-for="(kp, i) in result.key_points" :key="i" type="warning" size="small" effect="plain">{{ kp }}</el-tag>
</div>
</div>
<div class="info-group">
<span class="info-label">教学难点</span>
<div class="tag-list">
<el-tag v-for="(d, i) in result.difficulties" :key="i" type="danger" size="small" effect="plain">{{ d }}</el-tag>
</div>
</div>
</div>
<div class="phases-timeline">
<div v-for="(phase, i) in result.phases" :key="i" class="phase-card">
<div class="phase-marker">
<div class="phase-dot" :style="{ background: phaseColors[i % phaseColors.length] }">{{ i + 1 }}</div>
<div class="phase-line" v-if="i < result.phases.length - 1"></div>
</div>
<div class="phase-content">
<div class="phase-header">
<h4>{{ phase.name }}</h4>
<span class="phase-duration">{{ phase.duration }}分钟</span>
</div>
<div v-if="phase.activities?.length" class="phase-block">
<span class="block-label">教学活动</span>
<div class="phase-list"><MarkdownContent v-for="(a, j) in phase.activities" :key="j" :content="'- ' + a" /></div>
</div>
<div v-if="phase.teacher_actions?.length" class="phase-block">
<span class="block-label teacher">教师行为</span>
<div class="phase-list"><MarkdownContent v-for="(a, j) in phase.teacher_actions" :key="j" :content="'- ' + a" /></div>
</div>
<div v-if="phase.student_actions?.length" class="phase-block">
<span class="block-label student">学生行为</span>
<div class="phase-list"><MarkdownContent v-for="(a, j) in phase.student_actions" :key="j" :content="'- ' + a" /></div>
</div>
</div>
</div>
</div>
<div v-if="result.homework?.length" class="homework-section">
<h4>课后作业</h4>
<ol><li v-for="(h, i) in result.homework" :key="i"><MarkdownContent :content="h" /></li></ol>
</div>
</div>
<section class="saved-section">
<div class="saved-header">
<div>
<h3>已保存教案</h3>
<p>保存后的大单元教案会出现在这里方便回看和复用</p>
</div>
<button class="refresh-btn" type="button" @click="loadSavedPlans">
<el-icon><Refresh /></el-icon>
刷新
</button>
</div>
<div v-if="savedLoading" class="saved-loading">正在加载已保存教案...</div>
<div v-else-if="savedPlans.length === 0" class="saved-empty">
<el-icon><Tickets /></el-icon>
<span>暂无已保存教案</span>
</div>
<div v-else class="saved-grid">
<article v-for="item in savedPlans" :key="item.id" class="saved-card">
<div class="saved-cover">
<el-icon><Tickets /></el-icon>
<strong>{{ item.title }}</strong>
<span>{{ item.subject || '未设置学科' }} · {{ item.grade || '未设置年级' }}</span>
</div>
<div class="saved-body">
<h4>{{ item.title }}</h4>
<p>{{ item.objectives?.join('、') || '暂无教学目标' }}</p>
<div class="saved-actions">
<button type="button" @click="previewSaved(item)"><el-icon><View /></el-icon>预览</button>
<button type="button" @click="publishSaved(item)"><el-icon><Share /></el-icon>发布</button>
<button type="button" @click="downloadSaved(item)"><el-icon><Download /></el-icon>下载</button>
<el-popconfirm title="确定删除这个教案?关联资源会同步下架。" @confirm="handleDeleteSaved(item.id)">
<template #reference>
<button type="button" class="danger"><el-icon><Delete /></el-icon>删除</button>
</template>
</el-popconfirm>
</div>
</div>
</article>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { aiGenerateLessonPlan, createLessonPlan, deleteLessonPlan, exportLessonPlanDocx, getLessonPlan, getLessonPlans, updateLessonPlan } from '@/api/lessonPlan'
import { publishResource } from '@/api/resource'
import MarkdownContent from '@/components/common/MarkdownContent.vue'
import MaterialPicker from '@/components/common/MaterialPicker.vue'
import { useUserStore } from '@/stores/user'
import { downloadBlob } from '@/utils/download'
import { buildMaterialPromptBlock, type MaterialContextItem } from '@/utils/materialContext'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const generating = ref(false)
const result = ref<any>(null)
const savedLoading = ref(false)
const savedPlans = ref<any[]>([])
const quickPromptNotice = ref('')
const savedResultId = ref<number | null>(null)
const publishing = ref(false)
const selectedMaterials = ref<MaterialContextItem[]>([])
const subjects = ['语文', '数学', '英语', '物理', '化学', '生物', '历史', '地理', '政治']
const grades = ['一年级', '二年级', '三年级', '四年级', '五年级', '六年级', '初一', '初二', '初三', '高一', '高二', '高三']
const phaseColors = ['#6366F1', '#06B6D4', '#10B981', '#F59E0B', '#EC4899']
const guideExamples = [
{ title: '数学一元二次方程', desc: '初三数学,45分钟,包含引入、讲解、练习、总结四个环节', icon: 'Tickets', bg: 'var(--gradient-pink)', title_val: '一元二次方程', subject_val: '数学', grade_val: '初三' },
{ title: '语文古诗词鉴赏', desc: '高一语文,讲解李白《将进酒》,90分钟大课', icon: 'Document', bg: 'var(--gradient-blue)', title_val: '古诗词鉴赏:将进酒', subject_val: '语文', grade_val: '高一' },
{ title: '英语口语对话课', desc: '初二英语,日常对话练习,注重口语表达训练', icon: 'ChatDotRound', bg: 'var(--gradient-green)', title_val: '日常英语口语对话', subject_val: '英语', grade_val: '初二' },
]
const capabilities = [
{ title: '目标到活动闭环', desc: '围绕教学目标生成重点、难点、活动、作业与评价建议。' },
{ title: '大单元可拆课时', desc: '可用课时长度控制节奏,适配导入、探究、练习、总结等环节。' },
{ title: '沉淀校本模板', desc: '保存后的教案可复用为同学科、同年级的备课资产。' },
]
const form = reactive({ title: '', subject: '', grade: '', objectives: [] as string[], duration: 45, extra_requirements: '' })
function applyGuide(g: typeof guideExamples[0]) {
form.title = g.title_val
form.subject = g.subject_val
form.grade = g.grade_val
}
async function handleGenerate() {
if (!(await ensureLoggedIn())) return
generating.value = true
try {
const res: any = await aiGenerateLessonPlan({
...form,
extra_requirements: buildExtraRequirementsWithMaterials(),
})
userStore.refreshCreditsFromResponse(res)
result.value = res.data
savedResultId.value = null
ElMessage.success('教案生成成功')
} catch (e: any) {
if (handleAuthError(e)) return
ElMessage.error('生成失败:' + (e.response?.data?.detail || e.message || ''))
}
finally { generating.value = false }
}
function buildExtraRequirementsWithMaterials() {
return `${form.extra_requirements}${buildMaterialPromptBlock(selectedMaterials.value, '参考素材')}`.trim()
}
async function handleSave() {
if (!result.value) return
if (!(await ensureLoggedIn())) return
const payload = buildLessonPlanPayload()
const isUpdate = !!savedResultId.value
const saved: any = isUpdate
? await updateLessonPlan(savedResultId.value!, payload)
: await createLessonPlan(payload)
const item = saved.data || saved
savedResultId.value = item?.id || null
ElMessage.success(isUpdate ? '教案已更新' : '教案已保存')
await loadSavedPlans()
}
function printCurrent() {
const r = result.value
if (!r) return
const w = window.open('', '_blank', 'width=800,height=600')
if (!w) { ElMessage.warning('请允许弹出窗口以打印教案'); return }
const esc = (s: any) => String(s||'').replace(/</g,'&lt;').replace(/>/g,'&gt;')
const objHtml = (r.objectives||[]).map((o:any)=>'<li>'+esc(o)+'</li>').join('')
const keyHtml = (r.key_points||[]).map((o:any)=>'<li>'+esc(o)+'</li>').join('')
const diffHtml = (r.difficulties||[]).map((o:any)=>'<li>'+esc(o)+'</li>').join('')
const phases = r.content?.phases || r.content?.steps || r.phases || []
const phaseHtml = (Array.isArray(phases)?phases:[]).map((ph:any,i:number)=>{
const name = ph?.name||ph?.title||'环节'+(i+1)
const acts = ph?.activities||ph?.student_actions||[]
const actHtml = (Array.isArray(acts)?acts:[]).map((a:any)=>'<li>'+esc(a)+'</li>').join('')
return '<div class="phase"><h3>'+(i+1)+'. '+esc(name)+(ph?.duration?''+ph.duration+'分钟)':'')+'</h3>'+(actHtml?'<ul>'+actHtml+'</ul>':'')+'</div>'
}).join('')
const hwHtml = (r.homework||[]).map((o:any)=>'<li>'+esc(o)+'</li>').join('')
w.document.write('<html><head><title>'+esc(r.title||'教案')+'</title><style>'+
'body{font-family:SimSun,serif;padding:40px;max-width:760px;margin:0 auto;color:#1f2937;line-height:1.8}'+
'h1{text-align:center;border-bottom:2px solid #333;padding-bottom:10px}'+
'.meta{text-align:center;color:#666;margin:8px 0 24px}'+
'h2{margin-top:24px;border-left:4px solid #4f46e5;padding-left:8px}'+
'ul{padding-left:24px}.phase{margin:16px 0;padding:10px;background:#f8fafc;border-radius:6px}'+
'@media print{button{display:none}}'+
'</style></head><body>'+
'<button onclick="window.print()" style="position:fixed;top:10px;right:10px;padding:8px 20px;background:#4f46e5;color:#fff;border:none;border-radius:6px;cursor:pointer">打印 / 另存为PDF</button>'+
'<h1>'+esc(r.title||'大单元教案')+'</h1>'+
'<p class="meta">'+[r.subject,r.grade,r.duration?(r.duration+'分钟'):null].filter(Boolean).join(' / ')+'</p>'+
(objHtml?'<h2>教学目标</h2><ul>'+objHtml+'</ul>':'')+
(keyHtml?'<h2>教学重点</h2><ul>'+keyHtml+'</ul>':'')+
(diffHtml?'<h2>教学难点</h2><ul>'+diffHtml+'</ul>':'')+
(phaseHtml?'<h2>教学过程</h2>'+phaseHtml:'')+
(hwHtml?'<h2>课后作业</h2><ul>'+hwHtml+'</ul>':'')+
'</body></html>')
w.document.close()
}
async function downloadCurrent() {
const id = await ensureSavedCurrent()
if (id) await downloadSaved({ id, title: result.value?.title || form.title || '大单元教案' })
}
async function downloadSaved(item: any) {
if (!(await ensureLoggedIn())) return
try {
const blob = await exportLessonPlanDocx(item.id)
downloadBlob(blob, `${safeFilename(item.title || '大单元教案')}-教案.docx`)
ElMessage.success('已开始下载')
} catch (e: any) {
ElMessage.error('下载失败:' + (e.response?.data?.detail || e.message || ''))
}
}
async function loadSavedPlans() {
if (!(await ensureLoggedIn({ silent: true }))) {
savedPlans.value = []
savedLoading.value = false
return
}
savedLoading.value = true
try {
const res: any = await getLessonPlans({ limit: 50 })
savedPlans.value = res.data || res || []
} catch (e: any) {
if (handleAuthError(e)) {
savedPlans.value = []
return
}
ElMessage.error('加载已保存教案失败:' + (e.response?.data?.detail || e.message || ''))
} finally {
savedLoading.value = false
}
}
function previewSaved(item: any) {
result.value = item.content || item
form.title = item.title || ''
form.subject = item.subject || ''
form.grade = item.grade || ''
form.duration = item.duration || 45
form.objectives = item.objectives || []
savedResultId.value = item.id
window.scrollTo({ top: 0, behavior: 'smooth' })
}
async function handleDeleteSaved(id: number) {
if (!(await ensureLoggedIn())) return
await deleteLessonPlan(id)
ElMessage.success('教案已删除,关联资源已下架')
await loadSavedPlans()
}
function buildLessonPlanPayload() {
return {
...form,
title: result.value?.title || form.title || '大单元教案',
objectives: result.value?.objectives || form.objectives || [],
key_points: result.value?.key_points || [],
difficulties: result.value?.difficulties || [],
homework: result.value?.homework || [],
duration: form.duration,
content: result.value || {},
}
}
async function ensureSavedCurrent() {
if (!result.value) return null
if (!(await ensureLoggedIn())) return null
const payload = buildLessonPlanPayload()
const saved: any = savedResultId.value
? await updateLessonPlan(savedResultId.value, payload)
: await createLessonPlan(payload)
const item = saved.data || saved
savedResultId.value = item?.id || null
await loadSavedPlans()
return savedResultId.value
}
async function publishCurrent() {
if (!result.value) return
publishing.value = true
try {
const id = await ensureSavedCurrent()
if (!id) return
await publishLessonPlanAsset({
id,
...buildLessonPlanPayload(),
})
} finally {
publishing.value = false
}
}
async function publishSaved(item: any) {
if (!(await ensureLoggedIn())) return
await publishLessonPlanAsset(item)
}
async function publishLessonPlanAsset(item: any) {
const content = item.content || item
const created: any = await publishResource({
resource_type: 'lesson_plan',
title: item.title || content.title || '大单元教案',
description: buildLessonPlanDescription(item),
content_ref: `lesson_plan:${item.id}`,
file_url: '',
tags: ['大单元教案', item.subject || '通用', ...(item.key_points || content.key_points || []).slice(0, 2)],
subject: item.subject || '',
grade: item.grade || '',
is_public: 1,
})
const resource = created.data || created
ElMessage.success('已发布到资源广场')
if (resource?.id) router.push(`/resources/${resource.id}`)
}
function buildLessonPlanDescription(item: any) {
const content = item.content || item
const phases = content.phases?.length || 0
return `${item.subject || content.subject || '通用'}${item.grade ? ` · ${item.grade}` : ''}大单元教案,包含${phases || '多个'}教学环节,覆盖目标、重难点、活动、作业和评价建议。`
}
function reuseCurrent() {
if (!result.value) return
localStorage.setItem('quick_create_prompt', `参考教案《${result.value.title || form.title || '大单元教案'}》,重新生成一份原创大单元教学设计。要求保留目标结构,优化活动、评价和作业闭环。`)
result.value = null
savedResultId.value = null
window.scrollTo({ top: 0, behavior: 'smooth' })
}
async function ensureLoggedIn(options: { silent?: boolean } = {}) {
if (!userStore.isLoggedIn()) {
if (!options.silent) ElMessage.warning('请先登录后再使用教案生成功能')
await router.push('/login')
return false
}
if (!userStore.user) {
try {
await userStore.fetchProfile()
} catch {
if (!options.silent) ElMessage.warning('登录状态失效,请重新登录')
await router.push('/login')
return false
}
}
return true
}
function isAuthError(error: any) {
return error?.response?.status === 401 ||
(error?.response?.status === 403 && error?.response?.data?.detail === 'Not authenticated')
}
function handleAuthError(error: any) {
if (!isAuthError(error)) return false
userStore.clearToken()
ElMessage.warning('登录状态失效,请重新登录')
router.push('/login')
return true
}
function safeFilename(value: string) {
return (value || '资源').replace(/[\\/:*?"<>|\r\n]+/g, '_').trim() || '资源'
}
onMounted(async () => {
const quickPrompt = localStorage.getItem('quick_create_prompt')
if (quickPrompt) {
form.title = inferLessonTitle(quickPrompt)
form.extra_requirements = quickPrompt
form.subject = inferSubject(quickPrompt)
form.grade = inferGrade(quickPrompt)
quickPromptNotice.value = '已带入首页创作指令,可继续调整后生成教案。'
localStorage.removeItem('quick_create_prompt')
}
await loadSavedPlans()
await openSavedFromRoute()
})
async function openSavedFromRoute() {
const openId = Number(route.query.open)
if (!Number.isFinite(openId) || openId <= 0) return
try {
const data: any = await getLessonPlan(openId)
const item = data.data || data
previewSaved(item)
} catch {
ElMessage.warning('未能打开改编后的教案')
}
}
function inferLessonTitle(text: string) {
const match = text.match(/[《「](.*?)[》」]/)
if (match?.[1]) return match[1].slice(0, 24)
return text.replace(/生成一份|生成|大单元教学设计|教案|,.*$/g, '').slice(0, 24) || '大单元教学设计'
}
function inferSubject(text: string) {
return subjects.find(item => text.includes(item)) || ''
}
function inferGrade(text: string) {
return grades.find(item => text.includes(item)) || ''
}
</script>
<style scoped>
.gen-hero { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
.gen-hero-icon { width: 52px; height: 52px; border-radius: 14px; display: flex; align-items: center; justify-content: center; }
.gen-hero h2 { font-size: 24px; font-weight: 700; color: var(--text-primary); }
.gen-hero p { font-size: 14px; color: var(--text-muted); }
.gen-input-section { background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 24px; margin-bottom: 24px; }
.quick-notice { min-height: 38px; display: flex; align-items: center; gap: 8px; border: 1px solid #dce8de; border-radius: 12px; background: #f0fbef; color: #00543d; padding: 8px 12px; margin-bottom: 14px; font-size: 13px; font-weight: 800; }
.settings-grid { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 16px; margin-bottom: 16px; }
.setting-item { display: flex; flex-direction: column; gap: 6px; }
.setting-item label { font-size: 13px; font-weight: 500; color: var(--text-secondary); }
.setting-full { margin-bottom: 16px; }
.setting-full label { display: block; font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 6px; }
.setting-full :deep(.el-textarea__inner) { background: #F8FAFC; border-radius: var(--radius-sm); }
.material-row { display: flex; flex-wrap: wrap; gap: 8px; padding: 14px 0; border-top: 1px solid var(--border-color); }
.gen-action { display: flex; justify-content: flex-end; padding-top: 16px; border-top: 1px solid var(--border-color); }
.gen-btn { display: flex; align-items: center; gap: 8px; border: none; color: #fff; padding: 10px 28px; border-radius: var(--radius-sm); font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.3s; }
.gen-btn:hover:not(:disabled) { opacity: 0.9; transform: translateY(-1px); }
.gen-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.loading-section { text-align: center; padding: 48px 0; }
.loading-bar { width: 300px; height: 4px; background: #E2E8F0; border-radius: 2px; margin: 0 auto 20px; overflow: hidden; }
.loading-bar-fill { height: 100%; width: 40%; background: var(--gradient-pink); border-radius: 2px; animation: loadingSlide 1.5s ease-in-out infinite; }
@keyframes loadingSlide { 0% { transform: translateX(-100%); } 100% { transform: translateX(350%); } }
.loading-section p { font-size: 14px; color: var(--text-secondary); }
.guide-section { animation: fadeIn 0.4s ease; }
.capability-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 14px; }
.capability-item { min-height: 78px; border: 1px solid #ead3e3; border-radius: 12px; background: #fff; padding: 14px 16px; }
.capability-item strong { display: block; color: #a01867; font-size: 15px; margin-bottom: 6px; }
.capability-item span { color: #6a7f77; font-size: 12px; line-height: 1.45; }
.guide-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.guide-card { display: flex; flex-direction: column; gap: 10px; background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 18px; cursor: pointer; transition: all 0.3s; }
.guide-card:hover { box-shadow: var(--shadow-md); border-color: #EC4899; transform: translateY(-2px); }
.guide-icon { width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; }
.guide-info h4 { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.guide-info p { font-size: 12px; color: var(--text-muted); line-height: 1.5; }
.guide-arrow { font-size: 12px; color: #EC4899; font-weight: 500; margin-top: auto; }
.result-section { animation: fadeInUp 0.5s ease; }
.result-header { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 20px; }
.result-header h3 { font-size: 20px; font-weight: 600; color: var(--text-primary); }
.result-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
.save-btn { display: flex; align-items: center; gap: 6px; border: none; color: #fff; padding: 8px 20px; border-radius: var(--radius-sm); font-size: 14px; font-weight: 500; cursor: pointer; transition: opacity 0.2s; }
.save-btn:hover { opacity: 0.9; }
.tool-btn { height: 34px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; border: 1px solid #ead3e3; border-radius: 8px; background: #fff; color: #a01867; padding: 0 12px; font-size: 13px; font-weight: 800; cursor: pointer; }
.tool-btn:hover { background: #fff7fc; border-color: #ec4899; }
.tool-btn.publish { border-color: #ec4899; color: #a01867; }
.tool-btn:disabled { opacity: .58; cursor: not-allowed; }
.info-tags { display: flex; flex-direction: column; gap: 12px; background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 20px; margin-bottom: 24px; }
.info-group { display: flex; align-items: flex-start; gap: 12px; }
.info-label { font-size: 13px; font-weight: 500; color: var(--text-muted); min-width: 60px; padding-top: 2px; }
.tag-list { display: flex; flex-wrap: wrap; gap: 6px; }
.phases-timeline { display: flex; flex-direction: column; gap: 0; }
.phase-card { display: flex; gap: 20px; }
.phase-marker { display: flex; flex-direction: column; align-items: center; flex-shrink: 0; }
.phase-dot { width: 32px; height: 32px; border-radius: 10px; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 14px; font-weight: 700; }
.phase-line { width: 2px; flex: 1; background: var(--border-color); margin: 4px 0; }
.phase-content { flex: 1; background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 20px; margin-bottom: 16px; }
.phase-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.phase-header h4 { font-size: 16px; font-weight: 600; color: var(--text-primary); }
.phase-duration { font-size: 13px; color: var(--text-muted); background: #F8FAFC; padding: 4px 10px; border-radius: 6px; }
.phase-block { margin-top: 10px; }
.block-label { font-size: 12px; font-weight: 600; display: inline-block; padding: 2px 8px; border-radius: 4px; margin-bottom: 6px; background: #EEF2FF; color: #4F46E5; }
.block-label.teacher { background: #EEF2FF; color: #4F46E5; }
.block-label.student { background: #ECFDF5; color: #10B981; }
.phase-block ul { padding-left: 20px; }
.phase-block li { font-size: 14px; line-height: 1.8; color: var(--text-secondary); }
.homework-section { background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 20px; margin-top: 16px; }
.homework-section h4 { font-size: 16px; font-weight: 600; color: var(--text-primary); margin-bottom: 12px; }
.homework-section ol { padding-left: 20px; }
.homework-section li { font-size: 14px; line-height: 1.8; color: var(--text-secondary); }
.btn-loading { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.saved-section { margin-top: 28px; background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 22px; }
.saved-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 18px; }
.saved-header h3 { font-size: 18px; font-weight: 700; color: var(--text-primary); margin-bottom: 4px; }
.saved-header p { font-size: 13px; color: var(--text-muted); }
.refresh-btn { height: 34px; display: inline-flex; align-items: center; gap: 6px; border: 1px solid #ead3e3; border-radius: 17px; background: #fff; color: #a01867; padding: 0 14px; font-weight: 700; cursor: pointer; }
.saved-loading,
.saved-empty { min-height: 120px; display: flex; align-items: center; justify-content: center; gap: 8px; color: var(--text-muted); border: 1px dashed #ead3e3; border-radius: var(--radius-md); background: #fff7fc; }
.saved-empty .el-icon { font-size: 24px; color: #d64e98; }
.saved-grid { display: grid; grid-template-columns: repeat(3, minmax(220px, 1fr)); gap: 16px; }
.saved-card { overflow: hidden; border: 1px solid #ead3e3; border-radius: 12px; background: #fff; }
.saved-cover { height: 132px; display: flex; flex-direction: column; justify-content: center; gap: 8px; margin: 5px; padding: 18px; border-radius: 9px; color: #fff; background: linear-gradient(135deg, #be185d, #ec4899); }
.saved-cover .el-icon { font-size: 22px; opacity: .85; }
.saved-cover strong { font-size: 18px; line-height: 1.25; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.saved-cover span { width: fit-content; padding: 3px 8px; border-radius: 12px; background: rgba(255,255,255,.2); font-size: 12px; font-weight: 800; }
.saved-body { padding: 12px 16px 16px; }
.saved-body h4 { font-size: 15px; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 6px; }
.saved-body p { min-height: 36px; color: var(--text-muted); font-size: 12px; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.saved-actions { display: flex; gap: 8px; margin-top: 12px; }
.saved-actions button { height: 32px; display: inline-flex; align-items: center; justify-content: center; gap: 5px; border: 1px solid #ead3e3; border-radius: 8px; background: #fff7fc; color: #a01867; padding: 0 12px; cursor: pointer; font-weight: 700; }
.saved-actions .danger:hover { border-color: #fca5a5; background: #fef2f2; color: #ef4444; }
@media (max-width: 1080px) {
.settings-grid,
.saved-grid { grid-template-columns: repeat(2, minmax(220px, 1fr)); }
}
@media (max-width: 760px) {
.settings-grid,
.capability-row,
.guide-cards,
.saved-grid { grid-template-columns: 1fr; }
.result-header,
.saved-header,
.result-actions { flex-direction: column; align-items: stretch; }
}
</style>
+631
View File
@@ -0,0 +1,631 @@
<template>
<div class="login-page">
<div class="login-left">
<div class="deco deco-1"></div>
<div class="deco deco-2"></div>
<div class="deco deco-3"></div>
<div class="deco deco-4"></div>
<div class="deco deco-5"></div>
<div class="login-brand">
<div class="brand-icon">
<el-icon :size="44" color="#fff"><MagicStick /></el-icon>
</div>
<h1>智教助手</h1>
<p>面向教师的AI创作平台<br/>一句话生成互动课件教学动画课堂游戏和智能测练</p>
</div>
<div class="login-features">
<div class="feature-item" v-for="f in features" :key="f.text">
<div class="feature-icon-wrap" :style="{ background: f.bg }">
<el-icon :size="16" color="#fff"><component :is="f.icon" /></el-icon>
</div>
<div class="feature-text">
<span class="feature-title">{{ f.text }}</span>
<span class="feature-desc">{{ f.desc }}</span>
</div>
</div>
</div>
<div class="login-bottom">
<div class="login-stats">
<div v-for="item in stats" :key="item.label">
<strong>{{ item.value }}</strong>
<span>{{ item.label }}</span>
</div>
</div>
<div class="trust-badges">
<span>支持 <strong>全学科</strong> 备课授课练习与评价</span>
</div>
</div>
</div>
<div class="login-right">
<div class="login-form-wrapper">
<div class="form-header">
<h2>{{ activeTab === 'login' ? '欢迎回来' : activeTab === 'forgot' ? '找回密码' : '创建账号' }}</h2>
<p class="form-subtitle">{{ activeTab === 'login' ? '登录进入AI创作工作台' : activeTab === 'forgot' ? '通过手机验证码重置密码' : '注册账号,开始生成教学内容' }}</p>
</div>
<div class="tab-switch">
<button :class="['tab-btn', { active: activeTab === 'login' }]" @click="activeTab = 'login'">登录</button>
<button :class="['tab-btn', { active: activeTab === 'register' }]" @click="activeTab = 'register'">注册</button>
<div class="tab-indicator" :class="{ right: activeTab === 'register' }"></div>
</div>
<transition name="form-fade" mode="out-in">
<el-form v-if="activeTab === 'login'" ref="loginFormRef" :model="loginForm" :rules="rules" size="large" class="login-form" key="login">
<el-form-item prop="phone">
<el-input v-model="loginForm.phone" placeholder="请输入手机号" prefix-icon="Iphone" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="loginForm.password" type="password" placeholder="请输入密码" prefix-icon="Lock" show-password />
</el-form-item>
<el-form-item>
<button type="button" class="gradient-btn" :disabled="loading" @click="handleLogin">
<span v-if="loading" class="btn-loading"></span>
{{ loading ? '登录中...' : '登 录' }}
</button>
</el-form-item>
<div class="forgot-link" @click="activeTab = 'forgot'">忘记密码</div>
</el-form>
<el-form v-else-if="activeTab === 'register'" ref="registerFormRef" :model="registerForm" :rules="rules" size="large" class="login-form" key="register">
<el-form-item prop="phone">
<el-input v-model="registerForm.phone" placeholder="请输入手机号" prefix-icon="Iphone" />
</el-form-item>
<el-form-item prop="name">
<el-input v-model="registerForm.name" placeholder="请输入姓名" prefix-icon="User" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="registerForm.password" type="password" placeholder="请输入密码(6位以上)" prefix-icon="Lock" show-password />
</el-form-item>
<el-form-item>
<el-select v-model="registerForm.subject" placeholder="选择教授学科" style="width: 100%">
<el-option v-for="s in subjects" :key="s" :label="s" :value="s" />
</el-select>
</el-form-item>
<el-form-item>
<button type="button" class="gradient-btn" :disabled="loading" @click="handleRegister">
<span v-if="loading" class="btn-loading"></span>
{{ loading ? '注册中...' : '注 册' }}
</button>
</el-form-item>
</el-form>
<el-form v-else ref="forgotFormRef" :model="forgotForm" :rules="forgotRules" size="large" class="login-form" key="forgot">
<el-form-item prop="phone">
<el-input v-model="forgotForm.phone" placeholder="请输入注册手机号" prefix-icon="Iphone" />
</el-form-item>
<el-form-item prop="code">
<div class="code-row">
<el-input v-model="forgotForm.code" placeholder="6位验证码" prefix-icon="Key" maxlength="6" />
<button type="button" class="code-btn" :disabled="codeCooldown > 0" @click="handleSendCode">
{{ codeCooldown > 0 ? codeCooldown + 's' : '获取验证码' }}
</button>
</div>
</el-form-item>
<el-form-item prop="new_password">
<el-input v-model="forgotForm.new_password" type="password" placeholder="新密码(需含字母和数字)" prefix-icon="Lock" show-password />
</el-form-item>
<el-form-item>
<button type="button" class="gradient-btn" :disabled="loading" @click="handleReset">
<span v-if="loading" class="btn-loading"></span>
{{ loading ? '重置中...' : '重置密码' }}
</button>
</el-form-item>
<div class="forgot-link" @click="activeTab = 'login'">返回登录</div>
</el-form>
</transition>
<div class="public-entry">
<button type="button" @click="router.push('/')">先浏览创作广场</button>
<button type="button" @click="router.push('/resources')">查看资源案例</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, reactive, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance } from 'element-plus'
import { login, register, sendResetCode, resetPassword } from '@/api'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const activeTab = ref('login')
const loading = ref(false)
const loginFormRef = ref<FormInstance>()
const registerFormRef = ref<FormInstance>()
const forgotFormRef = ref<FormInstance>()
const codeCooldown = ref(0)
const forgotForm = reactive({ phone: '', code: '', new_password: '' })
const forgotRules = {
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }, { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }],
code: [{ required: true, message: '请输入验证码', trigger: 'blur' }],
new_password: [{ required: true, message: '请输入新密码', trigger: 'blur' }, { min: 6, message: '密码至少6位', trigger: 'blur' }],
}
async function handleSendCode() {
if (!/^1[3-9]\d{9}$/.test(forgotForm.phone)) {
ElMessage.warning('请先输入正确的手机号')
return
}
try {
const res: any = await sendResetCode(forgotForm.phone)
codeCooldown.value = 60
const timer = setInterval(() => {
codeCooldown.value--
if (codeCooldown.value <= 0) clearInterval(timer)
}, 1000)
ElMessage.success('验证码已发送')
} catch (error: any) {
ElMessage.error(error?.response?.data?.detail || '发送失败,请稍后重试')
}
}
async function handleReset() {
const valid = await forgotFormRef.value?.validate().catch(() => false)
if (!valid) return
if (!/[a-zA-Z]/.test(forgotForm.new_password) || !/\d/.test(forgotForm.new_password)) {
ElMessage.warning('密码必须同时含有字母和数字')
return
}
loading.value = true
try {
await resetPassword({ phone: forgotForm.phone, code: forgotForm.code, new_password: forgotForm.new_password })
ElMessage.success('密码重置成功,请使用新密码登录')
activeTab.value = 'login'
loginForm.phone = forgotForm.phone
forgotForm.code = ''
forgotForm.new_password = ''
} catch (error: any) {
ElMessage.error(error?.response?.data?.detail || '重置失败,请检查验证码')
} finally {
loading.value = false
}
}
const subjects = ['语文', '数学', '英语', '物理', '化学', '生物', '历史', '地理', '政治', '音乐', '美术', '体育', '信息技术']
const features = [
{ text: 'AI课件生成', desc: '一句话生成可编辑互动课件', icon: 'Document', bg: 'linear-gradient(135deg, #0F766E, #14B8A6)' },
{ text: '教学动画与游戏', desc: '抽象知识可视化,练习游戏化', icon: 'Film', bg: 'linear-gradient(135deg, #2563EB, #06B6D4)' },
{ text: 'AI命题与教案', desc: '从教学设计到试卷测评一站完成', icon: 'Finished', bg: 'linear-gradient(135deg, #F59E0B, #F97316)' },
{ text: '案例资源', desc: '沉淀可复用的课堂案例和素材', icon: 'FolderOpened', bg: 'linear-gradient(135deg, #334155, #64748B)' },
]
const stats = [
{ value: '8+', label: 'AI工具' },
{ value: '12', label: '学科覆盖' },
{ value: '3步', label: '生成课件' },
]
const loginForm = reactive({ phone: '', password: '' })
const registerForm = reactive({ phone: '', name: '', password: '', subject: '' })
const rules = {
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }, { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }, { min: 6, message: '密码至少6位', trigger: 'blur' }],
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
}
function getRedirectPath() {
const redirect = route.query.redirect
return typeof redirect === 'string' && redirect.startsWith('/') ? redirect : '/'
}
async function handleLogin() {
const valid = await loginFormRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
const res: any = await login(loginForm)
userStore.setTokens(res.access_token, res.refresh_token)
await userStore.fetchProfile().catch(() => undefined)
ElMessage.success('登录成功')
await router.replace(getRedirectPath())
} catch (error: any) {
ElMessage.error(error?.response?.data?.detail || '登录失败,请检查账号密码或稍后重试')
} finally {
loading.value = false
}
}
async function handleRegister() {
const valid = await registerFormRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
const res: any = await register(registerForm)
userStore.setTokens(res.access_token, res.refresh_token)
await userStore.fetchProfile().catch(() => undefined)
ElMessage.success('注册成功')
await router.replace(getRedirectPath())
} catch (error: any) {
ElMessage.error(error?.response?.data?.detail || '注册失败,请稍后重试')
} finally {
loading.value = false
}
}
function syncTabFromRoute() {
activeTab.value = route.query.tab === 'register' ? 'register' : 'login'
}
onMounted(syncTabFromRoute)
watch(() => route.query.tab, syncTabFromRoute)
</script>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
background: #fff;
}
.login-left {
flex: 1;
background:
linear-gradient(135deg, rgba(15, 118, 110, 0.96), rgba(20, 33, 61, 0.96)),
radial-gradient(circle at 82% 18%, rgba(45, 212, 191, 0.34), transparent 32%);
display: flex;
flex-direction: column;
justify-content: center;
padding: 60px;
position: relative;
overflow: hidden;
}
/* Floating decorative shapes */
.deco {
position: absolute;
border-radius: 50%;
opacity: 0.08;
background: #fff;
}
.deco-1 { width: 300px; height: 300px; top: -80px; right: -60px; animation: float 8s ease-in-out infinite; }
.deco-2 { width: 200px; height: 200px; bottom: 60px; left: -40px; animation: floatSlow 10s ease-in-out infinite; }
.deco-3 { width: 100px; height: 100px; top: 40%; right: 15%; animation: float 6s ease-in-out infinite 1s; opacity: 0.06; }
.deco-4 { width: 150px; height: 150px; bottom: 10%; right: 30%; animation: floatSlow 12s ease-in-out infinite 2s; opacity: 0.05; border-radius: 30%; }
.deco-5 { width: 60px; height: 60px; top: 20%; left: 20%; animation: float 7s ease-in-out infinite 0.5s; opacity: 0.1; border-radius: 16px; }
.login-brand {
position: relative;
z-index: 1;
margin-bottom: 40px;
}
.brand-icon {
width: 72px;
height: 72px;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
}
.login-brand h1 {
font-size: 34px;
font-weight: 700;
color: #fff;
margin-bottom: 14px;
letter-spacing: 0.5px;
}
.login-brand p {
font-size: 15px;
color: rgba(255, 255, 255, 0.75);
line-height: 1.8;
max-width: 380px;
}
.login-features {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 14px;
margin-bottom: 40px;
}
.feature-item {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(8px);
border-radius: var(--radius-md);
border: 1px solid rgba(255, 255, 255, 0.12);
transition: all 0.3s;
}
.feature-item:hover {
background: rgba(255, 255, 255, 0.16);
transform: translateX(4px);
}
.feature-icon-wrap {
width: 34px;
height: 34px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.feature-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.feature-title {
color: #fff;
font-size: 14px;
font-weight: 600;
}
.feature-desc {
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
}
.login-bottom {
position: relative;
z-index: 1;
}
.login-stats {
display: grid;
grid-template-columns: repeat(3, minmax(82px, 1fr));
gap: 10px;
max-width: 390px;
margin-bottom: 18px;
}
.login-stats div {
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 12px;
background: rgba(255, 255, 255, 0.1);
padding: 12px;
}
.login-stats strong {
display: block;
color: #fff;
font-size: 22px;
line-height: 1;
}
.login-stats span {
display: block;
margin-top: 8px;
color: rgba(255, 255, 255, 0.62);
font-size: 12px;
font-weight: 700;
}
.trust-badges {
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
}
.trust-badges strong {
color: rgba(255, 255, 255, 0.9);
}
.login-right {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
background: linear-gradient(180deg, #fff 0%, #F8FAFC 100%);
}
.login-form-wrapper {
width: 100%;
max-width: 400px;
animation: fadeInUp 0.6s ease;
}
.form-header {
margin-bottom: 28px;
}
.form-header h2 {
font-size: 26px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 6px;
}
.form-subtitle {
color: var(--text-muted);
font-size: 14px;
}
.tab-switch {
display: flex;
position: relative;
background: #F1F5F9;
border-radius: var(--radius-sm);
padding: 4px;
margin-bottom: 28px;
}
.tab-btn {
flex: 1;
padding: 10px 0;
background: none;
border: none;
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
position: relative;
z-index: 1;
transition: color 0.3s;
}
.tab-btn.active {
color: var(--primary);
}
.tab-indicator {
position: absolute;
top: 4px;
left: 4px;
width: calc(50% - 4px);
height: calc(100% - 8px);
background: #fff;
border-radius: 6px;
box-shadow: var(--shadow-sm);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.tab-indicator.right {
transform: translateX(100%);
}
.login-form :deep(.el-input__wrapper) {
padding: 4px 12px;
}
.forgot-link {
text-align: right;
font-size: 13px;
color: var(--brand-primary, #4f46e5);
cursor: pointer;
margin-top: -4px;
margin-bottom: 8px;
transition: opacity .2s;
}
.forgot-link:hover { opacity: .75; }
.code-row { display: flex; gap: 10px; width: 100%; }
.code-row :deep(.el-input) { flex: 1; }
.code-btn {
flex-shrink: 0;
white-space: nowrap;
padding: 0 16px;
border: 1px solid #c7d2fe;
border-radius: 8px;
background: #f5f3ff;
color: #4f46e5;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all .2s;
}
.code-btn:hover:not(:disabled) { background: #ede9fe; border-color: #a5b4fc; }
.code-btn:disabled { opacity: .5; cursor: not-allowed; }
.public-entry {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-top: 10px;
}
.public-entry button {
height: 38px;
border: 1px solid #dce8de;
border-radius: 10px;
background: #fff;
color: #31584d;
font-weight: 800;
cursor: pointer;
}
.public-entry button:hover {
border-color: #a8cfab;
background: #f6fbf6;
color: #00543d;
}
.gradient-btn {
width: 100%;
height: 46px;
background: var(--gradient);
border: none;
border-radius: var(--radius-sm);
color: #fff;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.gradient-btn:hover:not(:disabled) {
opacity: 0.9;
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(79, 70, 229, 0.4);
}
.gradient-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-loading {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
.form-fade-enter-active,
.form-fade-leave-active {
transition: all 0.25s ease;
}
.form-fade-enter-from {
opacity: 0;
transform: translateX(10px);
}
.form-fade-leave-to {
opacity: 0;
transform: translateX(-10px);
}
@media (max-width: 920px) {
.login-page {
flex-direction: column;
}
.login-left,
.login-right {
padding: 34px 22px;
}
.login-brand p {
max-width: none;
}
}
@media (max-width: 520px) {
.login-stats,
.public-entry {
grid-template-columns: 1fr;
}
}
</style>
+392
View File
@@ -0,0 +1,392 @@
<template>
<div class="materials-page">
<section class="materials-hero">
<div>
<span class="eyebrow">我的素材库</span>
<h1>沉淀教材课件试题和课堂资料</h1>
<p>上传解析后的材料会自动保存可按学科类型和关键词检索并一键带入课件组题教案命题或数据回收</p>
</div>
<div class="hero-actions">
<button class="upload-btn secondary" type="button" :disabled="uploading" @click="pickFile('image')">
<el-icon><Camera /></el-icon>
{{ uploadingMode === 'image' ? '识别中' : '拍照/图片' }}
</button>
<button class="upload-btn" type="button" :disabled="uploading" @click="pickFile('file')">
<el-icon><Upload /></el-icon>
{{ uploadingMode === 'file' ? '解析中' : '上传解析' }}
</button>
</div>
<input ref="fileInputRef" type="file" class="hidden-input" :accept="uploadAccept" @change="handleFileChange" />
</section>
<section class="filter-panel">
<div class="segmented">
<button
v-for="type in materialTypes"
:key="type.value"
type="button"
:class="{ active: filters.material_type === type.value }"
@click="setType(type.value)"
>
{{ type.label }}
</button>
</div>
<div class="filter-line">
<el-select v-model="filters.subject" placeholder="全部学科" clearable @change="loadData" style="width: 150px">
<el-option v-for="s in subjects" :key="s" :label="s" :value="s" />
</el-select>
<div class="search-box">
<el-icon><Search /></el-icon>
<input v-model="filters.keyword" placeholder="搜索素材标题、文件名或正文摘要" @keyup.enter="loadData" />
<button v-if="filters.keyword" type="button" @click="clearKeyword">清除</button>
</div>
</div>
</section>
<section class="material-grid" v-loading="loading">
<article v-for="item in materials" :key="item.id" class="material-card">
<div class="material-cover" :class="coverClass(item)">
<span>{{ typeLabel(item.material_type) }}</span>
<strong>{{ item.title }}</strong>
<em>{{ item.subject || '通用' }} · {{ item.grade || formatFileSize(item.size) }}</em>
</div>
<div class="material-body">
<div class="material-title-row">
<h3>{{ item.title }}</h3>
<el-dropdown trigger="click" @command="(cmd: string) => reuseMaterial(item, cmd)">
<button class="more-btn" type="button"><el-icon><MoreFilled /></el-icon></button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="courseware">生成课件</el-dropdown-item>
<el-dropdown-item command="animation">生成动画</el-dropdown-item>
<el-dropdown-item command="exercise">生成练习</el-dropdown-item>
<el-dropdown-item command="lesson">生成教案</el-dropdown-item>
<el-dropdown-item command="exam">智能命题</el-dropdown-item>
<el-dropdown-item command="classroom">数据回收</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<p>{{ item.summary || '暂无摘要' }}</p>
<div class="material-meta">
<span>{{ item.filename }}</span>
<span>{{ formatCount(item.char_count) }}</span>
</div>
<div class="tag-line" v-if="item.tags?.length">
<el-tag v-for="tag in item.tags" :key="tag" size="small" effect="plain">{{ tag }}</el-tag>
</div>
<div class="card-actions">
<button type="button" @click="openEdit(item)">编辑</button>
<button type="button" class="primary" @click="reuseMaterial(item, 'courseware')">带入课件</button>
<button type="button" @click="reuseMaterial(item, 'animation')">生成动画</button>
<el-popconfirm title="确定删除这个素材?" @confirm="removeMaterial(item.id)">
<template #reference>
<button type="button" class="danger">删除</button>
</template>
</el-popconfirm>
</div>
</div>
</article>
</section>
<div v-if="!loading && materials.length === 0" class="empty-state">
<el-icon><FolderOpened /></el-icon>
<strong>还没有素材</strong>
<span>上传教材课件试题或图片解析后会自动进入素材库</span>
<button type="button" @click="pickFile('file')">上传第一个素材</button>
</div>
<el-dialog v-model="showEdit" title="编辑素材信息" width="640px" :close-on-click-modal="false">
<el-form :model="editForm" label-position="top" class="edit-form">
<el-form-item label="素材标题">
<el-input v-model="editForm.title" />
</el-form-item>
<div class="edit-grid">
<el-form-item label="学科">
<el-select v-model="editForm.subject" filterable allow-create clearable style="width: 100%">
<el-option v-for="s in subjects" :key="s" :label="s" :value="s" />
</el-select>
</el-form-item>
<el-form-item label="年级">
<el-input v-model="editForm.grade" placeholder="例如:六年级下册" />
</el-form-item>
</div>
<el-form-item label="标签">
<el-select v-model="editForm.tags" multiple filterable allow-create placeholder="输入标签后回车" style="width: 100%" />
</el-form-item>
<el-form-item label="摘要">
<el-input v-model="editForm.summary" type="textarea" :rows="8" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEdit = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveEdit">保存修改</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { parseMaterial } from '@/api/ai'
import { deleteMaterial, getMaterials, updateMaterial } from '@/api/material'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const fileInputRef = ref<HTMLInputElement>()
const uploadAccept = ref('.txt,.md,.csv,.json,.docx,.pptx,.pdf,.xlsx,image/*')
const uploadMode = ref<'file' | 'image'>('file')
const uploadingMode = ref<'file' | 'image' | ''>('')
const loading = ref(false)
const uploading = ref(false)
const saving = ref(false)
const showEdit = ref(false)
const materials = ref<any[]>([])
const subjects = ['语文', '数学', '英语', '物理', '化学', '生物', '历史', '地理', '信息科技', '课堂工具']
const materialTypes = [
{ value: '', label: '全部' },
{ value: 'txt', label: '文本' },
{ value: 'docx', label: 'Word' },
{ value: 'pptx', label: 'PPT' },
{ value: 'image', label: '图片' },
{ value: 'csv', label: '表格' },
{ value: 'json', label: '结构化' },
]
const filters = reactive({ material_type: '', subject: '', keyword: '' })
const editForm = reactive({
id: 0,
title: '',
subject: '',
grade: '',
tags: [] as string[],
summary: '',
})
onMounted(() => {
filters.keyword = typeof route.query.keyword === 'string' ? route.query.keyword : ''
loadData()
})
watch(() => route.query.keyword, (keyword) => {
filters.keyword = typeof keyword === 'string' ? keyword : ''
loadData()
})
function setType(type: string) {
filters.material_type = type
loadData()
}
function clearKeyword() {
filters.keyword = ''
loadData()
}
async function loadData() {
if (!ensureLoggedIn(false)) return
loading.value = true
try {
const data: any = await getMaterials({
material_type: filters.material_type || undefined,
subject: filters.subject || undefined,
keyword: filters.keyword || undefined,
limit: 100,
})
materials.value = Array.isArray(data?.data) ? data.data : Array.isArray(data) ? data : []
} finally {
loading.value = false
}
}
function pickFile(type: 'file' | 'image') {
if (!ensureLoggedIn(true)) return
uploadMode.value = type
uploadAccept.value = type === 'image' ? 'image/*' : '.txt,.md,.csv,.json,.docx,.pptx,.pdf,.xlsx,image/*'
if (fileInputRef.value) {
if (type === 'image') fileInputRef.value.setAttribute('capture', 'environment')
else fileInputRef.value.removeAttribute('capture')
}
fileInputRef.value?.click()
}
async function handleFileChange(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
uploading.value = true
uploadingMode.value = uploadMode.value
try {
const parsed: any = await parseMaterial(file)
userStore.refreshCreditsFromResponse(parsed)
const data = parsed.data || parsed
ElMessage.success(data.material_type === 'image' ? '图片素材已保存,可带入生成' : '素材已解析并保存')
await loadData()
} finally {
uploading.value = false
uploadingMode.value = ''
;(event.target as HTMLInputElement).value = ''
}
}
function openEdit(item: any) {
editForm.id = item.id
editForm.title = item.title || item.filename || ''
editForm.subject = item.subject || ''
editForm.grade = item.grade || ''
editForm.tags = Array.isArray(item.tags) ? [...item.tags] : []
editForm.summary = item.summary || ''
showEdit.value = true
}
async function saveEdit() {
if (!editForm.id) return
saving.value = true
try {
await updateMaterial(editForm.id, {
title: editForm.title,
subject: editForm.subject,
grade: editForm.grade,
tags: editForm.tags,
summary: editForm.summary,
})
ElMessage.success('素材已更新')
showEdit.value = false
await loadData()
} finally {
saving.value = false
}
}
async function removeMaterial(id: number) {
await deleteMaterial(id)
ElMessage.success('素材已删除')
await loadData()
}
function reuseMaterial(item: any, mode: string) {
const prompt = buildPrompt(item, mode)
localStorage.setItem('quick_create_prompt', prompt)
if (mode === 'exam') router.push('/exam')
else if (mode === 'exercise') router.push('/exercise')
else if (mode === 'lesson') router.push('/lesson-plan')
else if (mode === 'animation') router.push('/animation')
else if (mode === 'classroom') router.push('/classroom')
else router.push('/courseware/create')
}
function buildPrompt(item: any, mode: string) {
const imageHint = item.material_type === 'image'
? '\n\n【图片使用要求】\n这是一份拍照/图片素材,请优先围绕图片中的教材页、题目、板书、图表或课堂场景设计内容;如果图片文字未完全识别,请保留“请教师补充图片关键文字”的提示位。'
: ''
const base = `请基于素材《${item.title || item.filename}》进行教学创作。\n\n【素材摘要】\n${item.summary || '暂无摘要,请结合素材标题和类型进行创作。'}${imageHint}`
if (mode === 'exam') return `${base}\n\n请据此生成一套原创试题,覆盖核心知识点,附答案和解析。`
if (mode === 'exercise') return `${base}\n\n请据此生成可课堂互动的练习/教学游戏,题目有梯度并附解析。`
if (mode === 'lesson') return `${base}\n\n请据此生成结构化大单元教案,包含目标、重难点、活动、评价和作业。`
if (mode === 'animation') return `${base}\n\n请据此生成一个课堂教学动画,突出知识形成过程、关键变化和可观察的步骤标注。`
if (mode === 'classroom') return `${base}\n\n请据此设计一个课堂数据回收活动,用于收集学生理解、选择或反馈。`
return `${base}\n\n请据此生成专业级互动课件,包含导入、讲解、互动练习和课堂总结。`
}
function ensureLoggedIn(redirect: boolean) {
if (localStorage.getItem('access_token')) return true
ElMessage.warning('请先登录后使用素材库')
if (redirect) router.push({ path: '/login', query: { redirect: '/materials' } })
return false
}
function typeLabel(type: string) {
const map: Record<string, string> = {
docx: 'Word',
pptx: 'PPT',
csv: '表格',
json: '结构化',
md: 'Markdown',
txt: '文本',
text: '文本',
image: '图片',
}
return map[type] || '素材'
}
function coverClass(item: any) {
if (item.material_type === 'image') return 'cover-mint'
if (item.material_type === 'pptx') return 'cover-blue'
if (item.material_type === 'docx') return 'cover-green'
if (item.material_type === 'csv' || item.material_type === 'json') return 'cover-yellow'
return 'cover-cream'
}
function formatFileSize(size: number) {
const n = Number(size || 0)
if (n < 1024) return `${n}B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`
return `${(n / 1024 / 1024).toFixed(1)}MB`
}
function formatCount(value: number) {
const n = Number(value || 0)
if (n >= 10000) return `${(n / 10000).toFixed(1)}`
return String(n)
}
</script>
<style scoped>
.materials-page { min-height: 100vh; background: #f7faf6; padding: 26px 36px 40px; color: #173f35; }
.materials-hero { max-width: 1280px; margin: 0 auto 18px; display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; border: 1px solid #dce8de; border-radius: 14px; background: #fff; padding: 24px; }
.eyebrow { color: #00724f; font-size: 13px; font-weight: 900; }
.materials-hero h1 { margin: 8px 0 8px; color: #173f35; font-size: 28px; line-height: 1.2; }
.materials-hero p { max-width: 760px; color: #6a7f77; line-height: 1.7; }
.hero-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
.upload-btn { height: 38px; display: inline-flex; align-items: center; gap: 7px; border: none; border-radius: 19px; background: #00543d; color: #fff; padding: 0 18px; font-weight: 900; cursor: pointer; white-space: nowrap; }
.upload-btn.secondary { border: 1px solid #9ed0aa; background: #eaf7e9; color: #00543d; }
.upload-btn:disabled { opacity: .62; cursor: not-allowed; }
.hidden-input { display: none; }
.filter-panel { max-width: 1280px; margin: 0 auto 18px; border: 1px solid #dce8de; border-radius: 14px; background: #fff; padding: 14px; }
.segmented { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
.segmented button { height: 32px; border: 1px solid #dbe8de; border-radius: 16px; background: #fff; color: #51665f; padding: 0 13px; cursor: pointer; }
.segmented button.active, .segmented button:hover { border-color: #9ed0aa; background: #eaf7e9; color: #00543d; font-weight: 900; }
.filter-line { display: flex; align-items: center; gap: 10px; }
.search-box { height: 34px; flex: 1; min-width: 220px; display: flex; align-items: center; gap: 8px; border: 1px solid #dbe8de; border-radius: 17px; padding: 0 10px; background: #f8faf8; }
.search-box input { flex: 1; min-width: 0; border: none; outline: none; background: transparent; color: #173f35; }
.search-box button { border: none; background: transparent; color: #00724f; cursor: pointer; font-weight: 800; }
.material-grid { max-width: 1280px; margin: 0 auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; }
.material-card { overflow: hidden; border: 1px solid #dce8de; border-radius: 14px; background: #fff; }
.material-cover { min-height: 148px; display: flex; flex-direction: column; justify-content: flex-end; padding: 18px; }
.material-cover span { width: fit-content; border-radius: 999px; background: rgba(255,255,255,.86); color: #00543d; padding: 4px 10px; font-size: 12px; font-weight: 900; }
.material-cover strong { margin-top: 12px; color: #173f35; font-size: 19px; line-height: 1.25; }
.material-cover em { margin-top: 7px; color: #51665f; font-style: normal; font-size: 13px; }
.cover-green { background: linear-gradient(135deg, #dff7e5, #9ed0aa); }
.cover-blue { background: linear-gradient(135deg, #dbe8ff, #a8c7ff); }
.cover-mint { background: linear-gradient(135deg, #d7f8ef, #8be1c6); }
.cover-yellow { background: linear-gradient(135deg, #fff1a1, #ffd84d); }
.cover-cream { background: linear-gradient(135deg, #f6edd3, #e6d0a3); }
.material-body { padding: 16px; }
.material-title-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.material-title-row h3 { color: #173f35; font-size: 16px; line-height: 1.35; }
.more-btn { width: 30px; height: 30px; border: 1px solid #dbe8de; border-radius: 8px; background: #fff; color: #31584d; cursor: pointer; }
.material-body p { height: 86px; margin-top: 8px; color: #6a7f77; font-size: 13px; line-height: 1.6; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
.material-meta { margin-top: 12px; display: flex; justify-content: space-between; gap: 10px; color: #87958f; font-size: 12px; }
.material-meta span:first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tag-line { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 10px; }
.card-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-top: 14px; }
.card-actions button { height: 32px; border: 1px solid #dbe8de; border-radius: 8px; background: #fff; color: #31584d; font-weight: 900; cursor: pointer; }
.card-actions button.primary { border-color: #00543d; background: #00543d; color: #fff; }
.card-actions button.danger { color: #b42318; }
.empty-state { max-width: 1280px; min-height: 320px; margin: 0 auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; border: 1px dashed #cfe0d3; border-radius: 14px; background: #fff; color: #75867f; }
.empty-state .el-icon { font-size: 40px; color: #9aaba4; }
.empty-state strong { color: #173f35; }
.empty-state button { border: none; border-radius: 18px; background: #00543d; color: #fff; padding: 8px 18px; font-weight: 900; cursor: pointer; }
.edit-grid { display: grid; grid-template-columns: repeat(2, minmax(180px, 1fr)); gap: 0 14px; }
@media (max-width: 760px) {
.materials-page { padding: 16px; }
.materials-hero { align-items: flex-start; flex-direction: column; }
.hero-actions { width: 100%; justify-content: flex-start; }
.filter-line { align-items: stretch; flex-direction: column; }
.search-box { width: 100%; }
.card-actions { grid-template-columns: repeat(2, 1fr); }
.edit-grid { grid-template-columns: 1fr; }
}
</style>
+366
View File
@@ -0,0 +1,366 @@
<template>
<div class="page-container">
<div class="gen-hero">
<div class="gen-hero-icon" style="background: linear-gradient(135deg, #6366f1, #8b5cf6)">
<el-icon :size="28" color="#fff"><Share /></el-icon>
</div>
<div>
<h2>AI 思维导图</h2>
<p>输入主题一键生成层次清晰的知识思维导图支持折叠导出与保存</p>
</div>
</div>
<div class="mm-layout">
<div class="mm-input-panel">
<div class="panel-header">
<h3>生成导图</h3>
</div>
<el-form label-position="top" @submit.prevent="handleGenerate">
<el-form-item label="主题">
<el-input
v-model="form.topic"
placeholder="例:光合作用、一元一次方程、唐宋八大家、地球公转与四季"
maxlength="120"
clearable
/>
</el-form-item>
<div class="form-row">
<el-form-item label="学科" class="flex1">
<el-select v-model="form.subject" placeholder="选学科" filterable allow-create>
<el-option v-for="s in subjects" :key="s" :label="s" :value="s" />
</el-select>
</el-form-item>
<el-form-item label="学段" class="flex1">
<el-select v-model="form.grade" placeholder="选学段" filterable clearable allow-create>
<el-option v-for="g in grades" :key="g" :label="g" :value="g" />
</el-select>
</el-form-item>
</div>
<div class="quick-topics">
<span class="quick-label">试试</span>
<button
v-for="t in quickTopics"
:key="t"
type="button"
class="quick-chip"
@click="applyQuick(t)"
>{{ t }}</button>
</div>
<el-button
type="primary"
:loading="generating"
:disabled="!form.topic.trim()"
@click="handleGenerate"
class="gen-btn"
>
<el-icon><MagicStick /></el-icon>
生成导图
</el-button>
</el-form>
<div class="history-section" v-if="history.length">
<div class="panel-header">
<h3>我的导图</h3>
<span class="count-badge">{{ history.length }}</span>
</div>
<div class="history-list">
<div v-for="m in history" :key="m.id" class="history-item" @click="loadSaved(m)">
<div class="hist-title">{{ m.title }}</div>
<div class="hist-meta">
<span>{{ m.subject }}</span>
<el-button text size="small" @click.stop="removeSaved(m.id)" class="del-btn">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</div>
<div class="mm-canvas-panel">
<div class="canvas-toolbar" v-if="result">
<h3 :title="result.title">{{ result.title }}</h3>
<div class="toolbar-actions">
<el-button text @click="expandAll(true)">全部展开</el-button>
<el-button text @click="expandAll(false)">全部折叠</el-button>
<el-button type="primary" plain @click="exportHtml">
<el-icon><Download /></el-icon> 导出HTML
</el-button>
<el-button type="success" plain :loading="saving" @click="saveMap">
<el-icon><Star /></el-icon> 保存
</el-button>
</div>
</div>
<el-empty v-if="!result && !generating" description="生成后在此展示知识思维导图" :image-size="80">
<template #image>
<el-icon :size="60" color="#c0c4cc"><Share /></el-icon>
</template>
</el-empty>
<div v-if="generating" class="loading-block">
<el-icon class="loading-spin"><Loading /></el-icon>
<span>AI 正在梳理知识结构</span>
<span style="font-size:13px;color:var(--el-text-color-secondary);opacity:.65;margin-top:8px">模型深度推理中通常需要 1-2 分钟请耐心等待</span>
</div>
<div v-if="result && !generating" class="canvas-scroll">
<div class="mind-stage">
<div class="root-node">{{ result.title }}</div>
<ul class="root-branches">
<MindNode
v-for="(branch, i) in result.nodes"
:key="i"
:node="branch"
:level="1"
:branch-color="branch.color || palette[i % palette.length]"
/>
</ul>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, provide, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
Share, MagicStick, Download, Star, Delete, Loading,
} from '@element-plus/icons-vue'
import MindNode from '@/components/common/MindNode.vue'
import { useUserStore } from '@/stores/user'
import {
aiGenerateMindMap, getMindMaps, getMindMap, createMindMap, deleteMindMap,
type MindMapNode, type MindMapData,
} from '@/api/mindmap'
const userStore = useUserStore()
const route = useRoute()
const subjects = ['语文', '数学', '英语', '物理', '化学', '生物', '历史', '地理', '政治', '科学', '综合']
const grades = ['一年级', '二年级', '三年级', '四年级', '五年级', '六年级', '初一', '初二', '初三', '高一', '高二', '高三']
const palette = ['#00a870', '#2563eb', '#d97706', '#db2777', '#7c3aed', '#0891b2', '#4f46e5', '#ea580c']
const quickTopics = ['光合作用', '一元一次方程', '光的折射', '细胞结构与功能', '唐朝由盛转衰']
const form = reactive({ topic: '', subject: '综合', grade: '' })
const generating = ref(false)
const saving = ref(false)
const result = ref<MindMapData | null>(null)
const history = ref<any[]>([])
// provide a shared expand signal so parent can collapse/expand all nodes
const expandVersion = ref(0)
const expandMode = ref<'open' | 'close'>('open')
provide('mmExpand', { expandVersion, expandMode })
async function handleGenerate() {
if (!form.topic.trim()) return
generating.value = true
result.value = null
try {
const res: any = await aiGenerateMindMap({
topic: form.topic.trim(),
subject: form.subject,
grade: form.grade,
})
if (res?.success && res?.data) {
result.value = res.data
userStore.refreshCreditsFromResponse(res)
} else {
ElMessage.error(res?.detail || '生成失败,请稍后重试')
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '生成失败,请稍后重试')
} finally {
generating.value = false
}
}
function applyQuick(t: string) {
form.topic = t
}
function expandAll(open: boolean) {
expandMode.value = open ? 'open' : 'close'
expandVersion.value++
}
async function loadHistory() {
try {
const res: any = await getMindMaps({ limit: 50 })
history.value = res || []
} catch {
history.value = []
}
}
function loadSaved(m: any) {
result.value = { title: m.title, nodes: m.nodes || [] }
}
async function removeSaved(id: number) {
try {
await deleteMindMap(id)
history.value = history.value.filter((x: any) => x.id !== id)
ElMessage.success('已删除')
} catch {
ElMessage.error('删除失败')
}
}
async function saveMap() {
if (!result.value) return
saving.value = true
try {
await createMindMap({
title: result.value.title,
subject: form.subject,
nodes: result.value.nodes,
})
ElMessage.success('已保存到我的导图')
await loadHistory()
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '保存失败')
} finally {
saving.value = false
}
}
function esc(s: string): string {
return s.replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function buildExportHtml(): string {
if (!result.value) return ''
const d = result.value
const nodeHtml = (n: MindMapNode, lvl: number, color: string): string => {
const titleEsc = esc(n.title)
const has = Array.isArray(n.children) && n.children.length
let style = ''
let cls = 'mm-card'
if (lvl === 1) {
cls += ' branch'
style = ` style="border-color:${color};background:${color}14"`
}
let inner = `<div class="${cls}"${style}><span class="mm-title">${titleEsc}</span></div>`
if (has) {
const kids = n.children!.map((c) => nodeHtml(c, lvl + 1, color)).join('')
inner += `<ul class="mm-children">${kids}</ul>`
}
return `<li>${inner}</li>`
}
const branches = d.nodes.map((b, i) => nodeHtml(b, 1, b.color || palette[i % palette.length])).join('')
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${esc(d.title)} - 思维导图</title>
<style>
*{box-sizing:border-box;margin:0;padding:0;font-family:'PingFang SC','Microsoft YaHei',-apple-system,sans-serif;}
body{background:#f7f8fa;padding:40px 20px;color:#1f2937;}
.wrap{max-width:960px;margin:0 auto;background:#fff;border-radius:16px;padding:36px;box-shadow:0 8px 30px rgba(0,0,0,.08);}
.title-bar{display:flex;align-items:center;gap:12px;margin-bottom:28px;}
.title-bar .dot{width:40px;height:40px;border-radius:11px;background:linear-gradient(135deg,#6366f1,#8b5cf6);display:flex;align-items:center;justify-content:center;color:#fff;font-size:20px;font-weight:700;}
.title-bar h1{font-size:22px;font-weight:700;}
.root-node{display:inline-block;padding:14px 28px;border-radius:12px;background:linear-gradient(135deg,#4f46e5,#6366f1);color:#fff;font-size:20px;font-weight:700;margin:0 0 24px;box-shadow:0 6px 18px rgba(79,70,229,.35);}
ul{list-style:none;}
.mm-children{display:flex;flex-direction:column;gap:10px;padding:10px 0 10px 14px;position:relative;}
.mm-children::before{content:'';position:absolute;left:0;top:0;bottom:0;width:2px;background:#e5e7eb;}
.mm-node,.root-list>li{display:flex;align-items:flex-start;position:relative;padding-left:26px;}
.root-list>li{padding-left:0;}
.root-list>li::before{display:none;}
.mm-children>li::before{content:'';position:absolute;left:0;top:24px;width:22px;height:2px;background:#e5e7eb;}
.mm-card{display:inline-block;padding:10px 18px;border-radius:10px;border:1.5px solid #e5e7eb;background:#fff;font-size:14px;font-weight:600;white-space:nowrap;}
.mm-card.branch{font-size:15px;font-weight:700;border-width:2px;}
</style>
</head>
<body>
<div class="wrap">
<div class="title-bar"><div class="dot"></div><h1>${esc(d.title)}</h1></div>
<div class="root-node">${esc(d.title)}</div>
<ul class="root-list">
${branches}
</ul>
</div>
</body>
</html>`
}
function exportHtml() {
if (!result.value) return
const html = buildExportHtml()
const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const safeName = (result.value.title || '思维导图').replace(/[\\/:*?"<>|]/g, '_').slice(0, 40)
a.download = `${safeName}-思维导图.html`
a.click()
URL.revokeObjectURL(url)
}
onMounted(async () => {
await loadHistory()
const openId = route.query.open
if (openId) {
try {
const res: any = await getMindMap(Number(openId))
if (res?.id) {
result.value = { title: res.title, nodes: res.nodes || [] }
}
} catch {
// ignore load error
}
}
})
</script>
<style scoped>
.gen-hero-icon { width: 52px; height: 52px; border-radius: 14px; display: flex; align-items: center; justify-content: center; }
.gen-hero { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
.gen-hero h2 { font-size: 24px; font-weight: 700; color: var(--text-primary); }
.gen-hero p { font-size: 14px; color: var(--text-muted); }
.mm-layout { display: grid; grid-template-columns: 360px 1fr; gap: 24px; align-items: start; }
.mm-input-panel { background: #fff; border-radius: 16px; padding: 24px; box-shadow: 0 2px 12px rgba(0,0,0,.04); position: sticky; top: 20px; }
.panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.panel-header h3 { font-size: 16px; font-weight: 600; color: var(--text-primary); }
.form-row { display: flex; gap: 12px; }
.flex1 { flex: 1; }
.gen-btn { width: 100%; margin-top: 8px; }
.quick-topics { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 4px 0 16px; }
.quick-label { font-size: 12px; color: var(--text-muted); }
.quick-chip { border: 1px solid #e5e7eb; background: #f9fafb; color: #374151; font-size: 12px; padding: 4px 12px; border-radius: 14px; cursor: pointer; transition: all .15s; }
.quick-chip:hover { border-color: #6366f1; color: #6366f1; background: #f5f3ff; }
.history-section { margin-top: 28px; }
.count-badge { background: #f3f4f6; color: #6366f1; font-size: 12px; font-weight: 600; padding: 2px 10px; border-radius: 10px; }
.history-list { display: flex; flex-direction: column; gap: 8px; max-height: 340px; overflow-y: auto; }
.history-item { border: 1px solid #eee; border-radius: 10px; padding: 12px 14px; cursor: pointer; transition: all .15s; }
.history-item:hover { border-color: #c4b5fd; background: #faf8ff; }
.hist-title { font-size: 14px; font-weight: 600; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hist-meta { display: flex; justify-content: space-between; align-items: center; margin-top: 6px; }
.hist-meta span { font-size: 12px; color: var(--text-muted); }
.del-btn { color: #ef4444; }
.mm-canvas-panel { background: #fff; border-radius: 16px; box-shadow: 0 2px 12px rgba(0,0,0,.04); min-height: 520px; display: flex; flex-direction: column; overflow: hidden; }
.canvas-toolbar { display: flex; justify-content: space-between; align-items: center; padding: 18px 24px; border-bottom: 1px solid #f0f0f0; flex-shrink: 0; gap: 12px; }
.canvas-toolbar h3 { font-size: 17px; font-weight: 700; color: var(--text-primary); max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.toolbar-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
.loading-block { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; color: var(--text-muted); font-size: 14px; }
.loading-spin { font-size: 32px; animation: spin 1s linear infinite; color: #6366f1; }
@keyframes spin { to { transform: rotate(360deg); } }
.canvas-scroll { flex: 1; overflow: auto; padding: 28px 24px; }
.mind-stage { display: flex; align-items: flex-start; gap: 28px; min-width: max-content; }
.root-node { flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; padding: 16px 28px; border-radius: 14px; background: linear-gradient(135deg, #4f46e5, #6366f1); color: #fff; font-size: 20px; font-weight: 700; box-shadow: 0 8px 22px rgba(79,70,229,.32); white-space: nowrap; }
.root-branches { list-style: none; display: flex; flex-direction: column; gap: 16px; padding: 0; margin: 0; }
@media (max-width: 900px) {
.mm-layout { grid-template-columns: 1fr; }
.mm-input-panel { position: static; }
.canvas-toolbar { flex-direction: column; align-items: stretch; }
}
</style>

Some files were not shown because too many files have changed in this diff Show More