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:
parent
f6ad45745b
commit
6f02d67ecd
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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}`);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ---- */
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
Loading…
Reference in New Issue