feat: implement split-screen layout, enhanced email modal, preview panel, and WebSocket

- chat.html: Enhanced modal with email+contact fields, split-screen HTML structure,
  resizer divider, preview panel with status bar and iframe
- chat.js: Email validation (frontend regex), email prefill on repeat visits,
  split-screen enterDevMode(), draggable resizer with touch support,
  preview panel status updates, WebSocket connection for build progress
- style.css: Split-screen dev-mode layout, resizer styles, preview panel styles
  with status indicators (waiting/building/done/error), modal field styles
- backend: Preview API route, WebSocket server for progress push,
  enhanced build route with email validation and broadcast,
  enhanced email template with file list and brand signature

Co-authored-by: qinfendebingshuo <207279273+qinfendebingshuo@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-03-11 11:47:50 +00:00
parent f6ad45745b
commit 6f02d67ecd
9 changed files with 714 additions and 37 deletions

View File

@ -11,7 +11,8 @@
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"nodemailer": "^7.0.13"
"nodemailer": "^7.0.13",
"ws": "^8.18.0"
}
},
"node_modules/accepts": {
@ -872,6 +873,27 @@
"engines": {
"node": ">= 0.8"
}
},
"node_modules/ws": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View File

@ -11,6 +11,7 @@
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"nodemailer": "^7.0.13"
"nodemailer": "^7.0.13",
"ws": "^8.18.0"
}
}

View File

@ -8,9 +8,12 @@ const memoryManager = require('../brain/memory-manager');
const codeGenerator = require('../brain/code-generator');
const emailSender = require('../utils/email-sender');
// 邮箱后端正则二次校验
const EMAIL_RE = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
// POST /api/ps/build/start
router.post('/start', async (req, res) => {
const { dev_id, email, conversation } = req.body || {};
const { dev_id, email, contact, conversation } = req.body || {};
if (!dev_id || !/^EXP-\d{3,}$/.test(dev_id)) {
return res.status(400).json({
@ -28,6 +31,15 @@ router.post('/start', async (req, res) => {
});
}
// 后端二次邮箱校验
if (!EMAIL_RE.test(email)) {
return res.status(400).json({
error: true,
code: 'INVALID_EMAIL',
message: '邮箱格式不正确'
});
}
// 先立即响应,后台异步处理
res.json({
error: false,
@ -37,7 +49,16 @@ router.post('/start', async (req, res) => {
// 异步执行代码生成 + 邮件通知
(async () => {
const broadcast = req.app.locals.broadcastToClient || function () {};
try {
broadcast(dev_id, {
type: 'progress',
message: '🔧 正在创建项目骨架...',
status: 'building',
status_text: '构建中'
});
const result = await codeGenerator.generate({
dev_id,
conversation: conversation || [],
@ -46,20 +67,42 @@ router.post('/start', async (req, res) => {
// 记录项目
memoryManager.addProject(dev_id, {
name: result.projectName || 'untitled',
email: email,
contact: contact || null,
status: 'completed',
created_at: new Date().toISOString(),
files: result.files || []
});
// 自动提取知识
memoryManager.autoExtractKnowledge(dev_id, conversation, result.projectName);
// 通知预览就绪
broadcast(dev_id, {
type: 'preview_ready',
project: result.projectName,
message: '✅ 预览已就绪'
});
broadcast(dev_id, {
type: 'complete',
message: '🎉 全部完成!邮件正在发送'
});
// 发邮件
await emailSender.sendCompletion({
to: email,
dev_id,
projectName: result.projectName,
summary: result.summary
summary: result.summary,
files: result.files
});
} catch (err) {
console.error('Build pipeline error:', err.message);
broadcast(dev_id, {
type: 'error',
message: '构建过程出错: ' + err.message
});
}
})();
});

View File

@ -0,0 +1,72 @@
/**
* persona-studio · 预览 API
* GET /api/ps/preview/:devId/:project 提供 iframe 实时预览
*/
const express = require('express');
const router = express.Router();
const path = require('path');
const fs = require('fs');
const WORKSPACE_DIR = path.join(__dirname, '..', '..', 'workspace');
// GET /api/ps/preview/:devId/:project
router.get('/:devId/:project', (req, res) => {
const { devId, project } = req.params;
if (!devId || !project) {
return res.status(400).json({
error: true,
code: 'MISSING_PARAMS',
message: '缺少必要参数'
});
}
// 安全校验:防止路径遍历
const safeDevId = path.basename(devId);
const safeProject = path.basename(project);
const previewDir = path.join(WORKSPACE_DIR, safeDevId, safeProject, 'preview');
const projectDir = path.join(WORKSPACE_DIR, safeDevId, safeProject);
// 优先从 preview/ 子目录查找
let targetDir = previewDir;
if (!fs.existsSync(previewDir)) {
targetDir = projectDir;
}
const indexPath = path.join(targetDir, 'index.html');
if (!fs.existsSync(indexPath)) {
return res.status(404).send(
'<html><body style="background:#0a0e1a;color:#94a3b8;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif">' +
'<div style="text-align:center"><p style="font-size:2rem">🌊</p><p>预览正在准备中…</p></div>' +
'</body></html>'
);
}
res.sendFile(indexPath);
});
// GET /api/ps/preview/:devId/:project/:file (sub-resources like CSS/JS)
router.get('/:devId/:project/:file', (req, res) => {
const { devId, project, file } = req.params;
const safeDevId = path.basename(devId);
const safeProject = path.basename(project);
const safeFile = path.basename(file);
const previewDir = path.join(WORKSPACE_DIR, safeDevId, safeProject, 'preview');
const projectDir = path.join(WORKSPACE_DIR, safeDevId, safeProject);
let targetDir = previewDir;
if (!fs.existsSync(previewDir)) {
targetDir = projectDir;
}
const filePath = path.join(targetDir, safeFile);
if (!fs.existsSync(filePath)) {
return res.status(404).send('File not found');
}
res.sendFile(filePath);
});
module.exports = router;

View File

@ -2,12 +2,14 @@ require('dotenv').config();
const express = require('express');
const cors = require('cors');
const path = require('path');
const http = require('http');
const authRoutes = require('./routes/auth');
const chatRoutes = require('./routes/chat');
const buildRoutes = require('./routes/build');
const notifyRoutes = require('./routes/notify');
const apikeyRoutes = require('./routes/apikey');
const previewRoutes = require('./routes/preview');
const app = express();
app.use(cors());
@ -22,6 +24,7 @@ app.use('/api/ps/chat', chatRoutes);
app.use('/api/ps/build', buildRoutes);
app.use('/api/ps/notify', notifyRoutes);
app.use('/api/ps/apikey', apikeyRoutes);
app.use('/api/ps/preview', previewRoutes);
// ── 健康检查 ──
app.get('/api/ps/health', (_req, res) => {
@ -48,7 +51,7 @@ app.get('/', (_req, res) => {
res.json({
status: 'ok',
message: 'Persona Studio 后端服务运行中',
version: '1.0.0',
version: '2.0.0',
routes: [
'/api/ps/auth/login',
'/api/ps/chat/message',
@ -57,6 +60,7 @@ app.get('/', (_req, res) => {
'/api/ps/notify/send',
'/api/ps/apikey/detect-models',
'/api/ps/apikey/chat',
'/api/ps/preview/:devId/:project',
'/api/ps/health'
]
});
@ -64,7 +68,59 @@ app.get('/', (_req, res) => {
const PORT = process.env.PS_PORT || 3002;
app.listen(PORT, () => {
// ── WebSocket 服务(预览进度推送) ──
const server = http.createServer(app);
// WebSocket clients map: dev_id -> Set<ws>
const wsClients = new Map();
try {
const WebSocket = require('ws');
const wss = new WebSocket.Server({ server, path: '/ws/preview' });
wss.on('connection', (ws, req) => {
const url = new URL(req.url, 'http://localhost');
const devId = url.searchParams.get('dev_id') || 'unknown';
if (!wsClients.has(devId)) {
wsClients.set(devId, new Set());
}
wsClients.get(devId).add(ws);
ws.on('close', () => {
const clients = wsClients.get(devId);
if (clients) {
clients.delete(ws);
if (clients.size === 0) wsClients.delete(devId);
}
});
ws.on('error', () => {
const clients = wsClients.get(devId);
if (clients) {
clients.delete(ws);
if (clients.size === 0) wsClients.delete(devId);
}
});
});
// Export broadcast function for other modules
app.locals.broadcastToClient = function (devId, data) {
const clients = wsClients.get(devId);
if (!clients) return;
const msg = typeof data === 'string' ? data : JSON.stringify(data);
clients.forEach(function (ws) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(msg);
}
});
};
} catch (_e) {
// ws module not installed, WebSocket disabled
app.locals.broadcastToClient = function () {};
}
server.listen(PORT, () => {
console.log(`🌊 Persona Studio 后端服务启动 · 端口 ${PORT}`);
});

View File

@ -50,21 +50,51 @@ async function send({ to, subject, body }) {
/**
* 发送开发完成通知
*/
async function sendCompletion({ to, dev_id, projectName, summary }) {
async function sendCompletion({ to, dev_id, projectName, summary, files, downloadUrl }) {
const subject = `✅ 你的模块已完成 · ${projectName}`;
// 生成文件列表
let fileListHtml = '';
if (files && files.length > 0) {
fileListHtml = '<h3>📁 文件列表</h3><ul style="color:#e2e8f0;padding-left:20px">';
files.forEach(function (f) {
fileListHtml += '<li style="margin:4px 0">' + f + '</li>';
});
fileListHtml += '</ul>';
}
// 下载链接
let downloadHtml = '';
if (downloadUrl) {
downloadHtml = '<p><a href="' + downloadUrl + '" style="display:inline-block;padding:10px 24px;background:linear-gradient(135deg,#3b82f6,#22d3ee);color:#fff;text-decoration:none;border-radius:8px;font-weight:600">📦 下载项目包</a></p>';
}
const body = [
'<div style="font-family:sans-serif;max-width:600px;margin:0 auto;padding:20px">',
'<h2 style="color:#0969da">🌊 光湖 Persona Studio</h2>',
'<hr>',
`<p>你好 ${dev_id}</p>`,
`<p>你的项目 <strong>${projectName}</strong> 已经完成开发!</p>`,
'<div style="font-family:sans-serif;max-width:600px;margin:0 auto;padding:20px;background:#0f172a;color:#e2e8f0;border-radius:12px">',
'<div style="text-align:center;padding:20px 0;border-bottom:1px solid #334155">',
'<h2 style="color:#60a5fa;margin:0">🌊 光湖 Persona Studio</h2>',
'<p style="color:#94a3b8;font-size:14px;margin:8px 0 0">HoloLake Era · AGE OS · 人格语言操作系统</p>',
'</div>',
'<div style="padding:20px 0">',
'<p>你好 ' + dev_id + '</p>',
'<p>你的项目 <strong style="color:#22d3ee">' + projectName + '</strong> 已经完成开发!</p>',
'<h3>📋 开发摘要</h3>',
`<p>${summary || '项目代码已生成'}</p>`,
'<hr>',
'<p style="color:#656d76;font-size:12px">',
'光湖语言人格系统 · HoloLake Era · AGE OS<br>',
'此邮件由铸渊自动发送',
'<p>' + (summary || '项目代码已生成') + '</p>',
fileListHtml,
downloadHtml,
'<h3>📖 使用说明</h3>',
'<ol style="padding-left:20px">',
'<li>下载项目包并解压</li>',
'<li>在浏览器中打开 index.html 查看效果</li>',
'<li>如需修改,用任意代码编辑器打开项目文件</li>',
'</ol>',
'</div>',
'<div style="border-top:1px solid #334155;padding:16px 0;text-align:center">',
'<p style="color:#64748b;font-size:12px;margin:0">',
'🌀 铸渊 · 代码守护人格体 · 自动发送<br>',
'光湖语言人格系统 · HoloLake Era · AGE OS',
'</p>',
'</div>',
'</div>'
].join('\n');

View File

@ -7,7 +7,7 @@
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="chat-layout">
<div class="chat-layout" id="chatLayout">
<!-- ── 左侧边栏 ── -->
<aside class="chat-sidebar" id="chatSidebar">
<div class="sidebar-header">
@ -59,8 +59,8 @@
</div>
</aside>
<!-- ── 主聊天区域 ── -->
<div class="chat-main">
<!-- ── 主聊天区域(分屏左侧) ── -->
<div class="chat-main" id="chatMain">
<header class="chat-header">
<div class="header-left">
<button class="sidebar-toggle" onclick="toggleSidebar()" title="展开侧栏" id="sidebarToggleBtn"></button>
@ -106,20 +106,54 @@
</div>
</div>
</div>
<!-- ── 可拖拽分割线 ── -->
<div class="resizer" id="resizer" style="display:none;"></div>
<!-- ── 右侧预览面板 ── -->
<div class="preview-panel" id="previewPanel" style="display:none;">
<div class="preview-header">
<div class="preview-title">
<span class="preview-icon">🌊</span>
<span>实时预览 · <span id="previewProjectName">项目</span></span>
</div>
<div class="preview-status" id="previewStatus">
<span class="status-dot status-waiting"></span>
<span class="status-text">等待中</span>
</div>
<div class="preview-actions">
<button class="preview-btn" onclick="refreshPreview()" title="刷新预览">↻ 刷新</button>
<button class="preview-btn" onclick="openPreviewNewWindow()" title="新窗口打开">↗ 新窗口</button>
</div>
</div>
<iframe id="previewFrame" class="preview-iframe" sandbox="allow-scripts allow-same-origin"></iframe>
</div>
</div>
<!-- Email Modal -->
<!-- Email Modal (增强版:邮箱+联系方式+二次校验) -->
<div id="emailModal" class="modal" style="display:none;">
<div class="modal-content">
<h3>📧 请填写模块开发完成后发送的邮箱</h3>
<input
type="email"
id="emailInput"
placeholder="your@email.com"
required
/>
<h3>📧 开发完成后,代码将发送到你的邮箱</h3>
<div class="modal-field">
<label class="modal-label" for="emailInput">邮箱地址 <span class="required">*</span></label>
<input
type="email"
id="emailInput"
placeholder="your@email.com"
required
/>
<div class="modal-error" id="emailError" style="display:none;"></div>
</div>
<div class="modal-field">
<label class="modal-label" for="contactInput">联系方式 <span class="optional">(选填)</span></label>
<input
type="text"
id="contactInput"
placeholder="微信号 / 手机号(选填)"
/>
</div>
<div class="modal-actions">
<button class="btn-primary" onclick="confirmBuild()">确定</button>
<button class="btn-primary" onclick="confirmBuild()"></button>
<button class="btn-secondary" onclick="closeEmailModal()">取消</button>
</div>
</div>

View File

@ -584,21 +584,65 @@ function appendMessage(role, content) {
/* ---- Build Flow ---- */
function handleBuild() {
document.getElementById('emailModal').style.display = 'flex';
document.getElementById('emailInput').focus();
var modal = document.getElementById('emailModal');
var emailInput = document.getElementById('emailInput');
var contactInput = document.getElementById('contactInput');
var errorDiv = document.getElementById('emailError');
// 预填已存储的邮箱
var savedEmail = sessionStorage.getItem('ps_build_email') || '';
var savedContact = sessionStorage.getItem('ps_build_contact') || '';
if (savedEmail) emailInput.value = savedEmail;
if (savedContact) contactInput.value = savedContact;
errorDiv.style.display = 'none';
modal.style.display = 'flex';
emailInput.focus();
}
function closeEmailModal() {
document.getElementById('emailModal').style.display = 'none';
document.getElementById('emailError').style.display = 'none';
}
/**
* 后端二次校验用的邮箱正则
*/
function validateEmail(email) {
var re = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
return re.test(email);
}
async function confirmBuild() {
var email = document.getElementById('emailInput').value.trim();
if (!email) return;
var emailInput = document.getElementById('emailInput');
var contactInput = document.getElementById('contactInput');
var errorDiv = document.getElementById('emailError');
var email = emailInput.value.trim();
var contact = contactInput.value.trim();
// 前端校验
if (!email) {
errorDiv.textContent = '请填写邮箱地址';
errorDiv.style.display = 'block';
return;
}
if (!validateEmail(email)) {
errorDiv.textContent = '邮箱格式不正确,请检查';
errorDiv.style.display = 'block';
return;
}
// 存储邮箱(下次预填)
sessionStorage.setItem('ps_build_email', email);
if (contact) sessionStorage.setItem('ps_build_contact', contact);
closeEmailModal();
appendMessage('system', '🚀 开发任务已提交,完成后会发送到 ' + email);
// 进入分屏模式
enterDevMode();
try {
await fetch(API_BASE + '/api/ps/build/start', {
method: 'POST',
@ -606,12 +650,227 @@ async function confirmBuild() {
body: JSON.stringify({
dev_id: DEV_ID,
email: email,
contact: contact,
conversation: conversationHistory
})
});
} catch (_err) {
appendMessage('system', '任务提交失败,请稍后再试');
}
// 连接 WebSocket 获取进度更新
connectPreviewWebSocket();
}
/* ---- Dev Mode: Split Screen ---- */
var isDevMode = false;
var wsConnection = null;
var currentPreviewUrl = '';
function enterDevMode() {
if (isDevMode) return;
isDevMode = true;
var layout = document.getElementById('chatLayout');
var resizer = document.getElementById('resizer');
var previewPanel = document.getElementById('previewPanel');
layout.classList.add('dev-mode');
resizer.style.display = 'block';
previewPanel.style.display = 'flex';
// 初始各占50%
var chatMain = document.getElementById('chatMain');
chatMain.style.flex = '1 1 50%';
previewPanel.style.flex = '1 1 50%';
updatePreviewStatus('waiting', '等待中');
initResizer();
}
function exitDevMode() {
isDevMode = false;
var layout = document.getElementById('chatLayout');
var resizer = document.getElementById('resizer');
var previewPanel = document.getElementById('previewPanel');
var chatMain = document.getElementById('chatMain');
layout.classList.remove('dev-mode');
resizer.style.display = 'none';
previewPanel.style.display = 'none';
chatMain.style.flex = '';
if (wsConnection) {
wsConnection.close();
wsConnection = null;
}
}
/* ---- Draggable Resizer ---- */
function initResizer() {
var resizer = document.getElementById('resizer');
var chatMain = document.getElementById('chatMain');
var previewPanel = document.getElementById('previewPanel');
var layout = document.getElementById('chatLayout');
var startX, startChatWidth, startPreviewWidth;
function onMouseDown(e) {
e.preventDefault();
startX = e.clientX;
startChatWidth = chatMain.getBoundingClientRect().width;
startPreviewWidth = previewPanel.getBoundingClientRect().width;
resizer.classList.add('resizing');
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
function onMouseMove(e) {
var dx = e.clientX - startX;
var layoutWidth = layout.getBoundingClientRect().width;
var sidebarWidth = document.getElementById('chatSidebar').getBoundingClientRect().width;
var resizerWidth = resizer.getBoundingClientRect().width;
var available = layoutWidth - sidebarWidth - resizerWidth;
var newChatWidth = startChatWidth + dx;
var newPreviewWidth = startPreviewWidth - dx;
// Enforce min-width 360px
if (newChatWidth < 360) newChatWidth = 360;
if (newPreviewWidth < 360) newPreviewWidth = 360;
if (newChatWidth + newPreviewWidth > available) return;
chatMain.style.flex = '0 0 ' + newChatWidth + 'px';
previewPanel.style.flex = '0 0 ' + newPreviewWidth + 'px';
}
function onMouseUp() {
resizer.classList.remove('resizing');
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
resizer.addEventListener('mousedown', onMouseDown);
// Touch support for mobile
resizer.addEventListener('touchstart', function (e) {
var touch = e.touches[0];
startX = touch.clientX;
startChatWidth = chatMain.getBoundingClientRect().width;
startPreviewWidth = previewPanel.getBoundingClientRect().width;
resizer.classList.add('resizing');
function onTouchMove(ev) {
var t = ev.touches[0];
var fakeEvent = { clientX: t.clientX };
onMouseMove(fakeEvent);
}
function onTouchEnd() {
resizer.classList.remove('resizing');
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
}
document.addEventListener('touchmove', onTouchMove);
document.addEventListener('touchend', onTouchEnd);
});
}
/* ---- Preview Panel ---- */
function updatePreviewStatus(status, text) {
var statusEl = document.getElementById('previewStatus');
if (!statusEl) return;
var dotClass = 'status-dot status-' + status;
statusEl.innerHTML = '<span class="' + dotClass + '"></span><span class="status-text">' + escapeHtml(text) + '</span>';
}
function updatePreviewUrl(url) {
currentPreviewUrl = url;
var frame = document.getElementById('previewFrame');
if (frame) frame.src = url;
}
function refreshPreview() {
var frame = document.getElementById('previewFrame');
if (frame && frame.src) {
frame.src = frame.src;
}
}
function openPreviewNewWindow() {
if (currentPreviewUrl) {
window.open(currentPreviewUrl, '_blank');
}
}
function setPreviewProjectName(name) {
var el = document.getElementById('previewProjectName');
if (el) el.textContent = name || '项目';
}
/* ---- WebSocket for Preview Updates ---- */
function connectPreviewWebSocket() {
var wsBase = API_BASE.replace(/^http/, 'ws');
var wsUrl = wsBase + '/ws/preview?dev_id=' + encodeURIComponent(DEV_ID);
try {
wsConnection = new WebSocket(wsUrl);
wsConnection.onopen = function () {
appendMessage('system', '🔧 正在创建项目骨架...');
updatePreviewStatus('building', '构建中');
};
wsConnection.onmessage = function (event) {
try {
var data = JSON.parse(event.data);
if (data.type === 'progress') {
appendMessage('system', data.message || '构建进度更新');
if (data.status) {
updatePreviewStatus(data.status, data.status_text || '');
}
}
if (data.type === 'preview_ready') {
var previewUrl = API_BASE + '/api/ps/preview/' + encodeURIComponent(DEV_ID) + '/' + encodeURIComponent(data.project);
updatePreviewUrl(previewUrl);
setPreviewProjectName(data.project);
updatePreviewStatus('done', '完成');
appendMessage('system', '✅ 预览已就绪,右侧可以查看');
}
if (data.type === 'reload') {
refreshPreview();
}
if (data.type === 'complete') {
updatePreviewStatus('done', '全部完成');
appendMessage('system', '🎉 全部完成!邮件正在发送');
}
if (data.type === 'error') {
updatePreviewStatus('error', '出错');
appendMessage('system', '❌ ' + (data.message || '构建出错'));
}
} catch (_e) { /* ignore malformed WS message */ }
};
wsConnection.onerror = function () {
// WebSocket not available, graceful degradation
updatePreviewStatus('waiting', '离线模式');
};
wsConnection.onclose = function () {
wsConnection = null;
};
} catch (_e) {
// WebSocket connection failed, graceful degradation
updatePreviewStatus('waiting', '离线模式');
}
}
/* ---- Logout ---- */

View File

@ -1205,7 +1205,34 @@ body {
color: var(--text);
}
.modal-content input[type="email"] {
.modal-field {
margin-bottom: 1rem;
}
.modal-label {
display: block;
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 0.4rem;
}
.modal-label .required {
color: #f87171;
}
.modal-label .optional {
color: var(--text-muted);
font-size: 0.8rem;
}
.modal-error {
color: #f87171;
font-size: 0.8rem;
margin-top: 0.3rem;
}
.modal-content input[type="email"],
.modal-content input[type="text"] {
width: 100%;
padding: 0.7rem 1rem;
font-size: 1rem;
@ -1214,15 +1241,15 @@ body {
border: 2px solid var(--border);
border-radius: 8px;
outline: none;
margin-bottom: 1rem;
transition: border-color 0.2s;
}
.modal-content input[type="email"]:focus {
.modal-content input[type="email"]:focus,
.modal-content input[type="text"]:focus {
border-color: var(--primary);
}
.modal-content input[type="email"]::placeholder {
.modal-content input::placeholder {
color: var(--text-muted);
}
@ -1232,6 +1259,139 @@ body {
justify-content: flex-end;
}
/* ---- Split-screen / Dev Mode ---- */
.chat-layout.dev-mode {
display: flex;
height: 100vh;
}
.chat-layout.dev-mode .chat-main {
min-width: 360px;
border-right: 1px solid rgba(255, 255, 255, 0.1);
}
/* ---- Resizer (draggable split line) ---- */
.resizer {
width: 4px;
cursor: col-resize;
background: linear-gradient(180deg, transparent, rgba(59, 130, 246, 0.4), transparent);
flex-shrink: 0;
position: relative;
z-index: 5;
transition: background 0.2s;
}
.resizer:hover,
.resizer.resizing {
background: linear-gradient(180deg, transparent, rgba(59, 130, 246, 0.8), transparent);
width: 6px;
}
/* ---- Preview Panel ---- */
.preview-panel {
min-width: 360px;
background: #0a0e1a;
display: flex;
flex-direction: column;
overflow: hidden;
}
.preview-header {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.6rem 1rem;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.preview-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
color: var(--text);
font-weight: 500;
}
.preview-icon {
font-size: 1.1rem;
}
.preview-status {
display: flex;
align-items: center;
gap: 0.4rem;
margin-left: auto;
font-size: 0.8rem;
color: var(--text-secondary);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-dot.status-waiting {
background: #6b7280;
animation: pulse 2s ease-in-out infinite;
}
.status-dot.status-building {
background: #3b82f6;
animation: pulse 1s ease-in-out infinite;
}
.status-dot.status-done {
background: #22c55e;
}
.status-dot.status-error {
background: #ef4444;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.preview-actions {
display: flex;
gap: 0.4rem;
}
.preview-btn {
padding: 0.3rem 0.6rem;
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.preview-btn:hover {
background: var(--bg-card-hover);
color: var(--text);
border-color: var(--border-light);
}
.preview-iframe {
flex: 1;
width: calc(100% - 24px);
height: calc(100% - 24px);
border: none;
border-radius: 8px;
margin: 12px;
box-shadow: 0 0 20px rgba(59, 130, 246, 0.15);
background: #fff;
}
/* ---- Animation ---- */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }