first commit

This commit is contained in:
2026-02-27 10:37:11 +08:00
commit 74f19aad0b
86 changed files with 18642 additions and 0 deletions

454
static/css/rich-editor.css Normal file
View File

@@ -0,0 +1,454 @@
/* ========== Rich Editor 样式 ========== */
/* 工具栏 */
.re-toolbar {
display: flex;
align-items: center;
gap: 2px;
padding: 6px 8px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
flex-wrap: wrap;
}
.re-toolbar.re-compact {
padding: 4px 6px;
gap: 1px;
}
.re-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 28px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: #475569;
transition: all 0.15s;
}
.re-btn:hover {
background: #e2e8f0;
color: #1e293b;
}
.re-compact .re-btn {
width: 26px;
height: 24px;
font-size: 12px;
}
.re-bold { font-weight: 700; }
.re-italic { font-style: italic; }
.re-code { font-family: Consolas, monospace; font-size: 11px; }
.re-sep {
width: 1px;
height: 18px;
background: #e2e8f0;
margin: 0 4px;
}
.re-dropdown-wrap {
position: relative;
}
/* 面板通用 */
.re-panel {
position: absolute;
top: 100%;
left: 0;
z-index: 100;
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 10px 40px -4px rgba(0,0,0,0.12);
margin-top: 4px;
}
/* 颜色面板 */
.re-color-panel {
padding: 10px;
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 4px;
width: 240px;
}
.re-swatch {
width: 20px;
height: 20px;
border-radius: 4px;
border: none;
cursor: pointer;
transition: transform 0.15s;
}
.re-swatch:hover {
transform: scale(1.3);
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
/* 字体面板 */
.re-font-panel {
padding: 6px;
width: 180px;
max-height: 320px;
overflow-y: auto;
}
.re-font-item {
display: block;
width: 100%;
text-align: left;
padding: 6px 10px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: #334155;
transition: background 0.15s;
}
.re-font-item:hover {
background: #f1f5f9;
}
/* Emoji 面板 */
.re-emoji-panel {
width: 300px;
padding: 0;
}
.re-emoji-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid #e2e8f0;
overflow-x: auto;
padding: 0 4px;
}
.re-emoji-tab {
padding: 8px 10px;
border: none;
background: transparent;
font-size: 12px;
color: #64748b;
cursor: pointer;
white-space: nowrap;
border-bottom: 2px solid transparent;
transition: all 0.15s;
}
.re-emoji-tab:hover { color: #334155; }
.re-emoji-tab.active {
color: #3b82f6;
border-bottom-color: #3b82f6;
}
.re-emoji-body {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 2px;
padding: 8px;
}
.re-emoji-item {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
transition: all 0.15s;
}
.re-emoji-item:hover {
background: #f1f5f9;
transform: scale(1.2);
}
/* 数学公式面板 */
.re-math-panel {
width: 380px;
padding: 0;
max-height: 420px;
overflow-y: auto;
}
.re-math-hint {
font-size: 11px;
color: #94a3b8;
padding: 8px 10px 4px;
}
.re-math-panel .re-emoji-tabs {
padding: 0 4px;
}
.re-math-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 4px;
padding: 8px;
}
.re-math-item {
padding: 6px 4px;
border: 1px solid #e2e8f0;
background: white;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
text-align: center;
color: #334155;
transition: all 0.15s;
}
.re-math-item:hover {
background: #eff6ff;
border-color: #93c5fd;
color: #1d4ed8;
}
.re-math-wrap-btn {
display: block;
width: calc(100% - 16px);
margin: 0 8px 8px;
padding: 6px;
border: 1px dashed #93c5fd;
background: #eff6ff;
border-radius: 8px;
cursor: pointer;
font-size: 12px;
color: #3b82f6;
text-align: center;
transition: all 0.15s;
}
.re-math-wrap-btn:hover {
background: #dbeafe;
}
.re-math-preview {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 8px;
margin: 0 8px 8px;
}
.re-math-preview-label {
font-size: 11px;
color: #94a3b8;
margin-bottom: 4px;
}
.re-math-preview-content {
min-height: 24px;
font-size: 14px;
color: #334155;
overflow-x: auto;
}
/* 行内代码样式 */
.re-inline-code {
background: #f1f5f9;
border: 1px solid #e2e8f0;
border-radius: 4px;
padding: 1px 5px;
font-family: Consolas, 'Courier New', monospace;
font-size: 0.9em;
color: #e11d48;
}
/* 隐藏类 */
.re-panel.hidden {
display: none !important;
}
/* 实时预览面板 */
.re-live-preview {
border-top: 1px solid #e2e8f0;
background: #f8fafc;
padding: 0;
max-height: 180px;
overflow-y: auto;
}
.re-pv-label {
font-size: 11px;
color: #94a3b8;
padding: 6px 10px 2px;
font-weight: 500;
user-select: none;
}
.re-pv-body {
padding: 4px 10px 8px;
font-size: 14px;
color: #334155;
line-height: 1.7;
word-break: break-word;
white-space: pre-wrap;
}
.re-pv-body img {
max-width: 100%;
max-height: 120px;
border-radius: 6px;
}
.re-pv-body strong { font-weight: 700; }
.re-pv-body em { font-style: italic; }
.re-pv-body .katex { font-size: 1.05em; }
/* ========== 数学公式编辑器弹窗 ========== */
.re-math-editor-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 9990;
display: flex;
align-items: center;
justify-content: center;
animation: reMathFadeIn 0.2s ease;
}
.re-math-editor-overlay.hidden {
display: none !important;
}
@keyframes reMathFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes reMathScaleIn {
from { opacity: 0; transform: scale(0.92); }
to { opacity: 1; transform: scale(1); }
}
.re-math-editor-modal {
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px -12px rgba(0,0,0,0.25);
width: 100%;
max-width: 672px;
max-height: 90vh;
display: flex;
flex-direction: column;
animation: reMathScaleIn 0.25s ease;
}
.re-math-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #e2e8f0;
}
.re-math-editor-close {
width: 32px;
height: 32px;
border: none;
background: transparent;
border-radius: 8px;
font-size: 20px;
color: #94a3b8;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.re-math-editor-close:hover {
background: #f1f5f9;
color: #475569;
}
.re-math-editor-body {
padding: 16px 20px;
overflow-y: auto;
flex: 1;
}
.re-math-editor-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-family: Consolas, 'Courier New', monospace;
font-size: 14px;
color: #334155;
resize: vertical;
transition: border-color 0.15s;
box-sizing: border-box;
}
.re-math-editor-input:focus {
outline: none;
border-color: #93c5fd;
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
}
.re-math-editor-preview {
min-height: 60px;
padding: 14px 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
overflow-x: auto;
font-size: 16px;
color: #334155;
}
.re-math-editor-preview .katex-display {
margin: 0;
}
.re-math-editor-templates {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.re-math-editor-tpl-card {
padding: 10px 8px;
border: 1px solid #e2e8f0;
background: white;
border-radius: 8px;
cursor: pointer;
text-align: center;
transition: all 0.15s;
}
.re-math-editor-tpl-card:hover {
border-color: #93c5fd;
background: #eff6ff;
box-shadow: 0 2px 8px rgba(59,130,246,0.1);
}
.re-math-editor-tpl-tex {
font-size: 13px;
color: #334155;
overflow: hidden;
min-height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.re-math-editor-tpl-tex .katex {
font-size: 0.85em;
}
.re-math-editor-tpl-label {
font-size: 11px;
color: #94a3b8;
margin-top: 4px;
}
.re-math-editor-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px;
border-top: 1px solid #e2e8f0;
}
.re-math-editor-btn-cancel {
padding: 8px 20px;
border: 1px solid #e2e8f0;
background: white;
border-radius: 8px;
font-size: 14px;
color: #64748b;
cursor: pointer;
transition: all 0.15s;
}
.re-math-editor-btn-cancel:hover {
background: #f1f5f9;
}
.re-math-editor-btn-confirm {
padding: 8px 20px;
border: none;
background: #3b82f6;
border-radius: 8px;
font-size: 14px;
color: white;
cursor: pointer;
font-weight: 500;
transition: all 0.15s;
}
.re-math-editor-btn-confirm:hover {
background: #2563eb;
}
.re-fx-btn {
font-family: Georgia, 'Times New Roman', serif;
font-style: italic;
font-weight: 700;
font-size: 12px;
}

184
static/css/style.css Normal file
View File

@@ -0,0 +1,184 @@
/* 自定义样式 */
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
/* 自定义滚动条 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: rgba(148, 163, 184, 0.4);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(148, 163, 184, 0.7);
}
/* 高级玻璃拟态效果 */
.glass-panel {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.6);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.05);
}
.glass-panel-dark {
background: rgba(15, 23, 42, 0.7);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
}
/* 高级阴影与悬浮动效 */
.hover-card-up {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.hover-card-up:hover {
transform: translateY(-4px) scale(1.01);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.hover-glow {
transition: all 0.3s ease;
}
.hover-glow:hover {
box-shadow: 0 0 20px rgba(59, 130, 246, 0.4);
}
/* 文本渐变发光 */
.text-gradient-glow {
background-clip: text;
-webkit-background-clip: text;
color: transparent;
text-shadow: 0 0 20px rgba(255, 255, 255, 0.3);
}
/* 渐变动画边框 */
.animated-border {
position: relative;
border-radius: inherit;
}
.animated-border::before {
content: "";
position: absolute;
inset: -2px;
border-radius: inherit;
background: linear-gradient(45deg, #3b82f6, #8b5cf6, #ec4899, #3b82f6);
background-size: 200% 200%;
animation: gradient-move 3s linear infinite;
z-index: -1;
opacity: 0;
transition: opacity 0.3s ease;
}
.animated-border:hover::before {
opacity: 1;
}
@keyframes gradient-move {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
/* 背景流动网格 */
.bg-grid-pattern {
background-size: 40px 40px;
background-image: linear-gradient(to right, rgba(0, 0, 0, 0.05) 1px, transparent 1px),
linear-gradient(to bottom, rgba(0, 0, 0, 0.05) 1px, transparent 1px);
}
.bg-grid-pattern-dark {
background-size: 40px 40px;
background-image: linear-gradient(to right, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
}
/* 通用过渡动画 */
.transition-all-200 {
transition: all 0.2s ease-in-out;
}
.transition-all-300 {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 拖拽排序 */
.question-item {
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.question-item[draggable="true"]:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.question-item.dragging {
opacity: 0.5;
}
.drag-handle {
font-size: 18px;
line-height: 1;
user-select: none;
}
.drag-handle:active {
cursor: grabbing;
}
/* 选项选中高亮 */
.option-label:has(input:checked) {
border-color: #3b82f6;
background-color: #eff6ff;
}
/* 计时器闪烁 */
@keyframes timer-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.animate-pulse {
animation: timer-pulse 1s ease-in-out infinite;
}
/* 题号导航按钮 */
#nav-panel button,
#nav-panel-mobile button {
transition: all 0.15s ease;
}
/* 进度条动画 */
#progress-bar {
transition: width 0.3s ease;
}
/* 批改页面底部栏 */
.sticky.bottom-4 {
backdrop-filter: blur(8px);
background-color: rgba(255,255,255,0.95);
}
/* 提交列表左侧颜色条 */
.border-l-4.border-l-green-400 {
border-left: 4px solid #4ade80;
}
.border-l-4.border-l-yellow-400 {
border-left: 4px solid #facc15;
}
/* 快速给分按钮 */
button[onclick^="quickScore"] {
transition: all 0.1s ease;
}
button[onclick^="quickScore"]:active {
transform: scale(0.95);
}
/* 响应式优化 */
@media (max-width: 640px) {
.sticky.top-0 {
position: sticky;
top: 0;
}
#nav-panel-mobile {
max-height: 120px;
overflow-y: auto;
}
}

736
static/js/rich-editor.js Normal file
View File

@@ -0,0 +1,736 @@
/**
* RichEditor - 共享富文本工具栏组件
* 用于聊天和论坛的输入区域
*/
// ========== 常量 ==========
const RE_FONTS = [
{ label: '默认', value: '' },
{ label: '宋体', value: '宋体,SimSun' },
{ label: '黑体', value: '黑体,SimHei' },
{ label: '楷体', value: '楷体,KaiTi' },
{ label: '仿宋', value: '仿宋,FangSong' },
{ label: '微软雅黑', value: '微软雅黑,Microsoft YaHei' },
{ label: '华文行楷', value: '华文行楷,STXingkai' },
{ label: '华文新魏', value: '华文新魏,STXinwei' },
{ label: '隶书', value: '隶书,LiSu' },
{ label: '幼圆', value: '幼圆,YouYuan' },
{ label: 'Consolas', value: 'Consolas,monospace' },
{ label: 'Georgia', value: 'Georgia,serif' },
{ label: 'Comic Sans', value: 'Comic Sans MS,cursive' },
{ label: 'Times New Roman', value: 'Times New Roman,serif' },
];
const RE_EMOJIS = {
'表情': ['😀','😁','😂','🤣','😊','😍','🥰','😘','😜','🤪'],
'手势': ['👍','👎','👏','🙌','🤝','✌️','🤞','👌','🫶','🙏'],
'心形': ['❤️','🧡','💛','💚','💙','💜','🖤','🤍','💖','💝'],
'动物': ['🐱','🐶','🐼','🦊','🐰','🐸','🐵','🦁','🐯','🐮'],
'物品': ['🎉','🎊','🔥','⭐','💡','📚','✏️','🏆','🎯','💻'],
'符号': ['✅','❌','⚠️','💯','🔴','🟢','🔵','⬆️','⬇️','➡️'],
};
const RE_COLORS = [
'#ef4444','#f97316','#eab308','#22c55e','#06b6d4',
'#3b82f6','#8b5cf6','#ec4899','#6b7280','#000000',
'#dc2626','#ea580c','#ca8a04','#16a34a','#0891b2',
'#2563eb','#7c3aed','#db2777','#9ca3af','#ffffff',
];
const RE_MATH_SYMBOLS = {
'基础运算': [
{ label: '+', tex: '+' },
{ label: '', tex: '-' },
{ label: '±', tex: '\\pm' },
{ label: '∓', tex: '\\mp' },
{ label: '×', tex: '\\times' },
{ label: '÷', tex: '\\div' },
{ label: '·', tex: '\\cdot' },
{ label: '=', tex: '=' },
{ label: '≠', tex: '\\neq' },
{ label: '≈', tex: '\\approx' },
{ label: '≡', tex: '\\equiv' },
{ label: '<', tex: '<' },
{ label: '>', tex: '>' },
{ label: '≤', tex: '\\leq' },
{ label: '≥', tex: '\\geq' },
{ label: '≪', tex: '\\ll' },
{ label: '≫', tex: '\\gg' },
{ label: '∝', tex: '\\propto' },
],
'上下标': [
{ label: 'x²', tex: '^{2}' },
{ label: 'x³', tex: '^{3}' },
{ label: 'xⁿ', tex: '^{}', cursor: -1 },
{ label: 'x₁', tex: '_{}', cursor: -1 },
{ label: 'xₙ', tex: '_{n}' },
{ label: 'x₁²', tex: '_{}^{}', cursor: -4 },
],
'分数根号': [
{ label: '分数', tex: '\\frac{}{}', cursor: -3 },
{ label: '√', tex: '\\sqrt{}', cursor: -1 },
{ label: 'ⁿ√', tex: '\\sqrt[n]{}', cursor: -1 },
{ label: 'ᵃ⁄ᵦ', tex: '\\dfrac{}{}', cursor: -3 },
],
'希腊字母': [
{ label: 'α', tex: '\\alpha' },
{ label: 'β', tex: '\\beta' },
{ label: 'γ', tex: '\\gamma' },
{ label: 'δ', tex: '\\delta' },
{ label: 'ε', tex: '\\epsilon' },
{ label: 'ζ', tex: '\\zeta' },
{ label: 'η', tex: '\\eta' },
{ label: 'θ', tex: '\\theta' },
{ label: 'λ', tex: '\\lambda' },
{ label: 'μ', tex: '\\mu' },
{ label: 'ξ', tex: '\\xi' },
{ label: 'π', tex: '\\pi' },
{ label: 'ρ', tex: '\\rho' },
{ label: 'σ', tex: '\\sigma' },
{ label: 'τ', tex: '\\tau' },
{ label: 'φ', tex: '\\varphi' },
{ label: 'ω', tex: '\\omega' },
{ label: 'Δ', tex: '\\Delta' },
{ label: 'Σ', tex: '\\Sigma' },
{ label: 'Ω', tex: '\\Omega' },
{ label: 'Φ', tex: '\\Phi' },
{ label: 'Π', tex: '\\Pi' },
{ label: 'Λ', tex: '\\Lambda' },
{ label: 'Γ', tex: '\\Gamma' },
],
'高等数学': [
{ label: '∑', tex: '\\sum_{i=1}^{n}', cursor: 0 },
{ label: '∏', tex: '\\prod_{i=1}^{n}', cursor: 0 },
{ label: '∫', tex: '\\int_{a}^{b}', cursor: 0 },
{ label: '∬', tex: '\\iint' },
{ label: '∮', tex: '\\oint' },
{ label: 'lim', tex: '\\lim_{x \\to }', cursor: -1 },
{ label: '∞', tex: '\\infty' },
{ label: 'log', tex: '\\log_{}', cursor: -1 },
{ label: 'ln', tex: '\\ln' },
{ label: 'sin', tex: '\\sin' },
{ label: 'cos', tex: '\\cos' },
{ label: 'tan', tex: '\\tan' },
{ label: 'arcsin', tex: '\\arcsin' },
{ label: 'arccos', tex: '\\arccos' },
{ label: 'arctan', tex: '\\arctan' },
{ label: '∂', tex: '\\partial' },
{ label: '∇', tex: '\\nabla' },
{ label: 'dx', tex: '\\mathrm{d}x' },
],
'集合逻辑': [
{ label: '∈', tex: '\\in' },
{ label: '∉', tex: '\\notin' },
{ label: '⊂', tex: '\\subset' },
{ label: '⊃', tex: '\\supset' },
{ label: '⊆', tex: '\\subseteq' },
{ label: '⊇', tex: '\\supseteq' },
{ label: '', tex: '\\cup' },
{ label: '∩', tex: '\\cap' },
{ label: '∅', tex: '\\emptyset' },
{ label: '∀', tex: '\\forall' },
{ label: '∃', tex: '\\exists' },
{ label: '¬', tex: '\\neg' },
{ label: '∧', tex: '\\land' },
{ label: '', tex: '\\lor' },
{ label: '⇒', tex: '\\Rightarrow' },
{ label: '⇔', tex: '\\Leftrightarrow' },
{ label: '→', tex: '\\to' },
{ label: '↦', tex: '\\mapsto' },
],
'括号矩阵': [
{ label: '()', tex: '\\left( \\right)', cursor: -8 },
{ label: '[]', tex: '\\left[ \\right]', cursor: -8 },
{ label: '{}', tex: '\\left\\{ \\right\\}', cursor: -9 },
{ label: '||', tex: '\\left| \\right|', cursor: -8 },
{ label: '⌈⌉', tex: '\\lceil \\rceil', cursor: -7 },
{ label: '⌊⌋', tex: '\\lfloor \\rfloor', cursor: -8 },
{ label: '矩阵', tex: '\\begin{pmatrix} \\\\ \\end{pmatrix}', cursor: -18 },
{ label: '行列式', tex: '\\begin{vmatrix} \\\\ \\end{vmatrix}', cursor: -18 },
{ label: '方程组', tex: '\\begin{cases} \\\\ \\end{cases}', cursor: -15 },
],
'其他符号': [
{ label: '…', tex: '\\cdots' },
{ label: '⋮', tex: '\\vdots' },
{ label: '⋱', tex: '\\ddots' },
{ label: '°', tex: '^{\\circ}' },
{ label: '‰', tex: '\\permil' },
{ label: '→', tex: '\\vec{}', cursor: -1 },
{ label: 'â', tex: '\\hat{}', cursor: -1 },
{ label: 'ā', tex: '\\bar{}', cursor: -1 },
{ label: 'ã', tex: '\\tilde{}', cursor: -1 },
{ label: '⊥', tex: '\\perp' },
{ label: '∥', tex: '\\parallel' },
{ label: '∠', tex: '\\angle' },
{ label: '△', tex: '\\triangle' },
{ label: '□', tex: '\\square' },
{ label: '⊕', tex: '\\oplus' },
{ label: '⊗', tex: '\\otimes' },
{ label: '★', tex: '\\star' },
{ label: '♠', tex: '\\spadesuit' },
],
};
const RE_FORMULA_TEMPLATES = [
{ label: '二次公式', tex: 'x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}' },
{ label: '勾股定理', tex: 'a^2 + b^2 = c^2' },
{ label: '求和公式', tex: '\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}' },
{ label: '二项式定理', tex: '(a+b)^n = \\sum_{k=0}^{n} \\binom{n}{k} a^{n-k} b^k' },
{ label: '欧拉公式', tex: 'e^{i\\pi} + 1 = 0' },
{ label: '三角恒等式', tex: '\\sin^2\\theta + \\cos^2\\theta = 1' },
{ label: '对数换底', tex: '\\log_a b = \\frac{\\ln b}{\\ln a}' },
{ label: '导数定义', tex: "f'(x) = \\lim_{h \\to 0} \\frac{f(x+h) - f(x)}{h}" },
{ label: '定积分', tex: '\\int_a^b f(x)\\,dx = F(b) - F(a)' },
{ label: '矩阵乘法', tex: '\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix} \\begin{pmatrix} e \\\\ f \\end{pmatrix} = \\begin{pmatrix} ae+bf \\\\ ce+df \\end{pmatrix}' },
{ label: '方程组', tex: '\\begin{cases} x + y = 5 \\\\ 2x - y = 1 \\end{cases}' },
{ label: '极限', tex: '\\lim_{x \\to \\infty} \\left(1 + \\frac{1}{x}\\right)^x = e' },
];
// ========== RichEditor 类 ==========
class RichEditor {
constructor(textareaId, options = {}) {
this.ta = document.getElementById(textareaId);
if (!this.ta) return;
this.compact = options.compact || false; // 紧凑模式(聊天用)
this.onEnter = options.onEnter || null;
this._buildToolbar();
this._buildPreview();
this._bindKeys();
this._bindPreviewUpdate();
}
_buildToolbar() {
const bar = document.createElement('div');
bar.className = 're-toolbar' + (this.compact ? ' re-compact' : '');
// 粗体
bar.appendChild(this._btn('B', '粗体', 're-bold', () => this._wrap('**', '**')));
// 斜体
bar.appendChild(this._btn('I', '斜体', 're-italic', () => this._wrap('*', '*')));
// 代码
bar.appendChild(this._btn('&lt;&gt;', '代码', 're-code', () => this._wrap('`', '`')));
bar.appendChild(this._sep());
// 颜色
const colorWrap = document.createElement('div');
colorWrap.className = 're-dropdown-wrap';
const colorBtn = this._btn('A', '文字颜色', 're-color-btn', () => this._togglePanel(colorWrap));
colorBtn.innerHTML = '<span style="border-bottom:2px solid #ef4444;padding-bottom:1px">A</span>';
colorWrap.appendChild(colorBtn);
colorWrap.appendChild(this._buildColorPanel(colorBtn));
bar.appendChild(colorWrap);
// 字体
const fontWrap = document.createElement('div');
fontWrap.className = 're-dropdown-wrap';
fontWrap.appendChild(this._btn('字', '字体', 're-font-btn', () => this._togglePanel(fontWrap)));
fontWrap.appendChild(this._buildFontPanel());
bar.appendChild(fontWrap);
bar.appendChild(this._sep());
// Emoji
const emojiWrap = document.createElement('div');
emojiWrap.className = 're-dropdown-wrap';
emojiWrap.appendChild(this._btn('😊', 'Emoji', 're-emoji-btn', () => this._togglePanel(emojiWrap)));
emojiWrap.appendChild(this._buildEmojiPanel());
bar.appendChild(emojiWrap);
// 公式编辑器按钮
bar.appendChild(this._sep());
const self = this;
bar.appendChild(this._btn('fx', '公式编辑器', 're-fx-btn', () => openMathEditor(self.ta)));
this.ta.parentNode.insertBefore(bar, this.ta);
this.toolbar = bar;
// 点击外部关闭面板
document.addEventListener('click', (e) => {
if (!bar.contains(e.target)) {
bar.querySelectorAll('.re-panel').forEach(p => p.classList.add('hidden'));
}
});
}
_btn(html, title, cls, onclick) {
const b = document.createElement('button');
b.type = 'button';
b.className = 're-btn ' + (cls || '');
b.title = title;
b.innerHTML = html;
b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); onclick(); });
return b;
}
_sep() {
const s = document.createElement('span');
s.className = 're-sep';
return s;
}
_togglePanel(wrap) {
const panel = wrap.querySelector('.re-panel');
const wasHidden = panel.classList.contains('hidden');
this.toolbar.querySelectorAll('.re-panel').forEach(p => p.classList.add('hidden'));
if (wasHidden) panel.classList.remove('hidden');
}
_wrap(pre, suf) {
const s = this.ta.selectionStart, e = this.ta.selectionEnd;
const txt = this.ta.value;
const sel = txt.substring(s, e) || '文本';
this.ta.value = txt.substring(0, s) + pre + sel + suf + txt.substring(e);
this.ta.focus();
this.ta.selectionStart = s + pre.length;
this.ta.selectionEnd = s + pre.length + sel.length;
this._refreshPreview();
}
_insertAt(text, cursorOffset) {
const s = this.ta.selectionStart;
const v = this.ta.value;
this.ta.value = v.substring(0, s) + text + v.substring(s);
this.ta.focus();
this.ta.selectionStart = this.ta.selectionEnd = s + text.length + (cursorOffset || 0);
this._refreshPreview();
}
_bindKeys() {
if (!this.onEnter) return;
this.ta.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.onEnter();
}
});
}
_buildPreview() {
// 实时预览面板,插在 textarea 后面
const pv = document.createElement('div');
pv.className = 're-live-preview';
pv.innerHTML = '<div class="re-pv-label">预览</div><div class="re-pv-body"></div>';
// 插到 textarea 的父容器末尾border 容器内)
this.ta.parentNode.appendChild(pv);
this.previewEl = pv.querySelector('.re-pv-body');
}
_bindPreviewUpdate() {
let timer = null;
const update = () => {
if (!this.previewEl) return;
const val = this.ta.value.trim();
if (!val) { this.previewEl.innerHTML = '<span style="color:#94a3b8;font-size:13px">输入内容后这里会显示实时预览...</span>'; return; }
this.previewEl.innerHTML = renderRichContent(val);
};
this.ta.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(update, 150); });
update();
}
_refreshPreview() {
if (!this.previewEl) return;
const val = this.ta.value.trim();
if (!val) { this.previewEl.innerHTML = '<span style="color:#94a3b8;font-size:13px">输入内容后这里会显示实时预览...</span>'; return; }
this.previewEl.innerHTML = renderRichContent(val);
}
// ===== 颜色面板 =====
_buildColorPanel(colorBtn) {
const panel = document.createElement('div');
panel.className = 're-panel re-color-panel hidden';
RE_COLORS.forEach(c => {
const swatch = document.createElement('button');
swatch.type = 'button';
swatch.className = 're-swatch';
swatch.style.background = c;
if (c === '#ffffff') swatch.style.border = '1px solid #e2e8f0';
swatch.addEventListener('click', (e) => {
e.stopPropagation();
this._wrap(`[color:${c}]`, '[/color]');
colorBtn.querySelector('span').style.borderBottomColor = c;
panel.classList.add('hidden');
});
panel.appendChild(swatch);
});
return panel;
}
// ===== 字体面板 =====
_buildFontPanel() {
const panel = document.createElement('div');
panel.className = 're-panel re-font-panel hidden';
RE_FONTS.forEach(f => {
const item = document.createElement('button');
item.type = 'button';
item.className = 're-font-item';
item.textContent = f.label;
if (f.value) item.style.fontFamily = f.value;
item.addEventListener('click', (e) => {
e.stopPropagation();
if (f.value) {
this._wrap(`[font:${f.label}]`, '[/font]');
}
panel.classList.add('hidden');
});
panel.appendChild(item);
});
return panel;
}
// ===== Emoji 面板 =====
_buildEmojiPanel() {
const panel = document.createElement('div');
panel.className = 're-panel re-emoji-panel hidden';
const tabs = document.createElement('div');
tabs.className = 're-emoji-tabs';
const body = document.createElement('div');
body.className = 're-emoji-body';
const categories = Object.keys(RE_EMOJIS);
categories.forEach((cat, i) => {
const tab = document.createElement('button');
tab.type = 'button';
tab.className = 're-emoji-tab' + (i === 0 ? ' active' : '');
tab.textContent = cat;
tab.addEventListener('click', (e) => {
e.stopPropagation();
tabs.querySelectorAll('.re-emoji-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
this._showEmojiCat(body, cat);
});
tabs.appendChild(tab);
});
panel.appendChild(tabs);
panel.appendChild(body);
this._showEmojiCat(body, categories[0]);
return panel;
}
_showEmojiCat(body, cat) {
body.innerHTML = '';
RE_EMOJIS[cat].forEach(em => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 're-emoji-item';
btn.textContent = em;
btn.addEventListener('click', (e) => {
e.stopPropagation();
this._insertAt(em);
});
body.appendChild(btn);
});
}
}
// ========== MathFormulaEditor 单例类 ==========
class MathFormulaEditor {
constructor() {
if (MathFormulaEditor._instance) return MathFormulaEditor._instance;
MathFormulaEditor._instance = this;
this._built = false;
this._target = null;
this._onInsert = null;
this._debounceTimer = null;
}
open(targetTextarea, onInsert) {
this._target = targetTextarea;
this._onInsert = onInsert || null;
if (!this._built) this._buildDOM();
this._input.value = '';
this._previewEl.innerHTML = '<span style="color:#94a3b8">在上方输入 LaTeX 公式,这里实时预览</span>';
this._overlay.classList.remove('hidden');
document.body.style.overflow = 'hidden';
// 激活第一个符号分类 tab
const firstTab = this._symbolTabs.querySelector('.re-emoji-tab');
if (firstTab) firstTab.click();
setTimeout(() => this._input.focus(), 50);
}
close() {
this._overlay.classList.add('hidden');
document.body.style.overflow = '';
}
_buildDOM() {
this._built = true;
const overlay = document.createElement('div');
overlay.className = 're-math-editor-overlay hidden';
overlay.addEventListener('click', (e) => { if (e.target === overlay) this.close(); });
const modal = document.createElement('div');
modal.className = 're-math-editor-modal';
// Header
const header = document.createElement('div');
header.className = 're-math-editor-header';
header.innerHTML = '<span style="font-weight:600;font-size:16px;color:#1e293b">数学公式编辑器</span>';
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 're-math-editor-close';
closeBtn.innerHTML = '&times;';
closeBtn.addEventListener('click', () => this.close());
header.appendChild(closeBtn);
modal.appendChild(header);
// Body
const body = document.createElement('div');
body.className = 're-math-editor-body';
// LaTeX input
const inputLabel = document.createElement('div');
inputLabel.style.cssText = 'font-size:12px;color:#64748b;margin-bottom:4px;font-weight:500';
inputLabel.textContent = 'LaTeX 公式';
body.appendChild(inputLabel);
const input = document.createElement('textarea');
input.className = 're-math-editor-input';
input.rows = 3;
input.placeholder = '输入 LaTeX 公式,如 \\frac{a}{b}、x^2 + y^2 = r^2';
input.addEventListener('input', () => this._updatePreview());
this._input = input;
body.appendChild(input);
// Preview
const pvLabel = document.createElement('div');
pvLabel.style.cssText = 'font-size:12px;color:#64748b;margin:12px 0 4px;font-weight:500';
pvLabel.textContent = '实时预览';
body.appendChild(pvLabel);
const preview = document.createElement('div');
preview.className = 're-math-editor-preview';
this._previewEl = preview;
body.appendChild(preview);
// Symbol panel
const symLabel = document.createElement('div');
symLabel.style.cssText = 'font-size:12px;color:#64748b;margin:12px 0 4px;font-weight:500';
symLabel.textContent = '符号面板';
body.appendChild(symLabel);
// PLACEHOLDER_SYMBOL_PANEL
const symTabs = document.createElement('div');
symTabs.className = 're-emoji-tabs';
symTabs.style.cssText = 'border:1px solid #e2e8f0;border-radius:8px 8px 0 0';
this._symbolTabs = symTabs;
const symGrid = document.createElement('div');
symGrid.className = 're-math-grid';
symGrid.style.cssText = 'border:1px solid #e2e8f0;border-top:none;border-radius:0 0 8px 8px;margin-bottom:0';
const categories = Object.keys(RE_MATH_SYMBOLS);
categories.forEach((cat, i) => {
const tab = document.createElement('button');
tab.type = 'button';
tab.className = 're-emoji-tab' + (i === 0 ? ' active' : '');
tab.textContent = cat;
tab.addEventListener('click', (e) => {
e.stopPropagation();
symTabs.querySelectorAll('.re-emoji-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
this._showSymCat(symGrid, cat);
});
symTabs.appendChild(tab);
});
body.appendChild(symTabs);
body.appendChild(symGrid);
this._showSymCat(symGrid, categories[0]);
// Template section
const tplLabel = document.createElement('div');
tplLabel.style.cssText = 'font-size:12px;color:#64748b;margin:14px 0 6px;font-weight:500';
tplLabel.textContent = '常用公式模板';
body.appendChild(tplLabel);
const tplGrid = document.createElement('div');
tplGrid.className = 're-math-editor-templates';
RE_FORMULA_TEMPLATES.forEach(tpl => {
const card = document.createElement('button');
card.type = 'button';
card.className = 're-math-editor-tpl-card';
const texDiv = document.createElement('div');
texDiv.className = 're-math-editor-tpl-tex';
if (window.katex) {
try { texDiv.innerHTML = katex.renderToString(tpl.tex, { throwOnError: false, displayMode: false }); }
catch(e) { texDiv.textContent = tpl.tex; }
} else { texDiv.textContent = tpl.tex; }
const labelDiv = document.createElement('div');
labelDiv.className = 're-math-editor-tpl-label';
labelDiv.textContent = tpl.label;
card.appendChild(texDiv);
card.appendChild(labelDiv);
card.addEventListener('click', () => this._applyTemplate(tpl.tex));
tplGrid.appendChild(card);
});
body.appendChild(tplGrid);
modal.appendChild(body);
// Footer
const footer = document.createElement('div');
footer.className = 're-math-editor-footer';
const cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 're-math-editor-btn-cancel';
cancelBtn.textContent = '取消';
cancelBtn.addEventListener('click', () => this.close());
const confirmBtn = document.createElement('button');
confirmBtn.type = 'button';
confirmBtn.className = 're-math-editor-btn-confirm';
confirmBtn.textContent = '插入公式';
confirmBtn.addEventListener('click', () => this._confirm());
footer.appendChild(cancelBtn);
footer.appendChild(confirmBtn);
modal.appendChild(footer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
this._overlay = overlay;
// ESC to close
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !this._overlay.classList.contains('hidden')) this.close();
});
}
_updatePreview() {
clearTimeout(this._debounceTimer);
this._debounceTimer = setTimeout(() => {
const tex = this._input.value.trim();
if (!tex) {
this._previewEl.innerHTML = '<span style="color:#94a3b8">在上方输入 LaTeX 公式,这里实时预览</span>';
return;
}
if (window.katex) {
try {
this._previewEl.innerHTML = katex.renderToString(tex, { throwOnError: true, displayMode: true });
} catch(e) {
this._previewEl.innerHTML = '<span style="color:#ef4444;font-size:13px">语法错误: ' + e.message.replace(/</g,'&lt;') + '</span>';
}
} else {
this._previewEl.textContent = tex;
}
}, 150);
}
_insertSymbol(tex, cursor) {
const start = this._input.selectionStart;
const val = this._input.value;
this._input.value = val.substring(0, start) + tex + val.substring(start);
this._input.focus();
this._input.selectionStart = this._input.selectionEnd = start + tex.length + (cursor || 0);
this._updatePreview();
}
_applyTemplate(tex) {
this._input.value = tex;
this._input.focus();
this._input.selectionStart = this._input.selectionEnd = tex.length;
this._updatePreview();
}
_confirm() {
const tex = this._input.value.trim();
if (!tex) { this.close(); return; }
const formula = '$' + tex + '$';
if (this._target) {
const start = this._target.selectionStart || this._target.value.length;
const val = this._target.value;
this._target.value = val.substring(0, start) + formula + val.substring(start);
this._target.selectionStart = this._target.selectionEnd = start + formula.length;
this._target.dispatchEvent(new Event('input', { bubbles: true }));
}
if (typeof this._onInsert === 'function') this._onInsert(formula);
this.close();
}
_showSymCat(grid, cat) {
grid.innerHTML = '';
RE_MATH_SYMBOLS[cat].forEach(sym => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 're-math-item';
btn.textContent = sym.label;
btn.title = sym.tex;
btn.addEventListener('click', (e) => {
e.stopPropagation();
this._insertSymbol(sym.tex, sym.cursor || 0);
});
grid.appendChild(btn);
});
}
}
function openMathEditor(textarea, callback) {
new MathFormulaEditor().open(textarea, callback);
}
// ========== 全局渲染函数 ==========
const RE_FONT_MAP = {};
RE_FONTS.forEach(f => { if (f.value) RE_FONT_MAP[f.label] = f.value; });
function _reEsc(text) {
const d = document.createElement('div');
d.textContent = text;
return d.innerHTML;
}
function renderRichContent(text) {
if (!text) return '';
// 1. 先提取公式,避免 HTML 转义破坏 LaTeX
const formulas = [];
let raw = text.replace(/\$([^$\n]+?)\$/g, function(m, tex) {
const idx = formulas.length;
formulas.push(tex);
return '\x00MATH' + idx + '\x00';
});
// 2. HTML 转义
let h = _reEsc(raw);
// 3. 图片标签 [img:url]
h = h.replace(/\[img:(\/static\/uploads\/[^\]]+)\]/g,
'<img src="$1" class="max-w-full rounded-lg border border-slate-200 my-2 cursor-pointer" style="max-height:400px" onclick="window.open(this.src)">');
// 4. 还原公式并用 KaTeX 渲染
h = h.replace(/\x00MATH(\d+)\x00/g, function(m, idx) {
const tex = formulas[parseInt(idx)];
if (window.katex) {
try {
return katex.renderToString(tex, { throwOnError: false });
} catch(e) { return '$' + _reEsc(tex) + '$'; }
}
// KaTeX 未加载时显示原始公式并标记 class 供后续渲染
return '<span class="re-pending-math" data-tex="' + _reEsc(tex) + '">$' + _reEsc(tex) + '$</span>';
});
// 5. 粗体 **...**
h = h.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// 6. 斜体 *...*
h = h.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
// 7. 行内代码 `...`
h = h.replace(/`([^`]+?)`/g, '<code class="re-inline-code">$1</code>');
// 8. 颜色 [color:#hex]...[/color]
h = h.replace(/\[color:(#[0-9a-fA-F]{3,8})\]([\s\S]*?)\[\/color\]/g,
'<span style="color:$1">$2</span>');
// 9. 字体 [font:name]...[/font]
h = h.replace(/\[font:([^\]]+)\]([\s\S]*?)\[\/font\]/g, function(m, name, content) {
const family = RE_FONT_MAP[name];
if (family) return '<span style="font-family:' + _reEsc(family) + '">' + content + '</span>';
return content;
});
return h;
}
// KaTeX 延迟加载后,重新渲染页面上未渲染的公式
function _reRenderPendingMath() {
if (!window.katex) return;
document.querySelectorAll('.re-pending-math').forEach(el => {
try {
el.innerHTML = katex.renderToString(el.dataset.tex, { throwOnError: false });
el.classList.remove('re-pending-math');
} catch(e) {}
});
}
// 定期检查 KaTeX 是否加载完成
(function _waitKatex() {
if (window.katex) { _reRenderPendingMath(); return; }
setTimeout(_waitKatex, 500);
})();

Binary file not shown.

After

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB