<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>编辑作品 - 未命名作品 - XMUER Coding Community (XCC)</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* 编辑器容器 */
.editor-container {
display: flex;
flex-direction: column;
width: 100%;
height: 100vh;
}
.editor-header {
display: flex;
align-items: center;
padding: 0 1.25rem;
height: 48px;
background: #1e1e1e;
color: white;
border-bottom: 1px solid #333;
flex-shrink: 0;
position: relative;
z-index: 50;
}
.logo {
font-weight: 700;
font-size: 1.125rem;
margin-right: 1.5rem;
color: #4d97ff;
}
.project-title {
flex: 1;
font-size: 0.875rem;
opacity: 0.8;
}
.actions {
display: flex;
gap: 0.5rem;
position: relative;
}
.btn {
padding: 0.4rem 0.875rem;
border-radius: 6px;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
border: none;
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-save {
background: #4d97ff;
color: white;
}
.btn-save:hover {
background: #3b82f6;
}
.btn-publish {
background: #ff8c1a;
color: white;
}
.btn-publish:hover {
background: #f97316;
}
.btn-download,
.btn-safe {
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: rgba(255,255,255,0.08);
color: rgba(255,255,255,0.7);
border: 1px solid rgba(255,255,255,0.1);
text-decoration: none;
}
.btn-download:hover,
.btn-safe:hover {
background: rgba(255,255,255,0.14);
color: #fff;
}
.editor-content {
flex: 1;
background: #1e1e1e;
position: relative;
contain: layout style paint;
}
.editor-content iframe {
width: 100%;
height: 100%;
border: none;
}
/* ========== 编辑器加载层 ========== */
.editor-loading {
position: absolute;
inset: 0;
z-index: 10;
background: #1e1e1e;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.5s ease;
}
.editor-loading.fade-out {
opacity: 0;
pointer-events: none;
}
/* 加载完成后停止spinner动画,释放GPU */
.editor-loading.fade-out .loading-spinner {
animation: none;
}
.loading-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
}
.loading-spinner {
width: 36px;
height: 36px;
border: 3px solid rgba(255,255,255,0.1);
border-top-color: #4d97ff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
color: rgba(255,255,255,0.6);
font-size: 0.875rem;
min-height: 1.5em;
transition: opacity 0.3s ease;
}
.loading-text.swap { opacity: 0; }
.loading-bar-wrap {
width: 200px;
height: 3px;
background: rgba(255,255,255,0.08);
border-radius: 2px;
overflow: hidden;
}
.loading-bar {
height: 100%;
width: 0%;
background: #4d97ff;
border-radius: 2px;
transition: width 0.8s ease;
}
/* ========== 溢出菜单 ========== */
.btn-more {
display: none;
padding: 0.35rem 0.5rem;
background: rgba(255,255,255,0.08);
color: rgba(255,255,255,0.7);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
position: relative;
}
.btn-more:hover {
background: rgba(255,255,255,0.14);
color: #fff;
}
.more-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 140px;
background: rgba(30,30,30,0.85);
backdrop-filter: blur(20px) saturate(1.8);
-webkit-backdrop-filter: blur(20px) saturate(1.8);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 10px;
padding: 0.375rem;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
z-index: 200;
opacity: 0;
visibility: hidden;
transform: translateY(-8px) scale(0.95);
transition: opacity 0.2s ease, transform 0.2s cubic-bezier(0.32, 0.72, 0, 1), visibility 0.2s;
}
.more-menu.show {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
}
.more-menu-item {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
background: none;
border: none;
color: rgba(255,255,255,0.8);
font-size: 0.8125rem;
border-radius: 6px;
cursor: pointer;
text-align: left;
transition: background 0.12s, color 0.12s;
text-decoration: none;
box-sizing: border-box;
}
.more-menu-item:hover {
background: rgba(255,255,255,0.1);
color: #fff;
}
.more-menu-publish { color: #ff8c1a; }
.more-menu-publish:hover {
color: #ffb366;
background: rgba(255,140,26,0.1);
}
/* ========== 毛玻璃弹窗系统 ========== */
.xcc-modal-backdrop {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0);
pointer-events: none;
transition: background 0.35s ease, backdrop-filter 0.35s ease, -webkit-backdrop-filter 0.35s ease;
padding: 1.5rem;
contain: layout style;
}
.xcc-modal-backdrop.show {
background: rgba(0,0,0,0.25);
backdrop-filter: blur(12px) saturate(1.5);
-webkit-backdrop-filter: blur(12px) saturate(1.5);
pointer-events: auto;
}
.xcc-modal {
background: rgba(255,255,255,0.8);
backdrop-filter: blur(40px) saturate(1.8);
-webkit-backdrop-filter: blur(40px) saturate(1.8);
border: 1px solid rgba(255,255,255,0.4);
border-radius: 16px;
padding: 2rem 1.75rem 1.5rem;
max-width: 380px;
width: 100%;
text-align: center;
box-shadow:
0 8px 32px rgba(0,0,0,0.08),
0 2px 8px rgba(0,0,0,0.04),
inset 0 1px 0 rgba(255,255,255,0.6);
opacity: 0;
transform: translateY(16px) scale(0.92);
transition: opacity 0.35s cubic-bezier(0.32, 0.72, 0, 1), transform 0.4s cubic-bezier(0.32, 0.72, 0, 1);
}
.xcc-modal-backdrop.show .xcc-modal {
opacity: 1;
transform: translateY(0) scale(1);
}
.xcc-modal-icon { margin-bottom: 0.75rem; font-size: 2rem; line-height: 1; }
.xcc-modal-title { font-size: 1rem; font-weight: 600; color: #111; margin: 0 0 0.5rem; }
.xcc-modal-message { font-size: 0.8125rem; color: #555; margin: 0 0 1.5rem; line-height: 1.6; word-break: break-all; }
.xcc-modal-actions { display: flex; gap: 0.625rem; justify-content: center; }
.xcc-modal-btn {
padding: 0.5rem 1.25rem;
border-radius: 8px;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
border: none;
transition: background 0.2s ease, box-shadow 0.2s ease;
min-width: 72px;
}
.xcc-modal-btn-primary { background: #4d97ff; color: #fff; }
.xcc-modal-btn-primary:hover { background: #3b82f6; box-shadow: 0 2px 12px rgba(77,151,255,0.3); }
.xcc-modal-btn-danger { background: #ef4444; color: #fff; }
.xcc-modal-btn-danger:hover { background: #dc2626; box-shadow: 0 2px 12px rgba(239,68,68,0.3); }
.xcc-modal-btn-ghost { background: rgba(0,0,0,0.04); color: #555; border: 1px solid rgba(0,0,0,0.08); }
.xcc-modal-btn-ghost:hover { background: rgba(0,0,0,0.08); }
.xcc-modal-btn-warning { background: #ff8c1a; color: #fff; }
.xcc-modal-btn-warning:hover { background: #f97316; box-shadow: 0 2px 12px rgba(255,140,26,0.3); }
/* ========== Toast 提示 ========== */
.xcc-toast-container {
position: fixed;
top: 1rem;
left: 50%;
transform: translateX(-50%);
z-index: 10000;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
pointer-events: none;
}
.xcc-toast {
background: rgba(30,30,30,0.85);
backdrop-filter: blur(20px) saturate(1.8);
-webkit-backdrop-filter: blur(20px) saturate(1.8);
color: #fff;
padding: 0.625rem 1.25rem;
border-radius: 10px;
font-size: 0.8125rem;
font-weight: 500;
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
pointer-events: auto;
opacity: 0;
transform: translateY(-12px) scale(0.9);
transition: opacity 0.3s cubic-bezier(0.32, 0.72, 0, 1), transform 0.35s cubic-bezier(0.32, 0.72, 0, 1);
max-width: 90vw;
text-align: center;
}
.xcc-toast.show { opacity: 1; transform: translateY(0) scale(1); }
.xcc-toast-success { border-left: 3px solid #22c55e; }
.xcc-toast-error { border-left: 3px solid #ef4444; }
.xcc-toast-info { border-left: 3px solid #4d97ff; }
/* ========== 发布全屏页面 ========== */
.publish-page {
position: fixed;
inset: 0;
background: #fff;
z-index: 1000;
display: flex;
flex-direction: column;
transform: translateY(100%);
opacity: 0;
transition: transform 0.35s cubic-bezier(0.32, 0.72, 0, 1), opacity 0.25s ease;
pointer-events: none;
contain: layout style paint;
}
.publish-page.active {
transform: translateY(0);
opacity: 1;
pointer-events: auto;
}
.publish-page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.5rem;
height: 56px;
border-bottom: 1px solid #e5e7eb;
flex-shrink: 0;
}
.publish-page-header h2 { font-size: 1rem; font-weight: 600; color: #111; }
.btn-back {
display: flex;
align-items: center;
gap: 0.375rem;
background: none;
border: none;
font-size: 0.875rem;
color: #4d97ff;
cursor: pointer;
padding: 0.5rem;
margin-left: -0.5rem;
border-radius: 6px;
transition: background 0.15s;
}
.btn-back:hover { background: #f0f5ff; }
.btn-confirm-publish {
padding: 0.5rem 1.25rem;
background: #ff8c1a;
color: white;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.btn-confirm-publish:hover { background: #f97316; }
.btn-confirm-publish:disabled { opacity: 0.5; cursor: not-allowed; }
.publish-page-body {
flex: 1;
overflow-y: auto;
padding: 2rem;
display: flex;
justify-content: center;
}
.publish-form { width: 100%; max-width: 560px; }
.form-group { margin-bottom: 1.25rem; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.form-label { display: block; font-size: 0.8125rem; font-weight: 500; color: #374151; margin-bottom: 0.5rem; }
.required { color: #ef4444; }
.form-input,
.form-textarea {
width: 100%;
padding: 0.625rem 0.875rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.875rem;
background: #fff;
transition: border-color 0.15s, box-shadow 0.15s;
}
.form-input:focus,
.form-textarea:focus {
outline: none;
border-color: #4d97ff;
box-shadow: 0 0 0 3px rgba(77, 151, 255, 0.15);
}
.form-textarea { resize: vertical; min-height: 80px; }
.form-hint { display: block; font-size: 0.75rem; color: #9ca3af; margin-top: 0.375rem; }
.form-hint.is-warn { color: #b45309; font-weight: 500; }
/* 封面工具行 */
.thumbnail-tools {
display: flex;
align-items: center;
gap: 0.625rem;
flex-wrap: wrap;
margin-top: 0.5rem;
}
.btn-stage-shot {
padding: 0.3125rem 0.75rem;
background: #fff;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.8125rem;
color: #374151;
cursor: pointer;
flex-shrink: 0;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.btn-stage-shot:hover { border-color: #4d97ff; color: #4d97ff; background: #f8faff; }
.btn-stage-shot:disabled { opacity: 0.6; cursor: not-allowed; }
.thumbnail-tip { font-size: 0.75rem; line-height: 1.5; color: #b45309; }
.thumbnail-tip.is-ok { color: #047857; }
.thumbnail-upload {
position: relative;
width: 100%;
height: 120px;
border: 2px dashed #d1d5db;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
overflow: hidden;
transition: border-color 0.15s, background 0.15s;
}
.thumbnail-upload:hover { border-color: #4d97ff; background: #f8faff; }
.thumbnail-upload input[type="file"] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.thumbnail-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.375rem;
color: #9ca3af;
font-size: 0.8125rem;
}
.thumbnail-preview-img { display: none; width: 100%; height: 100%; object-fit: cover; }
.thumbnail-preview-img[src] { display: block; }
.publish-meta {
display: flex;
gap: 1.5rem;
font-size: 0.8125rem;
color: #6b7280;
padding: 0.75rem 1rem;
background: #f9fafb;
border-radius: 8px;
margin-bottom: 1rem;
}
.form-error {
display: none;
background: #fef2f2;
color: #dc2626;
padding: 0.75rem 1rem;
border-radius: 8px;
font-size: 0.8125rem;
margin-top: 0.5rem;
}
.form-error.show { display: block; }
/* 团队Logo预览 */
.team-logo-preview {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(15%);
display: flex;
align-items: center;
pointer-events: none;
}
.team-logo-preview img { border: 1px solid #e5e7eb; }
/* ========== 许可证选择 ========== */
.license-select-btn {
display: flex !important;
align-items: center;
justify-content: space-between;
cursor: pointer;
user-select: none;
}
.license-select-btn:hover { border-color: #555 !important; }
.license-modal {
display: none;
position: fixed;
inset: 0;
z-index: 10001;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
justify-content: center;
align-items: center;
contain: layout style;
}
.license-modal.active { display: flex; }
.license-modal-content {
width: 520px;
max-width: 90vw;
max-height: 80vh;
background: #1e1e1e;
border-radius: 12px;
border: 1px solid #333;
display: flex;
flex-direction: column;
overflow: hidden;
}
.license-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid #333;
}
.license-modal-header h3 { margin: 0; font-size: 1rem; font-weight: 600; color: #e0e0e0; }
.license-modal-close {
background: none;
border: none;
color: #888;
cursor: pointer;
padding: 4px;
border-radius: 4px;
display: flex;
align-items: center;
}
.license-modal-close:hover { color: #fff; background: rgba(255,255,255,0.1); }
.license-modal-body { overflow-y: auto; padding: 0.75rem 1.25rem 1.25rem; }
.license-group { margin-bottom: 1rem; }
.license-group:last-child { margin-bottom: 0; }
.license-group-title {
font-size: 0.7rem;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.5rem 0 0.375rem;
}
.license-option {
padding: 0.625rem 0.75rem;
border-radius: 8px;
cursor: pointer;
transition: background 0.12s;
margin-bottom: 2px;
}
.license-option:hover { background: rgba(255, 255, 255, 0.06); }
.license-option.selected { background: rgba(77, 151, 255, 0.15); }
.license-option-name { font-size: 0.875rem; font-weight: 500; color: #e0e0e0; }
.license-option-desc { font-size: 0.75rem; color: #888; margin-top: 2px; }
.license-option.selected .license-option-name { color: #4d97ff; }
/* ========== 保险箱面板 ========== */
.safe-panel {
position: fixed;
inset: 0;
background: #f5f6f8;
z-index: 1000;
display: flex;
flex-direction: column;
transform: translateY(100%);
opacity: 0;
transition: transform 0.35s cubic-bezier(0.32, 0.72, 0, 1), opacity 0.25s ease;
pointer-events: none;
contain: layout style paint;
}
.safe-panel.active {
transform: translateY(0);
opacity: 1;
pointer-events: auto;
}
.safe-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.5rem;
height: 56px;
background: #fff;
border-bottom: 1px solid #e5e7eb;
flex-shrink: 0;
}
.safe-panel-header h2 { font-size: 1rem; font-weight: 600; color: #111; }
.safe-panel-body { flex: 1; overflow-y: auto; padding: 1.5rem; }
.safe-panel-content { max-width: 640px; margin: 0 auto; }
.safe-stats {
display: flex;
align-items: center;
justify-content: center;
gap: 2rem;
padding: 1.25rem 2rem;
background: #fff;
border-radius: 14px;
border: 1px solid #e5e7eb;
margin-bottom: 1.25rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
}
.safe-stat { display: flex; align-items: baseline; gap: 0.375rem; }
.safe-stat-value { font-size: 1.5rem; font-weight: 700; color: #111; font-variant-numeric: tabular-nums; }
.safe-stat-label { font-size: 0.8125rem; color: #9ca3af; }
.safe-stat-divider { width: 1px; height: 28px; background: #e5e7eb; }
.safe-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding-top: 5rem;
}
.safe-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding-top: 4rem;
}
.safe-empty-icon {
width: 96px;
height: 96px;
background: #fff;
border-radius: 24px;
border: 1px solid #e5e7eb;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.5rem;
}
.safe-empty p { font-size: 0.9375rem; font-weight: 500; color: #374151; margin: 0; }
.safe-empty small { font-size: 0.8125rem; color: #9ca3af; }
.safe-versions { display: flex; flex-direction: column; gap: 0.625rem; }
.safe-version-card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 14px;
padding: 1.25rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
position: relative;
overflow: hidden;
}
.safe-version-card::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: #4d97ff;
border-radius: 0 3px 3px 0;
opacity: 0;
transition: opacity 0.2s;
}
.safe-version-card:hover {
border-color: #c7d2fe;
box-shadow: 0 4px 12px rgba(77,151,255,0.08);
transform: translateY(-1px);
}
.safe-version-card:hover::before { opacity: 1; }
.safe-version-card.is-latest { border-color: #c7d2fe; background: linear-gradient(135deg, #f8faff 0%, #fff 100%); }
.safe-version-card.is-latest::before { opacity: 1; background: #22c55e; }
.safe-version-info { display: flex; flex-direction: column; gap: 0.5rem; min-width: 0; }
.safe-version-number {
font-size: 1.0625rem;
font-weight: 700;
color: #111;
display: flex;
align-items: center;
gap: 0.5rem;
}
.safe-version-badge {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 0.15rem 0.5rem;
background: #dcfce7;
color: #16a34a;
font-size: 0.6875rem;
font-weight: 600;
border-radius: 9999px;
line-height: 1;
}
.safe-version-meta { display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; font-size: 0.8125rem; color: #6b7280; }
.safe-version-meta-item { display: inline-flex; align-items: center; gap: 0.3rem; }
.safe-version-meta-item svg { flex-shrink: 0; opacity: 0.5; }
.safe-version-meta-item.file-size {
font-variant-numeric: tabular-nums;
font-family: 'SF Mono', 'Cascadia Code', 'Menlo', monospace;
font-size: 0.75rem;
background: #f3f4f6;
padding: 0.125rem 0.5rem;
border-radius: 4px;
color: #374151;
}
.safe-version-actions { display: flex; gap: 0.5rem; flex-shrink: 0; margin-left: 1.25rem; }
.btn-restore {
padding: 0.5rem 1.125rem;
background: #fff;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s, color 0.2s, border-color 0.2s, box-shadow 0.2s;
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
.btn-restore:hover { background: #4d97ff; color: #fff; border-color: #4d97ff; box-shadow: 0 2px 8px rgba(77,151,255,0.3); }
.btn-restore svg { width: 14px; height: 14px; }
.btn-download-version {
padding: 0.5rem 1.125rem;
background: #fff;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s, color 0.2s, border-color 0.2s, box-shadow 0.2s;
display: inline-flex;
align-items: center;
gap: 0.375rem;
text-decoration: none;
}
.btn-download-version:hover { background: #22c55e; color: #fff; border-color: #22c55e; box-shadow: 0 2px 8px rgba(34,197,94,0.3); }
.btn-download-version svg { width: 14px; height: 14px; }
/* ========== 5秒不可撤销警告 ========== */
.restore-warning {
display: none;
position: fixed;
inset: 0;
z-index: 10002;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
justify-content: center;
align-items: center;
contain: layout style;
}
.restore-warning.active { display: flex; }
.restore-warning-content {
background: #fff;
border-radius: 16px;
padding: 2.5rem 2rem 2rem;
max-width: 400px;
width: 90vw;
text-align: center;
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
animation: restoreWarnIn 0.3s ease;
}
@keyframes restoreWarnIn {
from { transform: scale(0.9); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.restore-warning-icon { margin-bottom: 1rem; }
.restore-warning-title { font-size: 1.125rem; font-weight: 700; color: #dc2626; margin: 0 0 0.75rem; }
.restore-warning-msg { font-size: 0.875rem; color: #374151; line-height: 1.7; margin: 0 0 0.5rem; }
.restore-warning-msg strong { color: #111; }
.restore-warning-tip { font-size: 0.75rem; color: #9ca3af; margin: 0 0 1.5rem; }
.restore-warning-actions { display: flex; gap: 0.75rem; justify-content: center; }
.restore-warning-actions .xcc-modal-btn { min-width: 110px; padding: 0.625rem 1.5rem; }
.xcc-modal-btn-danger[disabled] { background: #fca5a5; color: #fff; cursor: not-allowed; opacity: 1; }
.xcc-modal-btn-danger:not([disabled]) { animation: restoreBtnReady 0.3s ease; }
@keyframes restoreBtnReady {
0% { transform: scale(0.95); }
50% { transform: scale(1.03); }
100% { transform: scale(1); }
}
/* ========== 移动端响应式 ========== */
@media (max-width: 640px) {
.editor-header { padding: 0 0.75rem; }
.logo { margin-right: 0.75rem; }
.project-title { display: none; }
.actions .btn:not(.btn-more) { display: none; }
.actions .btn-more { display: flex; }
.more-menu { background: rgba(30,30,30,0.92); }
}
</style>
</head>
<body>
<div class="editor-container">
<div class="editor-header">
<span class="logo">XCC</span>
<span class="project-title">未命名作品</span>
<div class="actions">
<button class="btn" id="btn-back" onclick="goBack()">返回</button>
<button class="btn btn-save" id="btn-save" onclick="saveProject()">保存</button>
<button class="btn btn-publish" id="btn-publish" onclick="openPublishModal()">发布</button>
<a class="btn btn-download" href="/editor/gt0tqzprlzam0psmxnmo/sb3" title="下载SB3文件">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
下载
</a>
<button class="btn btn-safe" id="btn-safe" onclick="openSafePanel()" title="版本保险箱">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
保险箱
</button>
<button class="btn btn-more" id="btn-more" onclick="toggleMoreMenu(event)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>
</button>
<div class="more-menu" id="more-menu">
<button class="more-menu-item" onclick="goBack(); closeMoreMenu()">返回</button>
<button class="more-menu-item" onclick="saveProject(); closeMoreMenu()">保存</button>
<button class="more-menu-item more-menu-publish" onclick="openPublishModal(); closeMoreMenu()">发布</button>
<a class="more-menu-item" href="/editor/gt0tqzprlzam0psmxnmo/sb3" onclick="closeMoreMenu()">下载SB3</a>
<button class="more-menu-item" onclick="openSafePanel(); closeMoreMenu()">保险箱</button>
</div>
</div>
</div>
<div class="editor-content">
<!-- 自定义加载层 -->
<div class="editor-loading" id="editor-loading">
<div class="loading-inner">
<div class="loading-spinner"></div>
<div class="loading-text" id="loading-text">正在初始化作品</div>
<div class="loading-bar-wrap">
<div class="loading-bar" id="loading-bar"></div>
</div>
</div>
</div>
<iframe
id="scratch-editor"
src="/xce/zh-CN/editor.html?v=1790502944&project_url=https%3A%2F%2Fcoding.xmuer.online%2Feditor%2Fgt0tqzprlzam0psmxnmo%2Fsb3%2F6bc129dfda4100a2"
allow="autoplay"
></iframe>
</div>
</div>
<!-- 通用毛玻璃弹窗 -->
<div class="xcc-modal-backdrop" id="xcc-modal-backdrop">
<div class="xcc-modal" id="xcc-modal">
<div class="xcc-modal-icon" id="xcc-modal-icon"></div>
<h3 class="xcc-modal-title" id="xcc-modal-title"></h3>
<p class="xcc-modal-message" id="xcc-modal-message"></p>
<div class="xcc-modal-actions" id="xcc-modal-actions"></div>
</div>
</div>
<!-- Toast 提示 -->
<div class="xcc-toast-container" id="xcc-toast-container"></div>
<!-- 发布全屏页面 -->
<div class="publish-page" id="publish-modal">
<div class="publish-page-header">
<button class="btn-back" onclick="closePublishModal()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
返回编辑
</button>
<h2>发布作品</h2>
<button class="btn-confirm-publish" id="btn-confirm-publish" onclick="publishProject()">确认发布</button>
</div>
<div class="publish-page-body">
<div class="publish-form">
<div class="form-group">
<label class="form-label">作品标题 <span class="required">*</span></label>
<input type="text" id="publish-title" class="form-input"
value="未命名作品"
placeholder="给作品起个名字" maxlength="200">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">版本号 <span class="required">*</span></label>
<input type="text" id="publish-version" class="form-input"
value="1.0.0"
placeholder="1.0.0">
<small class="form-hint">x.y.z 格式,须高于当前版本</small>
</div>
<div class="form-group">
<label class="form-label">作品封面</label>
<div class="thumbnail-upload" id="thumbnail-upload">
<input type="file" id="publish-thumbnail" accept="image/*">
<div class="thumbnail-placeholder" id="thumbnail-placeholder" >
<svg width="24" height="24" 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>
<span>点击上传</span>
</div>
<img id="preview-img" class="thumbnail-preview-img" >
</div>
<div class="thumbnail-tools" id="thumbnail-tools" data-has-thumb="0">
<button type="button" class="btn-stage-shot" id="btn-stage-shot">从舞台截图</button>
<span class="thumbnail-tip" id="thumbnail-tip">
没封面的作品会被推荐降权,很难被刷到,建议截一张
</span>
</div>
</div>
</div>
<div class="form-group">
<label class="form-label">作品描述</label>
<textarea id="publish-description" class="form-textarea" rows="3"
placeholder="介绍一下你的作品..."></textarea>
</div>
<div class="form-group">
<label class="form-label">更新日志<span class="required" id="changelog-required" style="display:none"> *</span></label>
<textarea id="publish-changelog" class="form-textarea" rows="3"
placeholder="本次更新了什么...(可选)"></textarea>
<small class="form-hint" id="changelog-hint">改了许可证必须写清楚;换了团队会自动记一笔</small>
</div>
<div class="form-group">
<label class="form-label">所属团队</label>
<select id="publish-team" class="form-input" data-current="0">
<option value="0">不绑定团队</option>
</select>
<small class="form-hint">只能绑定自己所在的团队;更换团队会自动记进本次更新日志</small>
</div>
<div class="form-group">
<label class="form-label">许可证</label>
<input type="hidden" id="publish-license" value="all-rights-reserved">
<button type="button" class="form-input license-select-btn" id="license-select-btn" onclick="openLicenseModal()">
<span id="license-selected-name">许可证加载中...</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
</button>
<small class="form-hint">选择作品的发布许可证,用户可在作品页查看许可证详情</small>
</div>
<div class="publish-meta">
<span>剩余发布次数: <strong>10</strong>/10</span>
</div>
<div class="form-error" id="publish-error"></div>
</div>
</div>
</div>
<!-- 许可证选择弹窗 -->
<div class="license-modal" id="license-modal" onclick="if(event.target===this)closeLicenseModal()">
<div class="license-modal-content">
<div class="license-modal-header">
<h3>选择许可证</h3>
<button class="license-modal-close" onclick="closeLicenseModal()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="license-modal-body">
<div class="license-group">
<div class="license-group-title">默认</div>
<div class="license-option" data-slug="all-rights-reserved" onclick="selectLicense(this)">
<div class="license-option-name">All Rights Reserved</div>
<div class="license-option-desc">保留所有权利,未经许可不得使用</div>
</div>
</div>
<div class="license-group">
<div class="license-group-title">Creative Commons</div>
<div class="license-option" data-slug="cc-by-4.0" onclick="selectLicense(this)">
<div class="license-option-name">CC BY 4.0</div>
<div class="license-option-desc">署名 — 只要注明原作者即可自由使用</div>
</div>
<div class="license-option" data-slug="cc-by-sa-4.0" onclick="selectLicense(this)">
<div class="license-option-name">CC BY-SA 4.0</div>
<div class="license-option-desc">署名-相同方式共享</div>
</div>
<div class="license-option" data-slug="cc-by-nc-4.0" onclick="selectLicense(this)">
<div class="license-option-name">CC BY-NC 4.0</div>
<div class="license-option-desc">署名-非商业性使用</div>
</div>
<div class="license-option" data-slug="cc-by-nc-sa-4.0" onclick="selectLicense(this)">
<div class="license-option-name">CC BY-NC-SA 4.0</div>
<div class="license-option-desc">署名-非商业性使用-相同方式共享</div>
</div>
<div class="license-option" data-slug="cc0-1.0" onclick="selectLicense(this)">
<div class="license-option-name">CC0 1.0</div>
<div class="license-option-desc">公有领域 — 放弃所有权利</div>
</div>
</div>
<div class="license-group">
<div class="license-group-title">Creative Commons 旧版本</div>
<div class="license-option" data-slug="cc-by-sa-3.0" onclick="selectLicense(this)">
<div class="license-option-name">CC BY-SA 3.0</div>
<div class="license-option-desc">署名-相同方式共享,未本地化旧版</div>
</div>
<div class="license-option" data-slug="cc-by-sa-2.0" onclick="selectLicense(this)">
<div class="license-option-name">CC BY-SA 2.0</div>
<div class="license-option-desc">署名-相同方式共享,Scratch 官网 2026 年前的旧许可</div>
</div>
</div>
<div class="license-group">
<div class="license-group-title">代码许可证</div>
<div class="license-option" data-slug="mit" onclick="selectLicense(this)">
<div class="license-option-name">MIT License</div>
<div class="license-option-desc">极度宽松,只要保留声明即可做任何事</div>
</div>
<div class="license-option" data-slug="unlicense" onclick="selectLicense(this)">
<div class="license-option-name">The Unlicense</div>
<div class="license-option-desc">公共领域,完全无限制</div>
</div>
<div class="license-option" data-slug="bsd-2-clause" onclick="selectLicense(this)">
<div class="license-option-name">BSD 2-Clause</div>
<div class="license-option-desc">简化许可证,仅要求保留版权声明</div>
</div>
<div class="license-option" data-slug="mpl-2.0" onclick="selectLicense(this)">
<div class="license-option-name">MPL 2.0</div>
<div class="license-option-desc">弱Copyleft,修改的文件必须开源</div>
</div>
<div class="license-option" data-slug="gpl-2.0" onclick="selectLicense(this)">
<div class="license-option-name">GPL v2.0</div>
<div class="license-option-desc">强Copyleft,衍生作品必须以相同许可证开源</div>
</div>
<div class="license-option" data-slug="gpl-3.0" onclick="selectLicense(this)">
<div class="license-option-name">GPL v3.0</div>
<div class="license-option-desc">强Copyleft,含反Tivo化条款</div>
</div>
</div>
<div class="license-group">
<div class="license-group-title">CaelLab</div>
<div class="license-option" data-slug="caellab-bysa-code-v2" onclick="selectLicense(this)">
<div class="license-option-name">CaelLab BY-SA Code 2.0</div>
<div class="license-option-desc">代码/网站/数字作品的Copyleft许可证第 2 版,新作品建议选这个</div>
</div>
<div class="license-option" data-slug="caellab-bysa-code" onclick="selectLicense(this)">
<div class="license-option-name">CaelLab BY-SA Code</div>
<div class="license-option-desc">适用于代码、网站和数字作品的Copyleft许可证(旧版)</div>
</div>
<div class="license-option" data-slug="caellab-bysa-content" onclick="selectLicense(this)">
<div class="license-option-name">CaelLab BY-SA Content</div>
<div class="license-option-desc">适用于文章、Wiki、文档和创意内容的Copyleft许可证</div>
</div>
</div>
</div>
</div>
</div>
<!-- 保险箱面板 -->
<div class="safe-panel" id="safe-panel">
<div class="safe-panel-header">
<button class="btn-back" onclick="closeSafePanel()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
返回编辑
</button>
<h2>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-3px;margin-right:6px"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
版本保险箱
</h2>
<div style="width:120px"></div>
</div>
<div class="safe-panel-body">
<div class="safe-panel-content">
<div class="safe-stats" id="safe-stats" style="display:none">
<div class="safe-stat">
<span class="safe-stat-value" id="safe-stat-count">0</span>
<span class="safe-stat-label">个版本</span>
</div>
<div class="safe-stat-divider"></div>
<div class="safe-stat">
<span class="safe-stat-value" id="safe-stat-size">0</span>
<span class="safe-stat-label">总大小</span>
</div>
</div>
<div class="safe-loading" id="safe-loading">
<div class="loading-spinner"></div>
<div style="color:#9ca3af;font-size:0.875rem">加载版本列表...</div>
</div>
<div class="safe-empty" id="safe-empty" style="display:none">
<div class="safe-empty-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#d1d5db" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
</div>
<p>还没有发布过任何版本</p>
<small>发布作品后,所有版本都会出现在这里</small>
</div>
<div class="safe-versions" id="safe-versions" style="display:none"></div>
</div>
</div>
</div>
<!-- 5秒不可撤销警告弹窗 -->
<div class="restore-warning" id="restore-warning" onclick="if(event.target===this)cancelRestore()">
<div class="restore-warning-content">
<div class="restore-warning-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</div>
<h3 class="restore-warning-title">此操作不可撤销</h3>
<p class="restore-warning-msg">
即将还原到版本 <strong id="restore-target-version"></strong><br>
当前编辑器中的<strong>未保存内容将被覆盖</strong>,且无法恢复。
</p>
<p class="restore-warning-tip">请确保已保存当前进度</p>
<div class="restore-warning-actions">
<button class="xcc-modal-btn xcc-modal-btn-ghost" onclick="cancelRestore()">取消</button>
<button class="xcc-modal-btn xcc-modal-btn-danger" id="restore-confirm-btn" disabled>
<span id="restore-btn-text">请等待 5 秒</span>
</button>
</div>
</div>
</div>
<script>
// 自动为所有fetch请求添加 X-Requested-With 头,配合后端 isAjax() 检测
(function() {
var _fetch = window.fetch;
window.fetch = function(url, opts) {
opts = opts || {};
opts.headers = opts.headers || {};
if (opts.headers instanceof Headers) {
opts.headers.set('X-Requested-With', 'XMLHttpRequest');
} else if (typeof opts.headers === 'object') {
opts.headers['X-Requested-With'] = 'XMLHttpRequest';
}
return _fetch.call(this, url, opts);
};
})();
var projectId = "gt0tqzprlzam0psmxnmo";
var isAuthor = true;
var editorFrame = document.getElementById('scratch-editor');
var CSRF_TOKEN = 'c303b26af183145f919fcb903e338f73272289a4bba1fde7f31e8d21fbda0a0c';
// ========== 毛玻璃弹窗系统 ==========
var XCC = (function() {
var backdrop = document.getElementById('xcc-modal-backdrop');
var iconEl = document.getElementById('xcc-modal-icon');
var titleEl = document.getElementById('xcc-modal-title');
var msgEl = document.getElementById('xcc-modal-message');
var actionsEl = document.getElementById('xcc-modal-actions');
var toastContainer = document.getElementById('xcc-toast-container');
function closeModal() {
backdrop.classList.remove('show');
setTimeout(function() { actionsEl.innerHTML = ''; }, 350);
}
backdrop.addEventListener('click', function(e) {
if (e.target === backdrop) closeModal();
});
return {
alert: function(title, message, type) {
type = type || 'info';
var icons = { info: '\u2139', success: '\u2713', error: '\u2715', warning: '\u26A0' };
iconEl.textContent = icons[type] || icons.info;
titleEl.textContent = title;
msgEl.textContent = message || '';
actionsEl.innerHTML = '<button class="xcc-modal-btn xcc-modal-btn-primary" onclick="XCC._close()">确定</button>';
backdrop.classList.add('show');
},
confirm: function(opts) {
var icons = { info: '\u2139', success: '\u2713', error: '\u2715', warning: '\u26A0', danger: '!' };
iconEl.textContent = icons[opts.type] || '?';
titleEl.textContent = opts.title || '确认操作';
msgEl.textContent = opts.message || '';
var cancelBtn = '<button class="xcc-modal-btn xcc-modal-btn-ghost" onclick="XCC._close()">' + (opts.cancelText || '取消') + '</button>';
var confirmClass = opts.danger ? 'xcc-modal-btn-danger' : (opts.warning ? 'xcc-modal-btn-warning' : 'xcc-modal-btn-primary');
var confirmBtn = '<button class="xcc-modal-btn ' + confirmClass + '" id="xcc-confirm-btn">' + (opts.confirmText || '确定') + '</button>';
actionsEl.innerHTML = cancelBtn + confirmBtn;
document.getElementById('xcc-confirm-btn').onclick = function() {
closeModal();
if (opts.onConfirm) opts.onConfirm();
};
backdrop.classList.add('show');
},
_close: closeModal,
toast: function(message, type, duration) {
type = type || 'info';
duration = duration || 2500;
var toast = document.createElement('div');
toast.className = 'xcc-toast xcc-toast-' + type;
toast.textContent = message;
toastContainer.appendChild(toast);
requestAnimationFrame(function() { toast.classList.add('show'); });
setTimeout(function() {
toast.classList.remove('show');
setTimeout(function() { toast.remove(); }, 350);
}, duration);
}
};
})();
// ========== 溢出菜单 ==========
function toggleMoreMenu(e) {
e.stopPropagation();
var menu = document.getElementById('more-menu');
var isOpen = menu.classList.contains('show');
closeMoreMenu();
if (!isOpen) menu.classList.add('show');
}
function closeMoreMenu() {
document.getElementById('more-menu').classList.remove('show');
}
document.addEventListener('click', function(e) {
if (!e.target.closest('#btn-more') && !e.target.closest('#more-menu')) {
closeMoreMenu();
}
});
// ========== 编辑器加载动画 ==========
(function() {
var loadingEl = document.getElementById('editor-loading');
var textEl = document.getElementById('loading-text');
var barEl = document.getElementById('loading-bar');
var steps = [
{ text: '正在初始化作品', progress: 8 },
{ text: '申请作品基础属性', progress: 15 },
{ text: '拉取作品数据中', progress: 22 },
{ text: '尝试重新取得令牌', progress: 30 },
{ text: '正在整理回调信息', progress: 38 },
{ text: '加载编辑器内核', progress: 48 },
{ text: '申请扩展信息并组装上下文', progress: 56 },
{ text: '校验作品完整性', progress: 64 },
{ text: '构建运行时依赖树', progress: 72 },
{ text: '挂载积木渲染管线', progress: 80 },
{ text: '初始化舞台上下文', progress: 88 },
{ text: '即将就绪', progress: 95 },
];
var current = 0;
var editorReady = false;
function showStep(index) {
if (index >= steps.length) return;
textEl.classList.add('swap');
setTimeout(function() {
textEl.textContent = steps[index].text;
barEl.style.width = steps[index].progress + '%';
textEl.classList.remove('swap');
}, 300);
}
showStep(0);
var timer = setInterval(function() {
current++;
if (current < steps.length) {
showStep(current);
} else {
clearInterval(timer);
timer = null;
}
}, 1500);
function dismiss() {
if (editorReady) return;
editorReady = true;
if (timer) { clearInterval(timer); timer = null; }
barEl.style.width = '100%';
setTimeout(function() {
loadingEl.classList.add('fade-out');
// 淡出完成后彻底移除DOM,释放spinner动画的GPU合成层
setTimeout(function() { loadingEl.remove(); }, 500);
}, 400);
}
editorFrame.addEventListener('load', function() {
setTimeout(dismiss, 1500);
});
// 保底:最长10秒后无论如何消失
setTimeout(dismiss, 10000);
})();
// ========== 离开页面提醒 ==========
var hasUnsavedChanges = isAuthor;
window.addEventListener('beforeunload', function(e) {
if (hasUnsavedChanges) {
e.preventDefault();
e.returnValue = '';
}
});
// ========== 返回上一页(二次确认) ==========
function goBack() {
if (hasUnsavedChanges) {
XCC.confirm({
title: '确认离开',
message: '作品尚未保存,确定要离开编辑器吗?未保存的更改将会丢失。',
type: 'warning',
warning: true,
confirmText: '确认离开',
cancelText: '继续编辑',
onConfirm: function() {
hasUnsavedChanges = false;
history.back();
}
});
} else {
history.back();
}
}
// ========== 辅助函数 ==========
function base64ToBlob(base64) {
var binaryStr = atob(base64);
var len = binaryStr.length;
var bytes = new Uint8Array(len);
for (var i = 0; i < len; i++) {
bytes[i] = binaryStr.charCodeAt(i);
}
return new Blob([bytes]);
}
// 无DOM分配的HTML转义(比createElement快约10倍)
var _escapeMap = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
function escapeHtml(str) {
return str.replace(/[&<>"']/g, function(c) { return _escapeMap[c]; });
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
return (bytes / 1024).toFixed(1) + ' KB';
}
function formatTime(str) {
var d = new Date(str.replace(' ', 'T') + 'Z');
if (isNaN(d.getTime())) return str;
var diff = Date.now() - d;
if (diff < 60000) return '刚刚';
if (diff < 3600000) return Math.floor(diff / 60000) + ' 分钟前';
if (diff < 86400000) return Math.floor(diff / 3600000) + ' 小时前';
var month = d.getMonth() + 1;
var day = d.getDate();
var hours = String(d.getHours()).padStart(2, '0');
var mins = String(d.getMinutes()).padStart(2, '0');
return month + '月' + day + '日 ' + hours + ':' + mins;
}
// ========== 从编辑器获取SB3数据(统一复用) ==========
function getEditorData() {
return new Promise(function(resolve, reject) {
var timeout = setTimeout(function() {
window.removeEventListener('message', handler);
reject(new Error('获取数据超时(60秒)'));
}, 60000);
function handler(e) {
if (e.data && e.data.type === 'save-complete') {
clearTimeout(timeout);
window.removeEventListener('message', handler);
resolve(e.data.data);
} else if (e.data && e.data.type === 'save-error') {
clearTimeout(timeout);
window.removeEventListener('message', handler);
reject(new Error(e.data.error || '编辑器导出失败'));
}
}
window.addEventListener('message', handler);
editorFrame.contentWindow.postMessage({type: 'save'}, '*');
});
}
// ========== 保存项目 ==========
async function saveProject() {
var btn = document.getElementById('btn-save');
btn.textContent = '保存中...';
btn.disabled = true;
try {
var sb3Data = await getEditorData();
var blob = base64ToBlob(sb3Data);
var formData = new FormData();
formData.append('sb3_file', blob, 'project.sb3');
formData.append('_token', CSRF_TOKEN);
var response = await fetch('/editor/' + projectId + '/save', {
method: 'POST',
body: formData
});
var text = await response.text();
var result;
try { result = JSON.parse(text); } catch (e) {
throw new Error('服务器返回了非JSON响应,请检查登录状态或刷新页面重试');
}
if (result.success) {
hasUnsavedChanges = false;
btn.textContent = '已保存';
XCC.toast('作品已保存', 'success');
setTimeout(function() { btn.textContent = '保存'; }, 2000);
} else {
XCC.alert('保存失败', result.error || result.message || '未知错误', 'error');
btn.textContent = '保存';
}
} catch (err) {
XCC.alert('保存失败', err.message, 'error');
btn.textContent = '保存';
} finally {
btn.disabled = false;
}
}
// ========== 封面预览 ==========
document.getElementById('publish-thumbnail').addEventListener('change', function(e) {
var file = e.target.files[0];
if (file) {
var reader = new FileReader();
reader.onload = function(ev) {
document.getElementById('preview-img').src = ev.target.result;
document.getElementById('thumbnail-placeholder').style.display = 'none';
};
reader.readAsDataURL(file);
}
});
// ========== 舞台截图(发布封面) ==========
var stageShotBlob = null;
(function initStageShot() {
var btn = document.getElementById('btn-stage-shot');
var tools = document.getElementById('thumbnail-tools');
var tip = document.getElementById('thumbnail-tip');
var previewUrl = null;
var autoDone = false;
function setTip(text, ok) {
if (!tip) return;
tip.textContent = text;
tip.classList.toggle('is-ok', !!ok);
}
function innerWindow() {
try { return editorFrame.contentWindow; } catch (e) { return null; }
}
function requestSnapshot() {
return new Promise(function(resolve) {
var win = innerWindow();
var renderer = win && win.vm && win.vm.renderer;
if (!renderer || typeof renderer.requestSnapshot !== 'function') {
resolve(null);
return;
}
var settled = false;
var timer = setTimeout(function() {
if (!settled) { settled = true; resolve(null); }
}, 2500);
try {
renderer.requestSnapshot(function(dataURI) {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(dataURI || null);
});
} catch (e) {
clearTimeout(timer);
if (!settled) { settled = true; resolve(null); }
}
});
}
function readCanvasDirectly() {
var doc;
try { doc = editorFrame.contentDocument; } catch (e) { return null; }
if (!doc) return null;
var list = doc.querySelectorAll('canvas');
for (var i = 0; i < list.length; i++) {
if (list[i].width >= 200 && list[i].height >= 150) {
try { return list[i].toDataURL('image/png'); } catch (e) { return null; }
}
}
return null;
}
function loadImage(src) {
return new Promise(function(resolve) {
var img = new Image();
img.onload = function() { resolve(img); };
img.onerror = function() { resolve(null); };
img.src = src;
});
}
function toCoverBlob(img) {
return new Promise(function(resolve) {
if (!img) { resolve(null); return; }
var W = 480, H = 360;
var out = document.createElement('canvas');
out.width = W;
out.height = H;
var ctx = out.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, W, H);
var scale = Math.max(W / img.width, H / img.height);
var dw = img.width * scale;
var dh = img.height * scale;
ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh);
out.toBlob(function(blob) { resolve(blob); }, 'image/png');
});
}
function capture() {
return requestSnapshot()
.then(function(uri) { return uri || readCanvasDirectly(); })
.then(loadImage)
.then(toCoverBlob)
.then(function(blob) { return (blob && blob.size > 2000) ? blob : null; });
}
function applyShot(blob, tipText) {
stageShotBlob = blob;
if (previewUrl) URL.revokeObjectURL(previewUrl);
previewUrl = URL.createObjectURL(blob);
document.getElementById('preview-img').src = previewUrl;
document.getElementById('thumbnail-placeholder').style.display = 'none';
if (btn) btn.textContent = '重新截图';
setTip(tipText, true);
}
function userPickedFile() {
var input = document.getElementById('publish-thumbnail');
return !!(input && input.files && input.files.length);
}
if (btn) {
btn.addEventListener('click', function() {
btn.disabled = true;
btn.textContent = '截图中...';
capture().then(function(blob) {
btn.disabled = false;
if (!blob) {
btn.textContent = '从舞台截图';
XCC.toast('没抓到舞台画面,稍等一下再试,或手动上传封面', 'warning');
return;
}
applyShot(blob, '已截取舞台画面,发布时保存');
});
});
}
// 作品还没有封面:自动截一张
if (tools && tools.getAttribute('data-has-thumb') === '0') {
var attempts = 0;
var tryAuto = function() {
if (autoDone || userPickedFile()) return;
attempts++;
capture().then(function(blob) {
if (autoDone || userPickedFile()) return;
if (blob) {
autoDone = true;
applyShot(blob, '已自动截取舞台画面,发布时保存为封面');
return;
}
if (attempts < 4) setTimeout(tryAuto, 2500);
});
};
setTimeout(tryAuto, 4000);
}
})();
// ========== 发布页:更新日志强制提示 ==========
var hasPublished = false;
var initialLicense = document.getElementById('publish-license').value;
var initialTeamId = parseInt(document.getElementById('publish-team').getAttribute('data-current'), 10) || 0;
function publishChanges() {
var teamSelect = document.getElementById('publish-team');
return {
licenseChanged: hasPublished && document.getElementById('publish-license').value !== initialLicense,
teamChanged: !!teamSelect && (parseInt(teamSelect.value, 10) || 0) !== initialTeamId
};
}
function updateChangelogHint() {
var changed = publishChanges();
var req = document.getElementById('changelog-required');
var hint = document.getElementById('changelog-hint');
if (!req || !hint) return;
if (changed.licenseChanged) {
req.style.display = 'inline';
hint.textContent = '你更换了许可证,必须在这里写清楚改了什么';
hint.classList.add('is-warn');
} else {
req.style.display = 'none';
hint.textContent = changed.teamChanged
? '团队变更会自动记进本次更新日志'
: '改了许可证必须写清楚;换了团队会自动记一笔';
hint.classList.remove('is-warn');
}
}
// ========== 加载团队列表 ==========
(function loadTeams() {
var teamSelect = document.getElementById('publish-team');
if (!teamSelect) return;
var currentTeamId = parseInt(teamSelect.getAttribute('data-current'), 10) || 0;
var logoPreview = document.createElement('div');
logoPreview.id = 'team-logo-preview';
logoPreview.className = 'team-logo-preview';
logoPreview.style.display = 'none';
teamSelect.parentNode.style.position = 'relative';
teamSelect.parentNode.appendChild(logoPreview);
function syncTeamLogo() {
var selected = teamSelect.options[teamSelect.selectedIndex];
var logoUrl = selected ? selected.getAttribute('data-logo') : null;
if (logoUrl) {
logoPreview.innerHTML = '<img src="' + escapeHtml(logoUrl) + '" alt="团队Logo" style="width:24px;height:24px;border-radius:4px;object-fit:cover;">';
logoPreview.style.display = 'flex';
} else {
logoPreview.style.display = 'none';
}
}
function findOption(teamId) {
for (var i = 0; i < teamSelect.options.length; i++) {
if (teamSelect.options[i].value === String(teamId)) return teamSelect.options[i];
}
return null;
}
fetch('/api/team/my-teams')
.then(function(res) { return res.json(); })
.then(function(result) {
(result.teams || []).forEach(function(team) {
var existing = findOption(team.id);
if (existing) {
if (team.logo && !existing.getAttribute('data-logo')) {
existing.setAttribute('data-logo', team.logo);
}
return;
}
var option = document.createElement('option');
option.value = team.id;
option.textContent = team.name;
if (team.logo) option.setAttribute('data-logo', team.logo);
teamSelect.appendChild(option);
});
if (currentTeamId > 0) teamSelect.value = String(currentTeamId);
syncTeamLogo();
updateChangelogHint();
})
.catch(function(err) {
console.error('加载团队列表失败:', err);
});
teamSelect.addEventListener('change', function() {
syncTeamLogo();
updateChangelogHint();
});
})();
// ========== 许可证名称映射 ==========
var licenseNames = {
'all-rights-reserved': 'All Rights Reserved',
'cc-by-4.0': 'CC BY 4.0',
'cc-by-sa-4.0': 'CC BY-SA 4.0',
'cc-by-nc-4.0': 'CC BY-NC 4.0',
'cc-by-nc-sa-4.0': 'CC BY-NC-SA 4.0',
'cc0-1.0': 'CC0 1.0',
'cc-by-sa-3.0': 'CC BY-SA 3.0',
'cc-by-sa-2.0': 'CC BY-SA 2.0',
'mit': 'MIT License',
'unlicense': 'The Unlicense',
'bsd-2-clause': 'BSD 2-Clause',
'mpl-2.0': 'MPL 2.0',
'gpl-2.0': 'GPL v2.0',
'gpl-3.0': 'GPL v3.0',
'caellab-bysa-code-v2': 'CaelLab BY-SA Code 2.0',
'caellab-bysa-code': 'CaelLab BY-SA Code',
'caellab-bysa-content': 'CaelLab BY-SA Content'
};
function initLicenseButton() {
var slug = document.getElementById('publish-license').value || 'all-rights-reserved';
document.getElementById('license-selected-name').textContent = licenseNames[slug] || slug;
document.querySelectorAll('#license-modal .license-option').forEach(function(el) {
el.classList.toggle('selected', el.dataset.slug === slug);
});
}
// ========== 发布页开关 ==========
function openPublishModal() {
document.getElementById('publish-modal').classList.add('active');
initLicenseButton();
updateChangelogHint();
}
function closePublishModal() {
document.getElementById('publish-modal').classList.remove('active');
document.getElementById('publish-error').classList.remove('show');
}
// ========== 许可证弹窗 ==========
function openLicenseModal() {
initLicenseButton();
document.getElementById('license-modal').classList.add('active');
}
function closeLicenseModal() {
document.getElementById('license-modal').classList.remove('active');
}
function selectLicense(el) {
var slug = el.dataset.slug;
document.getElementById('publish-license').value = slug;
document.getElementById('license-selected-name').textContent = licenseNames[slug] || slug;
closeLicenseModal();
updateChangelogHint();
}
// ========== 发布项目 ==========
async function publishProject() {
var btn = document.getElementById('btn-confirm-publish');
var errorDiv = document.getElementById('publish-error');
var title = document.getElementById('publish-title').value.trim();
var version = document.getElementById('publish-version').value.trim();
var description = document.getElementById('publish-description').value.trim();
var changelog = document.getElementById('publish-changelog').value.trim();
var thumbnailFile = document.getElementById('publish-thumbnail').files[0] || stageShotBlob;
if (!title) {
errorDiv.textContent = '请输入作品标题';
errorDiv.classList.add('show');
return;
}
if (!version) {
errorDiv.textContent = '请输入版本号';
errorDiv.classList.add('show');
return;
}
if (publishChanges().licenseChanged && !changelog) {
updateChangelogHint();
errorDiv.textContent = '你更换了许可证,必须在更新日志里说明改了什么';
errorDiv.classList.add('show');
return;
}
btn.textContent = '发布中...';
btn.disabled = true;
document.getElementById('btn-save').disabled = true;
errorDiv.classList.remove('show');
try {
// 先保存当前编辑器内容
var sb3Data = await getEditorData();
var blob = base64ToBlob(sb3Data);
var saveFormData = new FormData();
saveFormData.append('sb3_file', blob, 'project.sb3');
saveFormData.append('_token', CSRF_TOKEN);
var saveResponse = await fetch('/editor/' + projectId + '/save', {
method: 'POST',
body: saveFormData
});
var saveResult = await saveResponse.json();
if (!saveResult.success) {
throw new Error('保存失败:' + (saveResult.error || '未知错误'));
}
var thumbnailUrl = null;
if (thumbnailFile) {
var thumbnailFormData = new FormData();
thumbnailFormData.append('thumbnail', thumbnailFile, thumbnailFile.name || 'stage-shot.png');
thumbnailFormData.append('_token', CSRF_TOKEN);
var thumbnailResponse = await fetch('/editor/' + projectId + '/thumbnail', {
method: 'POST',
body: thumbnailFormData
});
var thumbText = await thumbnailResponse.text();
var thumbnailResult;
try { thumbnailResult = JSON.parse(thumbText); } catch (e) {
throw new Error('封面上传失败:服务器返回了非JSON响应');
}
if (thumbnailResult.success) {
thumbnailUrl = thumbnailResult.thumbnail_url;
} else {
throw new Error(thumbnailResult.error || thumbnailResult.message || '封面上传失败');
}
}
var formData = new FormData();
formData.append('_token', CSRF_TOKEN);
formData.append('title', title);
formData.append('version', version);
formData.append('description', description);
formData.append('changelog', changelog);
var teamSelect = document.getElementById('publish-team');
if (teamSelect) formData.append('team_id', teamSelect.value);
formData.append('license', document.getElementById('publish-license').value);
if (thumbnailUrl) formData.append('thumbnail', thumbnailUrl);
var response = await fetch('/editor/' + projectId + '/publish', {
method: 'POST',
body: formData
});
var text = await response.text();
var result;
try { result = JSON.parse(text); } catch (e) {
throw new Error('服务器返回了非JSON响应,请检查登录状态或刷新页面重试');
}
if (result.success) {
hasUnsavedChanges = false;
XCC.toast('发布成功!', 'success');
setTimeout(function() { window.location.href = '/project/' + projectId; }, 800);
} else {
errorDiv.textContent = result.error || result.message || '发布失败';
if (result.reset_time) {
errorDiv.textContent += '(' + Math.ceil(result.reset_time / 60) + '分钟后可重试)';
}
errorDiv.classList.add('show');
}
} catch (err) {
errorDiv.textContent = '发布失败: ' + err.message;
errorDiv.classList.add('show');
} finally {
btn.textContent = '确认发布';
btn.disabled = false;
document.getElementById('btn-save').disabled = false;
}
}
// ========== 保险箱功能 ==========
var _restoreTimer = null;
var _pendingRestoreVersion = null;
function openSafePanel() {
var panel = document.getElementById('safe-panel');
var loading = document.getElementById('safe-loading');
var empty = document.getElementById('safe-empty');
var versions = document.getElementById('safe-versions');
var stats = document.getElementById('safe-stats');
panel.classList.add('active');
loading.style.display = 'flex';
empty.style.display = 'none';
versions.style.display = 'none';
stats.style.display = 'none';
fetch('/editor/' + projectId + '/versions')
.then(function(res) { return res.json(); })
.then(function(result) {
loading.style.display = 'none';
if (!result.success || !result.versions || result.versions.length === 0) {
empty.style.display = 'flex';
return;
}
renderVersions(result.versions);
})
.catch(function() {
loading.style.display = 'none';
XCC.alert('加载失败', '无法获取版本列表,请刷新重试', 'error');
});
}
function renderVersions(list) {
var container = document.getElementById('safe-versions');
container.innerHTML = '';
var totalSize = list.reduce(function(sum, v) { return sum + v.size; }, 0);
document.getElementById('safe-stat-count').textContent = list.length;
document.getElementById('safe-stat-size').textContent = formatFileSize(totalSize);
document.getElementById('safe-stats').style.display = 'flex';
// 使用DocumentFragment减少重排次数
var frag = document.createDocumentFragment();
list.forEach(function(v, i) {
var card = document.createElement('div');
card.className = 'safe-version-card' + (i === 0 ? ' is-latest' : '');
card.innerHTML =
'<div class="safe-version-info">' +
'<div class="safe-version-number">' +
'v' + escapeHtml(v.version) +
(i === 0 ? '<span class="safe-version-badge">最新</span>' : '') +
'</div>' +
'<div class="safe-version-meta">' +
'<span class="safe-version-meta-item">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>' +
formatTime(v.modified_at) +
'</span>' +
'<span class="safe-version-meta-item file-size">' +
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>' +
formatFileSize(v.size) +
'</span>' +
'</div>' +
'</div>' +
'<div class="safe-version-actions">' +
'<a class="btn-download-version" href="/editor/' + projectId + '/download/' + encodeURIComponent(v.version) + '" title="下载此版本">' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>' +
'下载' +
'</a>' +
'<button class="btn-restore" onclick="confirmRestore(\'' + escapeHtml(v.version) + '\')">' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>' +
'还原' +
'</button>' +
'</div>';
frag.appendChild(card);
});
container.appendChild(frag);
container.style.display = 'flex';
}
function closeSafePanel() {
document.getElementById('safe-panel').classList.remove('active');
}
function confirmRestore(version) {
_pendingRestoreVersion = version;
document.getElementById('restore-target-version').textContent = 'v' + version;
var btn = document.getElementById('restore-confirm-btn');
var btnText = document.getElementById('restore-btn-text');
btn.disabled = true;
btnText.textContent = '请等待 5 秒';
document.getElementById('restore-warning').classList.add('active');
var remaining = 5;
_restoreTimer = setInterval(function() {
remaining--;
if (remaining <= 0) {
clearInterval(_restoreTimer);
_restoreTimer = null;
btnText.textContent = '确认还原';
btn.disabled = false;
} else {
btnText.textContent = '请等待 ' + remaining + ' 秒';
}
}, 1000);
btn.onclick = function() {
if (btn.disabled) return;
doRestore();
};
}
function cancelRestore() {
if (_restoreTimer) { clearInterval(_restoreTimer); _restoreTimer = null; }
_pendingRestoreVersion = null;
document.getElementById('restore-warning').classList.remove('active');
}
async function doRestore() {
var version = _pendingRestoreVersion;
cancelRestore();
if (!version) return;
try {
var formData = new FormData();
formData.append('version', version);
formData.append('_token', CSRF_TOKEN);
var response = await fetch('/editor/' + projectId + '/restore', {
method: 'POST',
body: formData
});
var result = await response.json();
if (result.success) {
hasUnsavedChanges = false;
XCC.toast('已还原到版本 v' + version, 'success');
setTimeout(function() { window.location.reload(); }, 800);
} else {
XCC.alert('还原失败', result.error || '未知错误', 'error');
}
} catch (err) {
XCC.alert('还原失败', err.message, 'error');
}
}
// ========== 统一ESC键处理(合并为单一监听器,减少事件分发开销) ==========
document.addEventListener('keydown', function(e) {
if (e.key !== 'Escape') return;
if (document.getElementById('restore-warning').classList.contains('active')) {
cancelRestore();
} else if (document.getElementById('license-modal').classList.contains('active')) {
closeLicenseModal();
} else if (document.getElementById('safe-panel').classList.contains('active')) {
closeSafePanel();
} else if (document.getElementById('publish-modal').classList.contains('active')) {
closePublishModal();
} else if (document.getElementById('xcc-modal-backdrop').classList.contains('show')) {
XCC._close();
}
});
</script>
<script type="module" src="https://static.cloudflareinsights.com/beacon.min.js/v31edd6df95cf4e85bb4c19e7a9bdbcba1788362987495" integrity="sha512-iIg7k2xntmwu6/uSb5tpc/hySgZc4eoL31yB29W6tJFo2akwjPWcEqnCEdJvGexCL0KEQwVYv5BlowfhVz26hg==" data-cf-beacon='{"version":"2024.11.0","token":"fd99a8b0c5ed4d1dbe9dcc25a3a80c86","r":1,"spa":2}' crossorigin="anonymous"></script>
</body>
</html>