智教助手平台:完整初始化
- 前端: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:
@@ -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;"]
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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); });
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
Generated
+3874
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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="<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>"></iframe>
|
||||
</div>
|
||||
</body></html>
|
||||
@@ -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); });
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
@@ -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[]>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}`)
|
||||
@@ -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' } })
|
||||
@@ -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)
|
||||
@@ -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}`)
|
||||
@@ -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>
|
||||
@@ -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`)
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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}`)
|
||||
@@ -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}`)
|
||||
@@ -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}`)
|
||||
@@ -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`)
|
||||
@@ -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>
|
||||
Vendored
+88
@@ -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')
|
||||
}
|
||||
Vendored
+47
@@ -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>
|
||||
@@ -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>
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
@@ -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
@@ -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')}`
|
||||
}
|
||||
@@ -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
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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,'<').replace(/>/g,'>')
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
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
Reference in New Issue
Block a user