feat: push all project code to Gitea
- modules/orders: 之秋订单系统(zhiqiu-tools) - modules/drama: AI 真人短剧工坊 - tools/bili_drama_collector: B站短剧采集器 - project-drawers: 三层抽屉记忆系统
This commit is contained in:
parent
537d3b2bbc
commit
6e0cd45f87
|
|
@ -0,0 +1,141 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>之秋 · AI 真人短剧工坊</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;background:#f5f0eb;color:#2c2c2c;min-height:100vh}
|
||||
.container{max-width:900px;margin:0 auto;padding:20px}
|
||||
.header{text-align:center;padding:40px 0 20px}
|
||||
.header h1{font-size:28px;font-weight:600;letter-spacing:2px}
|
||||
.header p{color:#888;margin-top:6px;font-size:14px}
|
||||
.modes{display:flex;gap:10px;margin:20px 0}
|
||||
.mode-btn{flex:1;padding:14px;border:2px solid #ddd;border-radius:12px;background:#fff;cursor:pointer;text-align:center;transition:all .2s;font-size:14px}
|
||||
.mode-btn:hover{border-color:#c8a882}
|
||||
.mode-btn.active{border-color:#b8916e;background:#faf5ef;font-weight:600}
|
||||
.mode-btn .icon{font-size:22px;display:block;margin-bottom:4px}
|
||||
.mode-btn .desc{font-size:12px;color:#999;margin-top:4px}
|
||||
.input-area{background:#fff;border-radius:12px;padding:20px;box-shadow:0 2px 8px rgba(0,0,0,.06)}
|
||||
.input-area textarea{width:100%;min-height:120px;border:1px solid #e0ddd8;border-radius:8px;padding:12px;font-size:14px;line-height:1.6;resize:vertical;font-family:inherit}
|
||||
.input-area textarea:focus{outline:none;border-color:#b8916e}
|
||||
.btn-row{display:flex;gap:10px;margin-top:12px}
|
||||
.btn-gen{flex:1;padding:12px;background:#b8916e;color:#fff;border:none;border-radius:8px;font-size:15px;cursor:pointer;transition:background .2s}
|
||||
.btn-gen:hover{background:#a07d5c}
|
||||
.btn-gen:disabled{background:#ccc;cursor:not-allowed}
|
||||
.btn-clear{padding:12px 20px;background:#f0ece6;border:1px solid #ddd;border-radius:8px;cursor:pointer;color:#666}
|
||||
.loading{display:none;text-align:center;padding:30px 0;color:#b8916e}
|
||||
.loading.show{display:block}
|
||||
.loading .spinner{display:inline-block;width:20px;height:20px;border:2px solid #e0ddd8;border-top-color:#b8916e;border-radius:50%;animation:spin .8s linear infinite;margin-right:8px;vertical-align:middle}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.result{display:none;background:#fff;border-radius:12px;padding:24px;margin-top:16px;box-shadow:0 2px 8px rgba(0,0,0,.06);white-space:pre-wrap;line-height:1.8;font-size:14px}
|
||||
.result.show{display:block}
|
||||
.result .meta{color:#999;font-size:12px;margin-bottom:10px;padding-bottom:10px;border-bottom:1px solid #f0ece6}
|
||||
.hint{text-align:center;color:#bbb;font-size:13px;margin-top:40px;padding-bottom:20px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🎬 之秋 · 真人短剧工坊</h1>
|
||||
<p>AI 帮你写剧本 · 三种创作模式</p>
|
||||
</div>
|
||||
|
||||
<div class="modes" id="modeSelector">
|
||||
<div class="mode-btn active" data-mode="auto">
|
||||
<span class="icon">🤖</span>
|
||||
AI 全自动
|
||||
<div class="desc">给主题,出完整剧本</div>
|
||||
</div>
|
||||
<div class="mode-btn" data-mode="assist">
|
||||
<span class="icon">💡</span>
|
||||
想法辅助
|
||||
<div class="desc">你提想法,AI 展开</div>
|
||||
</div>
|
||||
<div class="mode-btn" data-mode="polish">
|
||||
<span class="icon">✨</span>
|
||||
AI 润色
|
||||
<div class="desc">已有草稿,优化提升</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-area">
|
||||
<textarea id="inputText" placeholder="请输入…… 例如:都市爱情,追妻火葬场,女主是咖啡店主理人" rows="5"></textarea>
|
||||
<div class="btn-row">
|
||||
<button class="btn-gen" id="btnGen">🎬 生成剧本</button>
|
||||
<button class="btn-clear" id="btnClear">清空</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="loading" id="loading">
|
||||
<span class="spinner"></span>AI 正在创作中,请稍候…
|
||||
</div>
|
||||
|
||||
<div class="result" id="result">
|
||||
<div class="meta">✍️ 生成完毕</div>
|
||||
<div id="resultContent"></div>
|
||||
</div>
|
||||
|
||||
<div class="hint">生成内容由 AI 创作 · 仅供参考</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const modeBtns = document.querySelectorAll('.mode-btn');
|
||||
let currentMode = 'auto';
|
||||
|
||||
modeBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
modeBtns.forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
currentMode = btn.dataset.mode;
|
||||
// 根据模式调整 placeholder
|
||||
const ta = document.getElementById('inputText');
|
||||
if (currentMode === 'auto') ta.placeholder = '请输入主题……\n\n例如:都市爱情,追妻火葬场,女主是咖啡店主理人';
|
||||
else if (currentMode === 'assist') ta.placeholder = '请输入你的想法……\n\n例如:女主是咖啡店主理人,偶遇前男友,两人在店里有一场对话';
|
||||
else ta.placeholder = '粘贴你的剧本草稿……\n\nAI 会润色优化,不改核心情节';
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('btnClear').addEventListener('click', () => {
|
||||
document.getElementById('inputText').value = '';
|
||||
document.getElementById('result').classList.remove('show');
|
||||
});
|
||||
|
||||
document.getElementById('btnGen').addEventListener('click', async () => {
|
||||
const input = document.getElementById('inputText').value.trim();
|
||||
if (!input) { alert('请先输入内容'); return; }
|
||||
|
||||
const btn = document.getElementById('btnGen');
|
||||
const loading = document.getElementById('loading');
|
||||
const result = document.getElementById('result');
|
||||
const content = document.getElementById('resultContent');
|
||||
|
||||
btn.disabled = true;
|
||||
loading.classList.add('show');
|
||||
result.classList.remove('show');
|
||||
|
||||
try {
|
||||
const res = await fetch('api/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: currentMode, input })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
content.textContent = data.content;
|
||||
result.classList.add('show');
|
||||
} else {
|
||||
alert('生成失败:' + (data.error || '未知错误'));
|
||||
}
|
||||
} catch (e) {
|
||||
alert('网络错误:' + e.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
loading.classList.remove('show');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>之秋 · AI 真人短剧工坊</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;background:#f5f0eb;color:#2c2c2c;min-height:100vh}
|
||||
.container{max-width:900px;margin:0 auto;padding:20px}
|
||||
.header{text-align:center;padding:40px 0 20px}
|
||||
.header h1{font-size:28px;font-weight:600;letter-spacing:2px}
|
||||
.header p{color:#888;margin-top:6px;font-size:14px}
|
||||
.modes{display:flex;gap:10px;margin:20px 0}
|
||||
.mode-btn{flex:1;padding:14px;border:2px solid #ddd;border-radius:12px;background:#fff;cursor:pointer;text-align:center;transition:all .2s;font-size:14px}
|
||||
.mode-btn:hover{border-color:#c8a882}
|
||||
.mode-btn.active{border-color:#b8916e;background:#faf5ef;font-weight:600}
|
||||
.mode-btn .icon{font-size:22px;display:block;margin-bottom:4px}
|
||||
.mode-btn .desc{font-size:12px;color:#999;margin-top:4px}
|
||||
.input-area{background:#fff;border-radius:12px;padding:20px;box-shadow:0 2px 8px rgba(0,0,0,.06)}
|
||||
.input-area textarea{width:100%;min-height:120px;border:1px solid #e0ddd8;border-radius:8px;padding:12px;font-size:14px;line-height:1.6;resize:vertical;font-family:inherit}
|
||||
.input-area textarea:focus{outline:none;border-color:#b8916e}
|
||||
.btn-row{display:flex;gap:10px;margin-top:12px}
|
||||
.btn-gen{flex:1;padding:12px;background:#b8916e;color:#fff;border:none;border-radius:8px;font-size:15px;cursor:pointer;transition:background .2s}
|
||||
.btn-gen:hover{background:#a07d5c}
|
||||
.btn-gen:disabled{background:#ccc;cursor:not-allowed}
|
||||
.btn-clear{padding:12px 20px;background:#f0ece6;border:1px solid #ddd;border-radius:8px;cursor:pointer;color:#666}
|
||||
.loading{display:none;text-align:center;padding:30px 0;color:#b8916e}
|
||||
.loading.show{display:block}
|
||||
.loading .spinner{display:inline-block;width:20px;height:20px;border:2px solid #e0ddd8;border-top-color:#b8916e;border-radius:50%;animation:spin .8s linear infinite;margin-right:8px;vertical-align:middle}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.result{display:none;background:#fff;border-radius:12px;padding:24px;margin-top:16px;box-shadow:0 2px 8px rgba(0,0,0,.06);white-space:pre-wrap;line-height:1.8;font-size:14px}
|
||||
.result.show{display:block}
|
||||
.result .meta{color:#999;font-size:12px;margin-bottom:10px;padding-bottom:10px;border-bottom:1px solid #f0ece6}
|
||||
.hint{text-align:center;color:#bbb;font-size:13px;margin-top:40px;padding-bottom:20px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🎬 之秋 · 真人短剧工坊</h1>
|
||||
<p>AI 帮你写剧本 · 三种创作模式</p>
|
||||
</div>
|
||||
|
||||
<div class="modes" id="modeSelector">
|
||||
<div class="mode-btn active" data-mode="auto">
|
||||
<span class="icon">🤖</span>
|
||||
AI 全自动
|
||||
<div class="desc">给主题,出完整剧本</div>
|
||||
</div>
|
||||
<div class="mode-btn" data-mode="assist">
|
||||
<span class="icon">💡</span>
|
||||
想法辅助
|
||||
<div class="desc">你提想法,AI 展开</div>
|
||||
</div>
|
||||
<div class="mode-btn" data-mode="polish">
|
||||
<span class="icon">✨</span>
|
||||
AI 润色
|
||||
<div class="desc">已有草稿,优化提升</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-area">
|
||||
<textarea id="inputText" placeholder="请输入…… 例如:都市爱情,追妻火葬场,女主是咖啡店主理人" rows="5"></textarea>
|
||||
<div class="btn-row">
|
||||
<button class="btn-gen" id="btnGen">🎬 生成剧本</button>
|
||||
<button class="btn-clear" id="btnClear">清空</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="loading" id="loading">
|
||||
<span class="spinner"></span>AI 正在创作中,请稍候…
|
||||
</div>
|
||||
|
||||
<div class="result" id="result">
|
||||
<div class="meta">✍️ 生成完毕</div>
|
||||
<div id="resultContent"></div>
|
||||
</div>
|
||||
|
||||
<div class="hint">生成内容由 AI 创作 · 仅供参考</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const modeBtns = document.querySelectorAll('.mode-btn');
|
||||
let currentMode = 'auto';
|
||||
|
||||
modeBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
modeBtns.forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
currentMode = btn.dataset.mode;
|
||||
// 根据模式调整 placeholder
|
||||
const ta = document.getElementById('inputText');
|
||||
if (currentMode === 'auto') ta.placeholder = '请输入主题……\n\n例如:都市爱情,追妻火葬场,女主是咖啡店主理人';
|
||||
else if (currentMode === 'assist') ta.placeholder = '请输入你的想法……\n\n例如:女主是咖啡店主理人,偶遇前男友,两人在店里有一场对话';
|
||||
else ta.placeholder = '粘贴你的剧本草稿……\n\nAI 会润色优化,不改核心情节';
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('btnClear').addEventListener('click', () => {
|
||||
document.getElementById('inputText').value = '';
|
||||
document.getElementById('result').classList.remove('show');
|
||||
});
|
||||
|
||||
document.getElementById('btnGen').addEventListener('click', async () => {
|
||||
const input = document.getElementById('inputText').value.trim();
|
||||
if (!input) { alert('请先输入内容'); return; }
|
||||
|
||||
const btn = document.getElementById('btnGen');
|
||||
const loading = document.getElementById('loading');
|
||||
const result = document.getElementById('result');
|
||||
const content = document.getElementById('resultContent');
|
||||
|
||||
btn.disabled = true;
|
||||
loading.classList.add('show');
|
||||
result.classList.remove('show');
|
||||
|
||||
try {
|
||||
const res = await fetch('api/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: currentMode, input })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
content.textContent = data.content;
|
||||
result.classList.add('show');
|
||||
} else {
|
||||
alert('生成失败:' + (data.error || '未知错误'));
|
||||
}
|
||||
} catch (e) {
|
||||
alert('网络错误:' + e.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
loading.classList.remove('show');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PORT = 3930;
|
||||
const DEEPSEEK_KEY = 'sk-a9b69e9cd2dc4ca68d6aceaa84f22afb';
|
||||
|
||||
// Prompt 模板:三种模式
|
||||
function buildPrompt(mode, input) {
|
||||
const base = `你是一位专业的真人短剧编剧。请用以下专业格式输出剧本:
|
||||
|
||||
【人物表】
|
||||
列出主要角色及简介
|
||||
|
||||
第X幕
|
||||
|
||||
场景 X · 地点 · 时间 · 内外景
|
||||
[镜头提示] 动作/场景描述
|
||||
角色名:(语气)"对白"
|
||||
角色名:动作描述
|
||||
[镜头切至] 下一个镜头
|
||||
|
||||
注意:
|
||||
- 每场标注镜头语言(中景、特写、远景、跟拍等)
|
||||
- 对白自然,符合人物性格
|
||||
- 动作描写简洁有力
|
||||
- 节奏紧凑,3-5分钟短剧体量`;
|
||||
|
||||
switch (mode) {
|
||||
case 'auto':
|
||||
return `${base}\n\n用户提供的主题:「${input}」\n请根据这个主题创作一个完整的真人短剧剧本。`;
|
||||
case 'assist':
|
||||
return `${base}\n\n用户提供的想法:「${input}」\n请根据这个想法展开,创作一个完整的真人短剧剧本。`;
|
||||
case 'polish':
|
||||
return `${base}\n\n以下是用户提供的剧本草稿。请在不改变核心情节的前提下,优化对白、增强画面感、调整节奏:\n\n${input}`;
|
||||
default:
|
||||
return `${base}\n\n用户输入:「${input}」`;
|
||||
}
|
||||
}
|
||||
|
||||
// 简易 HTML 注入
|
||||
function serveStatic(res, filePath) {
|
||||
const fullPath = path.join(__dirname, 'public', filePath);
|
||||
try {
|
||||
const data = fs.readFileSync(fullPath, 'utf-8');
|
||||
const ext = path.extname(filePath);
|
||||
const mime = { '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript' }[ext] || 'text/plain';
|
||||
res.writeHead(200, { 'Content-Type': mime });
|
||||
res.end(data);
|
||||
} catch {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
}
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://localhost:${PORT}`);
|
||||
const pathname = url.pathname;
|
||||
|
||||
// CORS
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; }
|
||||
|
||||
// 静态文件
|
||||
if (pathname === '/' || pathname === '/index.html') return serveStatic(res, 'index.html');
|
||||
|
||||
// 生成剧本 API
|
||||
if (pathname === '/api/generate' && req.method === 'POST') {
|
||||
let body = '';
|
||||
req.on('data', c => body += c);
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
const { mode, input } = JSON.parse(body);
|
||||
if (!input || input.trim().length < 2) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: '输入内容至少 2 个字符' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const prompt = buildPrompt(mode || 'auto', input);
|
||||
|
||||
// 调 DeepSeek API
|
||||
const apiRes = await fetch('https://api.deepseek.com/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${DEEPSEEK_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'deepseek-chat',
|
||||
messages: [
|
||||
{ role: 'system', content: '你是一位专业的真人短剧编剧。输出格式严格遵循用户要求的剧本格式。' },
|
||||
{ role: 'user', content: prompt }
|
||||
],
|
||||
temperature: 0.8,
|
||||
max_tokens: 4096
|
||||
})
|
||||
});
|
||||
|
||||
const data = await apiRes.json();
|
||||
const content = data.choices?.[0]?.message?.content || '生成失败,请重试';
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, content }));
|
||||
} catch (e) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: e.message }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
});
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`[drama] 启动成功 http://0.0.0.0:${PORT}`);
|
||||
});
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
node_modules/
|
||||
data/
|
||||
server.log
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
data/
|
||||
server.log
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p data && chmod 777 data
|
||||
|
||||
ENV ZQ_PORT=3920
|
||||
ENV DEEPSEEK_API_KEY=your-api-key-here
|
||||
|
||||
EXPOSE 3920
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
# zhiqiu-tools · 之秋工具箱
|
||||
|
||||
> 人格系统相关的小工具和小项目合集。
|
||||
|
||||
## AI 接单系统
|
||||
|
||||
智能技术开发接单管理工具,支持 AI 辅助需求录入、订单编号追踪、双向沟通同步。
|
||||
|
||||
**在线试用:** [guanghuice.com/orders/submit](https://guanghuice.com/orders/submit)
|
||||
|
||||
### 功能
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 🤖 **AI 辅助录入** | 提交需求时 AI 自动分析并结构化,推荐分类和预估工时 |
|
||||
| 🛡️ **垃圾过滤** | 蜜罐检测 + IP 频率限制 + 内容模式识别,自动标记可疑订单 |
|
||||
| 📋 **编号系统** | `ZT-YYYYMMDD-NNN` 格式,子任务分层 `-XX`,精准追踪 |
|
||||
| 🔧 **管理面板** | 订单列表、状态流转、子任务拆分、可疑审核、客户沟通 |
|
||||
| 🔍 **客户追踪** | 根据订单号查询进度、查看沟通记录、留言 |
|
||||
| 💬 **双向同步** | 客户和开发者通过评论双向沟通,时间线记录所有操作 |
|
||||
| 🐳 **Docker 支持** | 一键部署,环境变量配置 |
|
||||
|
||||
### 快速部署
|
||||
|
||||
#### 方式一:Docker(推荐)
|
||||
|
||||
```bash
|
||||
# 1. 克隆
|
||||
git clone https://gitea.com/zhizhideqiuqiu/zhiqiu-tools.git
|
||||
cd zhiqiu-tools
|
||||
|
||||
# 2. 配置环境变量
|
||||
export DEEPSEEK_API_KEY=sk-your-key-here
|
||||
export ZQ_SITE_NAME="我的接单系统"
|
||||
|
||||
# 3. 启动
|
||||
docker compose up -d
|
||||
|
||||
# 4. 访问 http://localhost:3920
|
||||
```
|
||||
|
||||
#### 方式二:直接运行
|
||||
|
||||
```bash
|
||||
# 需要 Node.js 18+
|
||||
git clone https://gitea.com/zhizhideqiuqiu/zhiqiu-tools.git
|
||||
cd zhiqiu-tools
|
||||
|
||||
# 配置 API Key(AI 功能需要)
|
||||
export DEEPSEEK_API_KEY=sk-your-key-here
|
||||
|
||||
# 启动
|
||||
node server.js
|
||||
```
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `DEEPSEEK_API_KEY` | - | DeepSeek API Key(AI 功能必需) |
|
||||
| `ZQ_PORT` | `3920` | 服务端口 |
|
||||
| `ZQ_SITE_NAME` | `之秋 · 技术开发接单` | 站点名称 |
|
||||
| `ZQ_ADMIN_TOKEN` | `` | 管理面板简单鉴权(可选) |
|
||||
| `ZQ_RATE_LIMIT` | `3` | 每 IP 每小时最多提交次数 |
|
||||
|
||||
### 垃圾过滤机制
|
||||
|
||||
系统使用三重防护对抗垃圾提交:
|
||||
|
||||
1. **蜜罐检测** — 表单中隐藏字段,正常用户不可见,机器人会自动填写
|
||||
2. **频率限制** — 同一 IP 每小时最多提交 3 次
|
||||
3. **内容分析** — 检测无意义字符、重复内容等异常模式
|
||||
|
||||
被标记的订单会在管理面板显示 ⚠️ 标识,管理员可以审核后解除标记或删除。
|
||||
|
||||
### 访问路径
|
||||
|
||||
| 页面 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| 📝 客户提交 | `/submit` | 公开,任何人可提交需求 |
|
||||
| 🔧 管理面板 | `/admin` | 管理员管理订单 |
|
||||
| 🔍 订单追踪 | `/track` | 客户凭编号查询进度 |
|
||||
| 💊 健康检查 | `/api/health` | 服务状态 |
|
||||
|
||||
### 技术栈
|
||||
|
||||
- 后端:Node.js(原生 http,无框架依赖)
|
||||
- 前端:原生 HTML/CSS/JS
|
||||
- 存储:JSON 文件(无需数据库,开箱即用)
|
||||
- AI:DeepSeek API
|
||||
- 部署:Docker / 直接运行
|
||||
|
||||
### 许可证
|
||||
|
||||
MIT
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* ai-intake.js - AI 辅助需求录入
|
||||
* 使用 DeepSeek 分析用户提交的原始需求,结构化为标准表单
|
||||
*/
|
||||
const DEEPSEEK_KEY = 'sk-a9b69e9cd2dc4ca68d6aceaa84f22afb';
|
||||
|
||||
async function callDeepSeek(messages, options = {}) {
|
||||
const body = {
|
||||
model: options.model || 'deepseek-chat',
|
||||
messages,
|
||||
temperature: options.temperature ?? 0.3,
|
||||
max_tokens: options.max_tokens ?? 2000,
|
||||
stream: false,
|
||||
};
|
||||
const resp = await fetch('https://api.deepseek.com/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${DEEPSEEK_KEY}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`DeepSeek error ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
return data.choices[0].message.content;
|
||||
}
|
||||
|
||||
/** 用 AI 分析原始需求,提取结构化信息 */
|
||||
export async function analyzeRequirement(rawText) {
|
||||
const prompt = `你是一个技术项目经理助理。请分析以下客户提交的原始需求,提取关键信息并以 JSON 格式输出。
|
||||
|
||||
原始需求:
|
||||
${rawText}
|
||||
|
||||
请提取:
|
||||
1. projectType: 项目类型(网站/小程序/API/工具/其他)
|
||||
2. summary: 一句话需求摘要(20字以内)
|
||||
3. techStack: 可能需要的技术栈(数组,如 ["Node.js", "React"])
|
||||
4. complexity: 复杂度评估(简单/中等/复杂)
|
||||
5. estimatedDays: 预估工时(天数,数字)
|
||||
6. clarity: 需求清晰度(清晰/模糊/非常模糊)
|
||||
7. suggestions: 建议用户补充的信息(数组,如 ["需要明确前端框架", "需要数据库类型"])
|
||||
|
||||
只输出 JSON,不要其他文字。格式:
|
||||
{
|
||||
"projectType": "",
|
||||
"summary": "",
|
||||
"techStack": [],
|
||||
"complexity": "",
|
||||
"estimatedDays": 0,
|
||||
"clarity": "",
|
||||
"suggestions": []
|
||||
}`;
|
||||
|
||||
try {
|
||||
const result = await callDeepSeek([{ role: 'user', content: prompt }], { temperature: 0.2 });
|
||||
return JSON.parse(result.replace(/```json\s*|\s*```/g, ''));
|
||||
} catch (e) {
|
||||
console.error('AI analyze failed:', e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** AI 自动回复确认(提交后给客户看到的信息) */
|
||||
export async function generateConfirmation(order) {
|
||||
const prompt = `你是一个温和友好的客服,请根据以下订单信息生成一段确认回复,告诉客户订单已收到并给出预期。
|
||||
|
||||
客户: ${order.name}
|
||||
需求: ${order.requirements}
|
||||
订单编号: ${order.id}
|
||||
|
||||
回复要求:简短、温暖、专业。告知订单编号已生成,开发者会尽快联系。`;
|
||||
|
||||
try {
|
||||
return await callDeepSeek([{ role: 'user', content: prompt }], { temperature: 0.6, max_tokens: 500 });
|
||||
} catch {
|
||||
return `感谢您的提交!您的订单编号为 ${order.id},我们会尽快与您联系。`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* config.js - 配置中心
|
||||
* 所有可通过环境变量覆盖的配置
|
||||
*/
|
||||
export const CONFIG = {
|
||||
port: parseInt(process.env.ZQ_PORT || '3920'),
|
||||
deepseekKey: process.env.DEEPSEEK_API_KEY || 'sk-a9b69e9cd2dc4ca68d6aceaa84f22afb',
|
||||
adminToken: process.env.ZQ_ADMIN_TOKEN || '', // 可选,管理面板简单鉴权
|
||||
rateLimit: {
|
||||
maxPerIp: parseInt(process.env.ZQ_RATE_LIMIT || '3'),
|
||||
windowMinutes: 60,
|
||||
},
|
||||
siteName: process.env.ZQ_SITE_NAME || '之秋 · 技术开发接单',
|
||||
};
|
||||
|
||||
export function validateConfig() {
|
||||
const warnings = [];
|
||||
if (!CONFIG.deepseekKey || CONFIG.deepseekKey === 'your-api-key-here') {
|
||||
warnings.push('⚠️ DEEPSEEK_API_KEY 未设置,AI 辅助录入功能将不可用');
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
version: '3.8'
|
||||
services:
|
||||
zhiqiu-orders:
|
||||
build: .
|
||||
container_name: zhiqiu-orders
|
||||
ports:
|
||||
- "3920:3920"
|
||||
environment:
|
||||
- ZQ_PORT=3920
|
||||
- DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY:-your-api-key-here}
|
||||
- ZQ_SITE_NAME=${ZQ_SITE_NAME:-之秋 · 技术开发接单}
|
||||
- ZQ_ADMIN_TOKEN=${ZQ_ADMIN_TOKEN:-}
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
/**
|
||||
* order-manager.js - 订单核心管理模块
|
||||
* 编号: ZT-YYYYMMDD-NNN, 子任务: -XX
|
||||
* 状态: submitted → reviewing → accepted(开发中) → testing → completed → delivered
|
||||
* 归档: 已完成/已交付订单可移入历史
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_FILE = path.join(__dirname, 'data', 'orders.json');
|
||||
const COUNTER_FILE = path.join(__dirname, 'data', 'counter.json');
|
||||
const HISTORY_FILE = path.join(__dirname, 'data', 'history.json');
|
||||
|
||||
fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
|
||||
|
||||
function loadOrders() {
|
||||
try { return JSON.parse(fs.readFileSync(DATA_FILE, 'utf-8')); }
|
||||
catch { return []; }
|
||||
}
|
||||
function saveOrders(orders) {
|
||||
fs.writeFileSync(DATA_FILE, JSON.stringify(orders, null, 2));
|
||||
}
|
||||
function loadCounter() {
|
||||
try { return JSON.parse(fs.readFileSync(COUNTER_FILE, 'utf-8')); }
|
||||
catch { return {}; }
|
||||
}
|
||||
function saveCounter(c) {
|
||||
fs.writeFileSync(COUNTER_FILE, JSON.stringify(c, null, 2));
|
||||
}
|
||||
function loadHistory() {
|
||||
try { return JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf-8')); }
|
||||
catch { return []; }
|
||||
}
|
||||
function saveHistory(h) {
|
||||
fs.writeFileSync(HISTORY_FILE, JSON.stringify(h, null, 2));
|
||||
}
|
||||
|
||||
/** 生成编号 */
|
||||
export function nextOrderNumber() {
|
||||
const counter = loadCounter();
|
||||
const today = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||
counter[today] = (counter[today] || 0) + 1;
|
||||
saveCounter(counter);
|
||||
return `ZT-${today}-${String(counter[today]).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
/** 生成子编号 */
|
||||
export function nextSubNumber(parentId) {
|
||||
const orders = loadOrders();
|
||||
const o = orders.find(x => x.id === parentId);
|
||||
if (!o) return null;
|
||||
return `${parentId}-${String((o.subs || []).length + 1).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** 创建订单 */
|
||||
export function createOrder({ name, contact, contactType, requirements, budget, expectedDays, source }) {
|
||||
const now = new Date().toISOString();
|
||||
const id = nextOrderNumber();
|
||||
const order = {
|
||||
id, name: name || '匿名客户', contact, contactType: contactType || '微信',
|
||||
requirements: requirements || '', budget: budget || '',
|
||||
expectedDays: expectedDays || 0,
|
||||
expectedDelivery: '',
|
||||
progress: 0, progressNote: '等待接单',
|
||||
source: source || 'web',
|
||||
status: 'submitted', subs: [], comments: [],
|
||||
timeline: [{ at: now, action: 'created', detail: '订单已提交' }],
|
||||
flagged: false, flagReason: '',
|
||||
archived: false,
|
||||
createdAt: now, updatedAt: now,
|
||||
};
|
||||
const orders = loadOrders();
|
||||
orders.push(order);
|
||||
saveOrders(orders);
|
||||
return order;
|
||||
}
|
||||
|
||||
/** 获取未归档订单 */
|
||||
export function getActiveOrders() {
|
||||
return loadOrders().filter(o => !o.archived).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
}
|
||||
|
||||
/** 获取已归档订单(历史) */
|
||||
export function getArchivedOrders() {
|
||||
return loadHistory().sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
|
||||
}
|
||||
|
||||
/** 获取全部(含归档) */
|
||||
export function getAllOrders() {
|
||||
return [...loadOrders(), ...loadHistory()].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
}
|
||||
|
||||
/** 搜索 */
|
||||
export function findOrder(query) {
|
||||
const all = getAllOrders();
|
||||
return all.filter(o =>
|
||||
o.id.toLowerCase().includes(query.toLowerCase()) ||
|
||||
(o.contact || '').includes(query) ||
|
||||
(o.name || '').includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
/** 按 ID 获取(查活跃+历史) */
|
||||
export function getOrder(id) {
|
||||
return loadOrders().find(o => o.id === id) || loadHistory().find(o => o.id === id) || null;
|
||||
}
|
||||
|
||||
/** 更新状态 */
|
||||
export function updateStatus(id, status) {
|
||||
const orders = loadOrders();
|
||||
const order = orders.find(o => o.id === id);
|
||||
if (!order) return null;
|
||||
const valid = ['submitted', 'reviewing', 'accepted', 'in-progress', 'testing', 'completed', 'delivered'];
|
||||
if (!valid.includes(status)) return null;
|
||||
const now = new Date().toISOString();
|
||||
order.status = status;
|
||||
order.updatedAt = now;
|
||||
const labels = { submitted:'已提交', reviewing:'审核中', accepted:'已接单', 'in-progress':'制作中', testing:'测试中', completed:'已完成', delivered:'已交付' };
|
||||
order.timeline.push({ at: now, action: 'status', detail: `状态 → ${labels[status] || status}` });
|
||||
// 接单时算预计交付日
|
||||
if (status === 'accepted' && order.expectedDays > 0) {
|
||||
const d = new Date(); d.setDate(d.getDate() + order.expectedDays);
|
||||
order.expectedDelivery = d.toISOString().slice(0, 10);
|
||||
order.progressNote = `预计 ${order.expectedDays} 天内交付`;
|
||||
}
|
||||
saveOrders(orders);
|
||||
return order;
|
||||
}
|
||||
|
||||
/** 更新进度百分比 + 说明 */
|
||||
export function updateProgress(id, progress, note = '') {
|
||||
const orders = loadOrders();
|
||||
const order = orders.find(o => o.id === id);
|
||||
if (!order) return null;
|
||||
const now = new Date().toISOString();
|
||||
order.progress = Math.max(0, Math.min(100, progress));
|
||||
order.progressNote = note || order.progressNote;
|
||||
order.updatedAt = now;
|
||||
order.timeline.push({ at: now, action: 'progress', detail: `进度更新: ${order.progress}% - ${order.progressNote}` });
|
||||
saveOrders(orders);
|
||||
return order;
|
||||
}
|
||||
|
||||
/** 设定预期天数(接单时) */
|
||||
export function setExpectedDays(id, days) {
|
||||
const orders = loadOrders();
|
||||
const order = orders.find(o => o.id === id);
|
||||
if (!order) return null;
|
||||
order.expectedDays = days;
|
||||
const d = new Date(); d.setDate(d.getDate() + days);
|
||||
order.expectedDelivery = d.toISOString().slice(0, 10);
|
||||
order.progressNote = `预计 ${days} 天内交付`;
|
||||
order.updatedAt = new Date().toISOString();
|
||||
order.timeline.push({ at: order.updatedAt, action: 'estimate', detail: `设定预期 ${days} 天交付` });
|
||||
saveOrders(orders);
|
||||
return order;
|
||||
}
|
||||
|
||||
/** 交付(标记为已交付) */
|
||||
export function deliverOrder(id) {
|
||||
const orders = loadOrders();
|
||||
const order = orders.find(o => o.id === id);
|
||||
if (!order) return null;
|
||||
const now = new Date().toISOString();
|
||||
order.status = 'delivered';
|
||||
order.progress = 100;
|
||||
order.progressNote = '已完成并交付';
|
||||
order.updatedAt = now;
|
||||
order.timeline.push({ at: now, action: 'deliver', detail: '订单已交付给客户' });
|
||||
saveOrders(orders);
|
||||
return order;
|
||||
}
|
||||
|
||||
/** 归档订单(移入历史) */
|
||||
export function archiveOrder(id) {
|
||||
const orders = loadOrders();
|
||||
const idx = orders.findIndex(o => o.id === id);
|
||||
if (idx === -1) return null;
|
||||
const order = orders[idx];
|
||||
order.archived = true;
|
||||
order.updatedAt = new Date().toISOString();
|
||||
order.timeline.push({ at: order.updatedAt, action: 'archive', detail: '订单已归档' });
|
||||
const history = loadHistory();
|
||||
history.push(order);
|
||||
saveHistory(history);
|
||||
orders.splice(idx, 1);
|
||||
saveOrders(orders);
|
||||
return order;
|
||||
}
|
||||
|
||||
/** 子任务管理 */
|
||||
export function addSubTask(id, { title, description }) {
|
||||
const orders = loadOrders();
|
||||
const order = orders.find(o => o.id === id);
|
||||
if (!order) return null;
|
||||
const subId = nextSubNumber(id);
|
||||
const now = new Date().toISOString();
|
||||
const sub = { subId, title: title || '', description: description || '', status: 'pending', createdAt: now };
|
||||
order.subs = order.subs || [];
|
||||
order.subs.push(sub);
|
||||
order.updatedAt = now;
|
||||
order.timeline.push({ at: now, action: 'subtask', detail: `添加子任务: ${subId} - ${title}` });
|
||||
saveOrders(orders);
|
||||
return sub;
|
||||
}
|
||||
export function updateSubStatus(id, subId, status) {
|
||||
const orders = loadOrders();
|
||||
const order = orders.find(o => o.id === id);
|
||||
if (!order) return null;
|
||||
const sub = order.subs?.find(s => s.subId === subId);
|
||||
if (!sub) return null;
|
||||
sub.status = status;
|
||||
sub.updatedAt = new Date().toISOString();
|
||||
order.updatedAt = sub.updatedAt;
|
||||
order.timeline.push({ at: sub.updatedAt, action: 'subtask', detail: `子任务 ${subId} → ${status}` });
|
||||
saveOrders(orders);
|
||||
return sub;
|
||||
}
|
||||
|
||||
/** 评论 */
|
||||
export function addComment(id, { author, content, side }) {
|
||||
const orders = loadOrders();
|
||||
const order = orders.find(o => o.id === id);
|
||||
if (!order) return null;
|
||||
const now = new Date().toISOString();
|
||||
const comment = { id: `c-${Date.now()}`, author: author || '客户', content: content || '', side: side || 'client', createdAt: now };
|
||||
order.comments = order.comments || [];
|
||||
order.comments.push(comment);
|
||||
order.updatedAt = now;
|
||||
order.timeline.push({ at: now, action: 'comment', detail: `${side === 'dev' ? '开发者' : '客户'}留言` });
|
||||
saveOrders(orders);
|
||||
return comment;
|
||||
}
|
||||
|
||||
/** 标记可疑 */
|
||||
export function flagOrder(id, flagged, reason = '') {
|
||||
const orders = loadOrders();
|
||||
const order = orders.find(o => o.id === id);
|
||||
if (!order) return null;
|
||||
order.flagged = flagged;
|
||||
order.flagReason = reason;
|
||||
order.updatedAt = new Date().toISOString();
|
||||
order.timeline.push({ at: order.updatedAt, action: 'flag', detail: flagged ? `标记可疑: ${reason}` : '解除可疑标记' });
|
||||
saveOrders(orders);
|
||||
return order;
|
||||
}
|
||||
|
||||
/** 统计 */
|
||||
export function getFlaggedOrders() { return loadOrders().filter(o => o.flagged); }
|
||||
export function getRecentOrders(min = 60) {
|
||||
const cutoff = Date.now() - min * 60000;
|
||||
return loadOrders().filter(o => new Date(o.createdAt).getTime() > cutoff);
|
||||
}
|
||||
export function getPendingCount() {
|
||||
return loadOrders().filter(o => !o.archived && ['submitted', 'reviewing'].includes(o.status)).length;
|
||||
}
|
||||
|
||||
/** 删除订单 */
|
||||
export function deleteOrder(id) {
|
||||
let orders = loadOrders();
|
||||
const idx = orders.findIndex(o => o.id === id);
|
||||
if (idx !== -1) { orders.splice(idx, 1); saveOrders(orders); return true; }
|
||||
let history = loadHistory();
|
||||
const hidx = history.findIndex(o => o.id === id);
|
||||
if (hidx !== -1) { history.splice(hidx, 1); saveHistory(history); return true; }
|
||||
return false;
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "zhiqiu-order-system",
|
||||
"version": "1.0.0",
|
||||
"description": "AI 接单系统 - 客户提交需求、管理员管理订单、编号追踪双向同步",
|
||||
"main": "server.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"author": "zhizhideqiuqiu",
|
||||
"license": "MIT"
|
||||
}
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
<!DOCTYPE html><html lang="zh-CN">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>__SITE_NAME__ · 管理</title><link rel="stylesheet" href="style.css">
|
||||
<style>
|
||||
.tab-bar { display:flex;gap:4px;margin-bottom:16px; }
|
||||
.tab-bar button { flex:1;padding:10px;border:0.5px solid #d3d1c7;background:#fff;border-radius:8px;cursor:pointer;font-size:13px;color:#5f5e5a; }
|
||||
.tab-bar button.active { background:#534ab7;color:#fff;border-color:#534ab7; }
|
||||
.filter-bar { display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap;align-items:center; }
|
||||
.filter-bar input { width:200px; }
|
||||
.detail-panel { display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.3);z-index:100;overflow-y:auto; }
|
||||
.detail-panel .card { max-width:700px;margin:40px auto;position:relative; }
|
||||
.close-btn { position:absolute;top:12px;right:16px;background:none;border:none;font-size:22px;cursor:pointer;color:#888780; }
|
||||
.dev-stage { display:flex;gap:8px;margin:12px 0;flex-wrap:wrap; }
|
||||
.dev-stage button { flex:1;min-width:80px;padding:8px;border:0.5px solid #d3d1c7;background:#fff;border-radius:6px;cursor:pointer;font-size:12px;text-align:center; }
|
||||
.dev-stage button.active { background:#534ab7;color:#fff;border-color:#534ab7; }
|
||||
.dev-stage button.done { background:#eaf3de;color:#3b6d11; }
|
||||
.comment-input { display:flex;gap:8px;margin-top:8px; }
|
||||
.comment-input input { flex:1; }
|
||||
.progress-edit { display:flex;gap:8px;align-items:center;margin:8px 0; }
|
||||
.progress-edit input[type=range] { flex:1; }
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="nav">
|
||||
<a href="/submit">提交需求</a>
|
||||
<a href="/track">我的订单</a>
|
||||
<a href="/admin" class="active">管理面板</a>
|
||||
</div>
|
||||
|
||||
<div class="stats" id="stats-row">
|
||||
<div class="stat-card"><div class="num" id="s-total">0</div><div class="label">当前订单</div></div>
|
||||
<div class="stat-card"><div class="num" id="s-pending">0</div><div class="label">待处理</div></div>
|
||||
<div class="stat-card"><div class="num" id="s-active">0</div><div class="label">制作中</div></div>
|
||||
<div class="stat-card"><div class="num" id="s-history">0</div><div class="label">历史</div></div>
|
||||
</div>
|
||||
|
||||
<div class="tab-bar">
|
||||
<button id="tab-active" class="active" onclick="switchTab('active')">📋 当前订单</button>
|
||||
<button id="tab-history" onclick="switchTab('history')">📦 历史订单</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<input id="filter-q" placeholder="搜索编号/姓名/联系方式" oninput="loadOrders()">
|
||||
<select id="filter-status" onchange="loadOrders()">
|
||||
<option value="">全部状态</option>
|
||||
<option value="submitted">已提交</option>
|
||||
<option value="reviewing">审核中</option>
|
||||
<option value="accepted">已接单</option>
|
||||
<option value="in-progress">制作中</option>
|
||||
<option value="testing">测试中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="delivered">已交付</option>
|
||||
<option value="flagged">⚠️ 可疑</option>
|
||||
</select>
|
||||
<button class="small" onclick="loadOrders()">🔄 刷新</button>
|
||||
</div>
|
||||
|
||||
<div id="order-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="detail-panel" id="detail-panel" onclick="if(event.target===this)closeDetail()">
|
||||
<div class="card" id="detail-card"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const STATUS_LABEL = { submitted:'已提交', reviewing:'审核中', accepted:'已接单', 'in-progress':'制作中', testing:'测试中', completed:'已完成', delivered:'已交付' };
|
||||
const DEV_STAGES = [
|
||||
{ key:'analysis', label:'需求分析', pct:10 },
|
||||
{ key:'design', label:'方案设计', pct:25 },
|
||||
{ key:'backend', label:'后端开发', pct:45 },
|
||||
{ key:'frontend', label:'前端开发', pct:65 },
|
||||
{ key:'testing', label:'测试', pct:85 },
|
||||
{ key:'done', label:'完成', pct:100 },
|
||||
];
|
||||
let currentTab = 'active';
|
||||
|
||||
function switchTab(tab) {
|
||||
currentTab = tab;
|
||||
document.querySelectorAll('.tab-bar button').forEach(b => b.classList.remove('active'));
|
||||
document.getElementById('tab-'+tab).classList.add('active');
|
||||
loadOrders();
|
||||
}
|
||||
|
||||
async function loadOrders() {
|
||||
const el = document.getElementById('order-list');
|
||||
try {
|
||||
const url = currentTab === 'history' ? 'api/orders/archived' : 'api/orders/active';
|
||||
const r = await fetch(url);
|
||||
let orders = await r.json();
|
||||
|
||||
const q = document.getElementById('filter-q').value.trim().toLowerCase();
|
||||
const st = document.getElementById('filter-status').value;
|
||||
if (q) orders = orders.filter(o => o.id.toLowerCase().includes(q) || (o.name||'').includes(q) || (o.contact||'').includes(q));
|
||||
if (st === 'flagged') orders = orders.filter(o => o.flagged);
|
||||
else if (st) orders = orders.filter(o => o.status === st);
|
||||
|
||||
renderList(orders, el);
|
||||
updateStats();
|
||||
} catch(e) { el.innerHTML = `<p class="msg error">加载失败: ${e.message}</p>`; }
|
||||
}
|
||||
|
||||
function renderList(orders, el) {
|
||||
if (!orders.length) { el.innerHTML = '<p style="color:#888780;text-align:center;padding:40px;">暂无订单</p>'; return; }
|
||||
el.innerHTML = orders.map(o => `
|
||||
<div class="order-card" onclick="openDetail('${o.id}')">
|
||||
<div class="oh">
|
||||
<div><span class="id">${o.id}</span> <strong>${o.name||''}</strong>
|
||||
${o.flagged ? '<span style="color:#e24b4a;font-size:11px;">⚠️</span>' : ''}
|
||||
${o.archived ? '<span style="color:#888780;font-size:11px;">📦</span>' : ''}
|
||||
</div>
|
||||
<div><span class="status status-${o.status}">${STATUS_LABEL[o.status]||o.status}</span></div>
|
||||
</div>
|
||||
<div style="font-size:12px;color:#5f5e5a;margin-top:4px;">${(o.requirements||'').slice(0,80)}${(o.requirements||'').length>80?'...':''}</div>
|
||||
<div style="font-size:11px;color:#888780;margin-top:4px;display:flex;gap:12px;">
|
||||
<span>📞 ${o.contactType||''}: ${o.contact||''}</span>
|
||||
<span>📅 ${new Date(o.createdAt).toLocaleDateString()}</span>
|
||||
${o.expectedDays ? `<span>⏱ ${o.expectedDays}天</span>` : ''}
|
||||
<span>📊 ${o.progress||0}%</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function updateStats() {
|
||||
const r = await fetch('api/orders/active');
|
||||
const active = await r.json();
|
||||
const rh = await fetch('api/orders/archived');
|
||||
const history = await rh.json();
|
||||
document.getElementById('s-total').textContent = active.length;
|
||||
document.getElementById('s-pending').textContent = active.filter(o => ['submitted','reviewing'].includes(o.status)).length;
|
||||
document.getElementById('s-active').textContent = active.filter(o => ['accepted','in-progress','testing'].includes(o.status)).length;
|
||||
document.getElementById('s-history').textContent = history.length;
|
||||
}
|
||||
|
||||
async function openDetail(id) {
|
||||
const r = await fetch(`api/orders/search?q=${id}`);
|
||||
const orders = await r.json();
|
||||
const o = orders.find(x => x.id === id);
|
||||
if (!o) return;
|
||||
document.getElementById('detail-panel').style.display = 'block';
|
||||
document.getElementById('detail-card').innerHTML = buildDetail(o);
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
document.getElementById('detail-panel').style.display = 'none';
|
||||
}
|
||||
|
||||
function buildDetail(o) {
|
||||
const idx = DEV_STAGES.findIndex(s => s.key === (o.devStage || ''));
|
||||
const exp = o.expectedDelivery ? new Date(o.expectedDelivery).toLocaleDateString() : '未设定';
|
||||
|
||||
let h = `<button class="close-btn" onclick="closeDetail()">✕</button>`;
|
||||
h += `<h2>${o.id}</h2>`;
|
||||
h += `<p style="color:#5f5e5a;font-size:13px;"><strong>${o.name||''}</strong> · 📞 ${o.contactType||''}: ${o.contact||''}</p>`;
|
||||
h += `<span class="status status-${o.status}" style="font-size:13px;padding:4px 12px;">${STATUS_LABEL[o.status]||o.status}</span>`;
|
||||
if (o.flagged) h += ` <span style="color:#e24b4a;font-size:12px;">⚠️ ${o.flagReason||'可疑'}</span>`;
|
||||
|
||||
// --- 开发工作流(仅活跃订单)---
|
||||
if (!o.archived) {
|
||||
h += `<div style="margin-top:12px;"><h3>🔧 开发进度</h3>`;
|
||||
h += `<div class="dev-stage">`;
|
||||
DEV_STAGES.forEach((s, i) => {
|
||||
const cls = i <= idx ? 'done' : (s.key === o.devStage ? 'active' : '');
|
||||
h += `<button class="${cls}" onclick="setStage('${o.id}','${s.key}',${s.pct})">${i <= idx ? '✅' : ''} ${s.label}</button>`;
|
||||
});
|
||||
h += `</div>`;
|
||||
|
||||
// 进度条
|
||||
h += `<div style="margin:8px 0;"><div style="display:flex;justify-content:space-between;font-size:12px;color:#888780;">
|
||||
<span>${o.progressNote||''}</span><span>${o.progress||0}%</span></div>
|
||||
<div class="progress-bar"><div class="progress-fill" style="width:${o.progress||0}%;background:#1d9e75;"></div></div>
|
||||
</div>`;
|
||||
|
||||
// 手动调整进度
|
||||
h += `<div class="progress-edit"><span style="font-size:12px;color:#888780;">进度:</span>
|
||||
<input type="range" min="0" max="100" value="${o.progress||0}" oninput="previewProgress(this.value)" onchange="setProgress('${o.id}',this.value)">
|
||||
<span id="prog-val" style="font-size:12px;font-weight:500;width:30px;">${o.progress||0}%</span></div>
|
||||
<input id="prog-note" placeholder="进度说明(如:后端API已完成)" style="margin-bottom:6px;">
|
||||
<button class="small" onclick="setProgressWithNote('${o.id}')">更新进度</button>`;
|
||||
|
||||
// 预期时间
|
||||
h += `<div style="margin-top:8px;display:flex;gap:8px;align-items:center;">
|
||||
<span style="font-size:12px;color:#888780;">⏱ 预期 ${o.expectedDays||'?'} 天</span>
|
||||
<span style="font-size:12px;color:#888780;">📅 ${exp}</span></div>`;
|
||||
|
||||
// 接单/推进/交付/归档按钮
|
||||
h += `<div style="margin-top:12px;display:flex;gap:6px;flex-wrap:wrap;">`;
|
||||
const flow = ['submitted','reviewing','accepted','in-progress','testing','completed','delivered'];
|
||||
const ci = flow.indexOf(o.status);
|
||||
if (ci < flow.length-1) h += `<button class="small" onclick="updateStatus('${o.id}','${flow[ci+1]}')">→ ${STATUS_LABEL[flow[ci+1]]}</button>`;
|
||||
if (ci > 0) h += `<button class="small secondary" onclick="updateStatus('${o.id}','${flow[ci-1]}')">← 回退</button>`;
|
||||
if (o.status === 'completed' || o.status === 'testing') h += `<button class="small" style="background:#1d9e75;" onclick="deliver('${o.id}')">📤 交付给客户</button>`;
|
||||
if (o.status === 'delivered') h += `<button class="small" style="background:#639922;" onclick="doArchive('${o.id}')">📦 归档</button>`;
|
||||
h += `<button class="small secondary" onclick="if(confirm('确定删除?'))deleteOrder('${o.id}')">🗑</button>`;
|
||||
if (o.flagged) h += `<button class="small" onclick="dismissFlag('${o.id}')">解除可疑</button>`;
|
||||
h += `</div>`;
|
||||
}
|
||||
|
||||
// 需求
|
||||
h += `<div style="margin-top:12px;"><h3>📝 需求</h3><p style="font-size:13px;color:#5f5e5a;white-space:pre-wrap;">${o.requirements||''}</p>`;
|
||||
if (o.budget) h += `<p style="font-size:12px;color:#888780;">💰 预算: ${o.budget}</p></div>`;
|
||||
|
||||
// 子任务
|
||||
if (o.subs?.length) {
|
||||
h += `<h3>📋 子任务 (${o.subs.length})</h3><div class="sub-list">`;
|
||||
o.subs.forEach(s => h += `<div class="sub-item"><span class="sid">${s.subId}</span><span class="sub-status sub-${s.status==='done'?'done':'pending'}">${s.status==='done'?'✅':'⏳'}</span><span>${s.title||''}</span></div>`);
|
||||
h += `</div>`;
|
||||
}
|
||||
h += `<div style="margin-top:8px;display:flex;gap:4px;">
|
||||
<input id="sub-title" placeholder="子任务" style="flex:1;">
|
||||
<button class="small" onclick="addSub('${o.id}')">+</button>
|
||||
</div>`;
|
||||
|
||||
// 沟通记录
|
||||
if (o.comments?.length) {
|
||||
h += `<h3>💬 沟通记录 (${o.comments.length})</h3>`;
|
||||
o.comments.slice().reverse().forEach(c => {
|
||||
h += `<div class="comment ${c.side}"><span class="time">${new Date(c.createdAt).toLocaleString()}</span>`;
|
||||
h += `<span class="author">${c.author} (${c.side==='dev'?'我':'客户'}):</span><br>${c.content}</div>`;
|
||||
});
|
||||
}
|
||||
h += `<div class="comment-input"><input id="comment-text" placeholder="留言..." onkeydown="if(event.key==='Enter')addComment('${o.id}')"><button class="small" onclick="addComment('${o.id}')">发送</button></div>`;
|
||||
|
||||
// 时间线
|
||||
if (o.timeline?.length) {
|
||||
h += `<h3>⏱ 时间线</h3><div style="font-size:12px;color:#888780;max-height:200px;overflow-y:auto;">`;
|
||||
o.timeline.slice().reverse().forEach(t => h += `<div style="padding:2px 0;">${new Date(t.at).toLocaleString()} · ${t.detail}</div>`);
|
||||
h += `</div>`;
|
||||
}
|
||||
|
||||
return h;
|
||||
}
|
||||
|
||||
// 开发阶段
|
||||
async function setStage(id, stage, pct) {
|
||||
await fetch(`api/orders/${id}`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ action:'progress', progress:pct, note:`${stage}阶段` }) });
|
||||
// 同步推进状态
|
||||
if (pct >= 100 && stage === 'done') {
|
||||
await fetch(`api/orders/${id}`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ action:'status', status:'completed' }) });
|
||||
} else if (pct >= 25) {
|
||||
await fetch(`api/orders/${id}`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ action:'status', status:'in-progress' }) });
|
||||
}
|
||||
openDetail(id); loadOrders();
|
||||
}
|
||||
function previewProgress(v) { document.getElementById('prog-val').textContent = v + '%'; }
|
||||
async function setProgress(id, v) {
|
||||
await fetch(`api/orders/${id}`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ action:'progress', progress:parseInt(v) }) });
|
||||
openDetail(id); loadOrders();
|
||||
}
|
||||
async function setProgressWithNote(id) {
|
||||
const p = document.getElementById('prog-val').textContent.replace('%','');
|
||||
const n = document.getElementById('prog-note').value;
|
||||
await fetch(`api/orders/${id}`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ action:'progress', progress:parseInt(p), note:n }) });
|
||||
document.getElementById('prog-note').value = '';
|
||||
openDetail(id); loadOrders();
|
||||
}
|
||||
|
||||
// 状态/操作
|
||||
async function updateStatus(id, s) {
|
||||
await fetch(`api/orders/${id}`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ action:'status', status:s }) });
|
||||
openDetail(id); loadOrders();
|
||||
}
|
||||
async function deliver(id) {
|
||||
await fetch(`api/orders/${id}`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ action:'deliver' }) });
|
||||
openDetail(id); loadOrders();
|
||||
}
|
||||
async function doArchive(id) {
|
||||
await fetch(`api/orders/${id}`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ action:'archive' }) });
|
||||
closeDetail(); loadOrders();
|
||||
}
|
||||
async function addSub(id) {
|
||||
const t = document.getElementById('sub-title').value.trim();
|
||||
if (!t) return;
|
||||
await fetch(`api/orders/${id}`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ action:'subtask', title:t }) });
|
||||
document.getElementById('sub-title').value = '';
|
||||
openDetail(id); loadOrders();
|
||||
}
|
||||
async function addComment(id) {
|
||||
const c = document.getElementById('comment-text').value.trim();
|
||||
if (!c) return;
|
||||
await fetch(`api/orders/${id}`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ action:'comment', author:'之之', content:c, side:'dev' }) });
|
||||
document.getElementById('comment-text').value = '';
|
||||
openDetail(id); loadOrders();
|
||||
}
|
||||
async function dismissFlag(id) {
|
||||
await fetch(`api/orders/${id}`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ action:'flag', flagged:false }) });
|
||||
openDetail(id); loadOrders();
|
||||
}
|
||||
async function deleteOrder(id) {
|
||||
await fetch(`api/orders/${id}`, { method:'DELETE' });
|
||||
closeDetail(); loadOrders();
|
||||
}
|
||||
|
||||
loadOrders();
|
||||
</script>
|
||||
</body></html>
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* zhiqiu-order-system 公共样式
|
||||
*/
|
||||
body { margin:0;font-family:-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;background:#f5f3f0;color:#2c2c2a; }
|
||||
.container { max-width:960px;margin:0 auto;padding:20px; }
|
||||
|
||||
/* 导航 */
|
||||
.nav { display:flex;gap:8px;margin-bottom:20px;flex-wrap:wrap; }
|
||||
.nav a { text-decoration:none;padding:8px 18px;border-radius:8px;font-size:13px;color:#5f5e5a;background:#fff;border:0.5px solid #d3d1c7; }
|
||||
.nav a:hover { border-color:#534ab7;color:#3c3489; }
|
||||
.nav a.active { background:#534ab7;color:#fff;border-color:#534ab7; }
|
||||
|
||||
/* 卡片 */
|
||||
.card { background:#fff;border-radius:12px;padding:20px;border:0.5px solid #d3d1c7;margin-bottom:16px; }
|
||||
.card h2 { margin:0 0 12px;font-size:16px;font-weight:500; }
|
||||
.card h3 { margin:0 0 8px;font-size:14px;font-weight:500;color:#3c3489; }
|
||||
|
||||
/* 表单 */
|
||||
label { display:block;font-size:13px;font-weight:500;margin-top:12px;margin-bottom:4px;color:#3c3489; }
|
||||
input,select,textarea { width:100%;padding:10px;border:1px solid #d3d1c7;border-radius:8px;font-size:13px;font-family:inherit; }
|
||||
input:focus,textarea:focus { outline:none;border-color:#534ab7; }
|
||||
textarea { resize:vertical;min-height:80px; }
|
||||
button { margin-top:12px;padding:10px 20px;background:#534ab7;color:#fff;border:none;border-radius:8px;font-size:14px;cursor:pointer; }
|
||||
button:hover { background:#3c3489; }
|
||||
button.secondary { background:#888780; }
|
||||
button.secondary:hover { background:#5f5e5a; }
|
||||
button.small { padding:6px 12px;font-size:12px;margin-top:4px; }
|
||||
button.danger { background:#e24b4a; }
|
||||
button.danger:hover { background:#a32d2d; }
|
||||
|
||||
/* 表格 / 订单卡片 */
|
||||
.order-card { border:0.5px solid #d3d1c7;border-radius:8px;padding:14px;margin-bottom:10px;cursor:pointer;transition:box-shadow .15s; }
|
||||
.order-card:hover { box-shadow:0 2px 8px rgba(0,0,0,.06); }
|
||||
.order-card .oh { display:flex;justify-content:space-between;align-items:center; }
|
||||
.order-card .id { font-weight:500;color:#534ab7;font-size:13px;font-family:monospace; }
|
||||
.order-card .status { display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px; }
|
||||
.status-submitted { background:#faeeda;color:#854f0b; }
|
||||
.status-reviewing { background:#e6f1fb;color:#185fa5; }
|
||||
.status-accepted { background:#eaf3de;color:#3b6d11; }
|
||||
.status-in-progress { background:#e1f5ee;color:#0f6e56; }
|
||||
.status-testing { background:#fbeaf0;color:#993556; }
|
||||
.status-completed { background:#d3d1c7;color:#2c2c2a; }
|
||||
.status-delivered { background:#cecbf6;color:#3c3489; }
|
||||
|
||||
/* 标签 */
|
||||
.tag { display:inline-block;padding:2px 8px;border-radius:6px;font-size:11px;background:#f1efe8;color:#5f5e5a;margin:2px; }
|
||||
|
||||
/* 评论 */
|
||||
.comment { padding:10px;margin:6px 0;border-radius:8px;font-size:13px; }
|
||||
.comment.dev { background:#e6f1fb;margin-left:20px; }
|
||||
.comment.client { background:#f1efe8;margin-right:20px; }
|
||||
.comment .author { font-size:11px;color:#888780; }
|
||||
.comment .time { font-size:11px;color:#b4b2a9;float:right; }
|
||||
|
||||
/* 状态条 */
|
||||
.status-bar { display:flex;gap:4px;margin:12px 0;flex-wrap:wrap; }
|
||||
.status-dot { width:14px;height:14px;border-radius:50%; }
|
||||
.status-dot.done { background:#534ab7; }
|
||||
.status-dot.active { background:#1d9e75;box-shadow:0 0 0 2px #9fe1cb; }
|
||||
.status-dot.pending { background:#d3d1c7; }
|
||||
|
||||
/* 加载 */
|
||||
.loading { text-align:center;padding:40px;color:#888780; }
|
||||
|
||||
/* 消息 */
|
||||
.msg { padding:10px;border-radius:8px;font-size:13px;margin:8px 0; }
|
||||
.msg.success { background:#eaf3de;color:#3b6d11; }
|
||||
.msg.error { background:#fcebeb;color:#a32d2d; }
|
||||
.msg.info { background:#e6f1fb;color:#185fa5; }
|
||||
|
||||
/* 子任务列表 */
|
||||
.sub-list { margin:4px 0;padding-left:16px; }
|
||||
.sub-item { font-size:13px;padding:4px 0;display:flex;gap:8px;align-items:center; }
|
||||
.sub-item .sid { font-family:monospace;font-size:11px;color:#888780; }
|
||||
.sub-status { font-size:11px;padding:1px 6px;border-radius:4px; }
|
||||
.sub-pending { background:#f1efe8;color:#5f5e5a; }
|
||||
.sub-done { background:#eaf3de;color:#3b6d11; }
|
||||
|
||||
/* 表单辅助 */
|
||||
.hint { font-size:12px;color:#888780;margin-top:4px; }
|
||||
.flex { display:flex;gap:8px; }
|
||||
.flex-grow { flex:1; }
|
||||
|
||||
/* 仪表盘 */
|
||||
.stats { display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:20px; }
|
||||
.stat-card { background:#fff;border-radius:8px;padding:16px;text-align:center;border:0.5px solid #d3d1c7; }
|
||||
.stat-card .num { font-size:28px;font-weight:500;color:#534ab7; }
|
||||
.stat-card .label { font-size:12px;color:#888780;margin-top:4px; }
|
||||
|
||||
@media(max-width:640px) {
|
||||
.container { padding:12px; }
|
||||
.order-card .oh { flex-direction:column;align-items:flex-start;gap:4px; }
|
||||
.stats { grid-template-columns:repeat(2,1fr); }
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
<!DOCTYPE html><html lang="zh-CN">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>__SITE_NAME__</title><link rel="stylesheet" href="style.css">
|
||||
<style>
|
||||
.hero { text-align:center;padding:32px 0 16px; }
|
||||
.hero h1 { font-size:22px;font-weight:500;margin:0; }
|
||||
.hero p { color:#888780;font-size:13px;margin:8px 0 0; }
|
||||
.ai-badge { display:inline-block;padding:2px 10px;border-radius:10px;font-size:11px;background:#e6f1fb;color:#185fa5;margin-top:4px; }
|
||||
.ai-result { background:#f1efe8;border-radius:8px;padding:12px;margin-top:10px;font-size:13px;display:none; }
|
||||
.ai-result .label { font-size:11px;color:#888780; }
|
||||
.confirm { display:none;text-align:center;padding:32px; }
|
||||
.confirm h2 { color:#1d9e75;font-size:18px; }
|
||||
.confirm .order-num { font-size:16px;font-family:monospace;color:#534ab7;padding:8px 16px;background:#f1efe8;border-radius:8px;display:inline-block;margin:12px 0; }
|
||||
/* 蜜罐:用户不可见,仅机器人会填 */
|
||||
.hp-field { position:absolute;left:-9999px;opacity:0;height:0;overflow:hidden; }
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="nav">
|
||||
<a href="/submit" class="active">提交需求</a>
|
||||
<a href="/track">追踪订单</a>
|
||||
<a href="/admin">管理面板</a>
|
||||
</div>
|
||||
|
||||
<div id="form-view">
|
||||
<div class="hero">
|
||||
<h1>之秋 · 技术开发接单</h1>
|
||||
<p>提交你的需求,AI 辅助梳理后推送给开发者</p>
|
||||
<span class="ai-badge">🤖 由 AI 辅助录入</span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>你的需求</h2>
|
||||
<label>请描述你的需求 *</label>
|
||||
<textarea id="req-text" rows="6" placeholder="例:我想做一个微信小程序,用户可以发布二手物品信息,需要有登录、发布、搜索功能,类似闲鱼但简单很多。预算5000左右。"></textarea>
|
||||
<button class="secondary small" onclick="aiAnalyze()">🤖 AI 帮我梳理</button>
|
||||
<div id="ai-result" class="ai-result"></div>
|
||||
|
||||
<label>怎么称呼你 *</label>
|
||||
<input id="req-name" placeholder="你的名字或昵称">
|
||||
|
||||
<label>联系方式 *</label>
|
||||
<div class="flex">
|
||||
<select id="req-contact-type" style="width:100px;flex-shrink:0;">
|
||||
<option value="微信">微信</option>
|
||||
<option value="手机">手机</option>
|
||||
<option value="邮箱">邮箱</option>
|
||||
<option value="QQ">QQ</option>
|
||||
</select>
|
||||
<input id="req-contact" class="flex-grow" placeholder="微信号 / 手机号 / 邮箱">
|
||||
</div>
|
||||
|
||||
<label>预算范围</label>
|
||||
<input id="req-budget" placeholder="如:3000-5000 元(选填)">
|
||||
|
||||
<label>预期完成时间(多少天内)</label>
|
||||
<select id="req-days">
|
||||
<option value="3">3 天内</option>
|
||||
<option value="7" selected>7 天内</option>
|
||||
<option value="14">14 天内</option>
|
||||
<option value="30">30 天内</option>
|
||||
<option value="0">不着急,慢慢来</option>
|
||||
</select>
|
||||
|
||||
<!-- 蜜罐字段:用户不可见,机器人会填 -->
|
||||
<div class="hp-field">
|
||||
<label>网站</label>
|
||||
<input type="text" name="website" id="hp-website" tabindex="-1" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<button onclick="submitOrder()">📤 提交需求</button>
|
||||
<div id="form-msg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="confirm-view" class="confirm"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function aiAnalyze() {
|
||||
const text = document.getElementById('req-text').value.trim();
|
||||
if (!text) return;
|
||||
const box = document.getElementById('ai-result');
|
||||
box.style.display = 'block';
|
||||
box.innerHTML = '<p style="color:#888780">AI 正在分析...</p>';
|
||||
try {
|
||||
const r = await fetch('/api/orders/analyze', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({text})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.error) { box.innerHTML = `<p class="msg error">${d.error}</p>`; return; }
|
||||
let html = '<div class="label">AI 分析结果</div>';
|
||||
html += `<p><strong>类型:</strong> ${d.projectType || '?'} · <strong>复杂度:</strong> ${d.complexity || '?'} · <strong>预估:</strong> ${d.estimatedDays || '?'}天</p>`;
|
||||
html += `<p><strong>摘要:</strong> ${d.summary || ''}</p>`;
|
||||
if (d.techStack?.length) html += `<p><strong>建议技术栈:</strong> ${d.techStack.map(t=>`<span class="tag">${t}</span>`).join(' ')}</p>`;
|
||||
if (d.suggestions?.length) html += `<p><strong>建议补充:</strong></p><ul style="font-size:12px;color:#5f5e5a;">${d.suggestions.map(s=>`<li>${s}</li>`).join('')}</ul>`;
|
||||
box.innerHTML = html;
|
||||
} catch(e) {
|
||||
box.innerHTML = `<p class="msg error">分析失败: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitOrder() {
|
||||
const name = document.getElementById('req-name').value.trim() || '匿名';
|
||||
const contact = document.getElementById('req-contact').value.trim();
|
||||
const requirements = document.getElementById('req-text').value.trim();
|
||||
if (!contact || !requirements) {
|
||||
document.getElementById('form-msg').innerHTML = '<p class="msg error">请填写需求描述和联系方式</p>';
|
||||
return;
|
||||
}
|
||||
document.querySelector('button').disabled = true;
|
||||
document.getElementById('form-msg').innerHTML = '<p style="color:#888780">提交中...</p>';
|
||||
try {
|
||||
const r = await fetch('/api/orders', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({
|
||||
name, contact,
|
||||
contactType: document.getElementById('req-contact-type').value,
|
||||
requirements,
|
||||
budget: document.getElementById('req-budget').value.trim(),
|
||||
expectedDays: parseInt(document.getElementById('req-days').value) || 0,
|
||||
website: document.getElementById('hp-website').value,
|
||||
})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.success) {
|
||||
document.getElementById('form-view').style.display = 'none';
|
||||
const cv = document.getElementById('confirm-view');
|
||||
cv.style.display = 'block';
|
||||
cv.innerHTML = `
|
||||
<h2>✅ 提交成功!</h2>
|
||||
<p>你的需求已推送给开发者</p>
|
||||
<div class="order-num">${d.order.id}</div>
|
||||
<p style="margin:12px 0;"><a href="/track?id=${d.order.id}" style="background:#534ab7;color:#fff;padding:10px 24px;border-radius:8px;text-decoration:none;font-size:14px;">📌 点击查看订单进度</a></p>
|
||||
<p style="font-size:12px;color:#888780;">建议收藏此链接,也可保存订单编号 ${d.order.id} 备用</p>
|
||||
<p style="font-size:13px;color:#888780;margin-top:16px;">开发者会通过 ${d.order.contactType} 联系你</p>
|
||||
`;
|
||||
} else {
|
||||
document.getElementById('form-msg').innerHTML = `<p class="msg error">${d.error}</p>`;
|
||||
}
|
||||
} catch(e) {
|
||||
document.getElementById('form-msg').innerHTML = `<p class="msg error">${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// 自动聚焦
|
||||
document.getElementById('req-text').focus();
|
||||
</script>
|
||||
</body></html>
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
<!DOCTYPE html><html lang="zh-CN">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>__SITE_NAME__ · 我的订单</title><link rel="stylesheet" href="style.css">
|
||||
<style>
|
||||
.dashboard-header { text-align:center;padding:24px 0 16px; }
|
||||
.dashboard-header h1 { font-size:20px;font-weight:500;margin:0; }
|
||||
.dashboard-header p { color:#888780;font-size:13px;margin:6px 0 0; }
|
||||
.order-info { background:#f1efe8;border-radius:8px;padding:14px;margin:12px 0;text-align:center; }
|
||||
.order-info .oid { font-family:monospace;font-size:15px;color:#534ab7;font-weight:500; }
|
||||
.progress-wrap { margin:12px 0; }
|
||||
.progress-bar { height:8px;background:#d3d1c7;border-radius:4px;overflow:hidden; }
|
||||
.progress-fill { height:100%;background:#534ab7;border-radius:4px;transition:width .5s; }
|
||||
.progress-label { display:flex;justify-content:space-between;font-size:12px;color:#888780;margin-top:4px; }
|
||||
.status-grid { display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));gap:8px;margin:12px 0; }
|
||||
.status-step { text-align:center;padding:8px;border-radius:6px;font-size:12px;background:#f1efe8;color:#b4b2a9; }
|
||||
.status-step.done { background:#eaf3de;color:#3b6d11; }
|
||||
.status-step.active { background:#534ab7;color:#fff; }
|
||||
.contact-box { border:0.5px solid #d3d1c7;border-radius:8px;padding:14px;margin:12px 0;text-align:center; }
|
||||
.contact-box p { font-size:13px;color:#5f5e5a;margin:0 0 8px; }
|
||||
.search-area { border:0.5px solid #d3d1c7;border-radius:8px;padding:20px;margin-top:16px;text-align:center; }
|
||||
.search-area h3 { font-size:14px;font-weight:500;margin:0 0 8px;color:#3c3489; }
|
||||
.search-row { display:flex;gap:8px;max-width:400px;margin:0 auto; }
|
||||
.search-row input { flex:1;font-family:monospace; }
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="nav">
|
||||
<a href="/submit">提交需求</a>
|
||||
<a href="/track" class="active">我的订单</a>
|
||||
<a href="/admin">管理面板</a>
|
||||
</div>
|
||||
|
||||
<!-- 查看已有订单 -->
|
||||
<div id="search-view">
|
||||
<div class="dashboard-header">
|
||||
<h1>🔍 我的订单</h1>
|
||||
<p>输入订单编号查看进度</p>
|
||||
</div>
|
||||
<div class="search-area">
|
||||
<h3>查询订单</h3>
|
||||
<div class="search-row">
|
||||
<input id="track-q" placeholder="如: ZT-20260606-001" onkeydown="if(event.key==='Enter')trackOrder()">
|
||||
<button onclick="trackOrder()">查询</button>
|
||||
</div>
|
||||
<p style="font-size:12px;color:#b4b2a9;margin-top:6px;">提交需求后会自动获得订单编号</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 订单详情面板 -->
|
||||
<div id="dashboard-view" style="display:none;">
|
||||
<div class="dashboard-header">
|
||||
<h1>📋 订单详情</h1>
|
||||
<button class="small secondary" onclick="showSearch()" style="margin-top:8px;">← 查询其他订单</button>
|
||||
</div>
|
||||
<div id="dashboard-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const STATUS_LABEL = { submitted:'已提交', reviewing:'审核中', accepted:'已接单', 'in-progress':'制作中', testing:'测试中', completed:'已完成', delivered:'已交付' };
|
||||
const STATUS_STEPS = ['submitted','reviewing','accepted','in-progress','testing','completed','delivered'];
|
||||
|
||||
function showSearch() {
|
||||
document.getElementById('search-view').style.display = 'block';
|
||||
document.getElementById('dashboard-view').style.display = 'none';
|
||||
}
|
||||
|
||||
async function trackOrder() {
|
||||
const q = document.getElementById('track-q').value.trim();
|
||||
if (!q) return;
|
||||
await loadOrder(q);
|
||||
}
|
||||
|
||||
async function loadOrder(id) {
|
||||
const dv = document.getElementById('dashboard-view');
|
||||
const dc = document.getElementById('dashboard-content');
|
||||
dv.style.display = 'block';
|
||||
document.getElementById('search-view').style.display = 'none';
|
||||
dc.innerHTML = '<div class="loading"><p>查询中...</p></div>';
|
||||
|
||||
try {
|
||||
const r = await fetch(`api/orders/search?q=${encodeURIComponent(id)}`);
|
||||
const orders = await r.json();
|
||||
const o = orders.find(x => x.id.toLowerCase() === id.toLowerCase());
|
||||
if (!o) {
|
||||
dc.innerHTML = `<div class="card"><p style="color:#e24b4a;text-align:center;">未找到订单 <strong>${id}</strong></p>
|
||||
<p style="text-align:center;font-size:13px;color:#888780;margin-top:8px;"><a href="/submit">提交新需求</a></p></div>`;
|
||||
return;
|
||||
}
|
||||
renderDashboard(o, dc);
|
||||
} catch(e) {
|
||||
dc.innerHTML = `<p class="msg error">${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderDashboard(o, el) {
|
||||
const idx = STATUS_STEPS.indexOf(o.status);
|
||||
const pct = o.progress || (idx >= 0 ? Math.round((idx / (STATUS_STEPS.length-1)) * 100) : 0);
|
||||
const exp = o.expectedDelivery ? new Date(o.expectedDelivery).toLocaleDateString() : '待确认';
|
||||
|
||||
let h = `<div class="card"><div class="order-info"><div class="oid">${o.id}</div>`;
|
||||
h += `<span class="status status-${o.status}" style="margin-top:6px;display:inline-block;">${STATUS_LABEL[o.status]||o.status}</span></div>`;
|
||||
|
||||
// 进度条
|
||||
h += `<div class="progress-wrap"><div class="progress-bar"><div class="progress-fill" style="width:${pct}%"></div></div>`;
|
||||
h += `<div class="progress-label"><span>${o.progressNote||STATUS_LABEL[o.status]}</span><span>${pct}%</span></div></div>`;
|
||||
|
||||
// 状态步骤
|
||||
h += `<div class="status-grid">`;
|
||||
STATUS_STEPS.forEach((s, i) => {
|
||||
const cls = i <= idx ? 'done' : (i === idx+1 && idx >= 0 ? 'active' : '');
|
||||
h += `<div class="status-step ${cls}">${i <= idx ? '✅' : (i === idx+1 ? '⏳' : '○')}<br>${STATUS_LABEL[s]}</div>`;
|
||||
});
|
||||
h += `</div>`;
|
||||
|
||||
// 预期交付
|
||||
h += `<p style="font-size:13px;color:#5f5e5a;text-align:center;">📅 预计交付: ${exp}</p>`;
|
||||
|
||||
// 需求
|
||||
h += `<h3>📝 需求</h3><p style="font-size:13px;color:#5f5e5a;white-space:pre-wrap;">${o.requirements||''}</p>`;
|
||||
if (o.budget) h += `<p style="font-size:12px;color:#888780;">💰 预算: ${o.budget}</p>`;
|
||||
|
||||
// 子任务
|
||||
if (o.subs?.length) {
|
||||
h += `<h3>📋 任务清单</h3><div class="sub-list">`;
|
||||
o.subs.forEach(s => {
|
||||
h += `<div class="sub-item"><span class="sid">${s.subId}</span><span class="sub-status sub-${s.status==='done'?'done':'pending'}">${s.status==='done'?'✅':'⏳'}</span><span>${s.title}</span></div>`;
|
||||
});
|
||||
h += `</div>`;
|
||||
}
|
||||
|
||||
// 沟通记录
|
||||
if (o.comments?.length) {
|
||||
h += `<h3>💬 沟通记录</h3>`;
|
||||
o.comments.slice().reverse().forEach(c => {
|
||||
h += `<div class="comment ${c.side}"><span class="time">${new Date(c.createdAt).toLocaleString()}</span>`;
|
||||
h += `<span class="author">${c.author} (${c.side==='dev'?'开发者':'你'}):</span><br>${c.content}</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
// 主动联系入口
|
||||
h += `<div class="contact-box"><p>有问题需要联系开发者?</p>`;
|
||||
h += `<textarea id="client-msg" rows="2" placeholder="留言给开发者..." style="margin-bottom:6px;"></textarea>`;
|
||||
h += `<button class="small" onclick="sendMsg('${o.id}')">📤 发送留言</button></div>`;
|
||||
|
||||
// 时间线
|
||||
if (o.timeline?.length) {
|
||||
h += `<h3>⏱ 更新记录</h3><div style="font-size:12px;color:#888780;">`;
|
||||
o.timeline.slice().reverse().forEach(t => {
|
||||
h += `<div style="padding:2px 0;">${new Date(t.at).toLocaleString()} · ${t.detail}</div>`;
|
||||
});
|
||||
h += `</div>`;
|
||||
}
|
||||
|
||||
h += `</div>`;
|
||||
el.innerHTML = h;
|
||||
}
|
||||
|
||||
async function sendMsg(id) {
|
||||
const content = document.getElementById('client-msg').value.trim();
|
||||
if (!content) return;
|
||||
await fetch(`api/orders/${id}`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ action:'comment', author:'客户', content, side:'client' }) });
|
||||
document.getElementById('client-msg').value = '';
|
||||
loadOrder(id);
|
||||
}
|
||||
|
||||
// URL 参数自动查询
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get('id')) loadOrder(params.get('id'));
|
||||
else document.getElementById('track-q').focus();
|
||||
</script>
|
||||
</body></html>
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
/**
|
||||
* server.js - AI 接单系统主服务
|
||||
* 支持 Docker 部署、垃圾过滤、环境变量配置
|
||||
*/
|
||||
import http from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { CONFIG, validateConfig } from './config.js';
|
||||
import {
|
||||
createOrder, getActiveOrders, getArchivedOrders, getAllOrders, findOrder, getOrder,
|
||||
updateStatus, addSubTask, updateSubStatus, updateProgress, setExpectedDays,
|
||||
deliverOrder, archiveOrder, addComment, getRecentOrders, getPendingCount, deleteOrder, flagOrder
|
||||
} from './order-manager.js';
|
||||
import { analyzeRequirement, generateConfirmation } from './ai-intake.js';
|
||||
import { filterOrder, getRateStatus } from './spam-filter.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// 启动检查
|
||||
const configWarnings = validateConfig();
|
||||
if (configWarnings.length) configWarnings.forEach(w => console.log(w));
|
||||
|
||||
// 路由表
|
||||
const routes = {
|
||||
'/' : serveStatic('submit.html', 'text/html'),
|
||||
'/submit' : serveStatic('submit.html', 'text/html'),
|
||||
'/admin' : serveStatic('admin.html', 'text/html'),
|
||||
'/track' : serveStatic('track.html', 'text/html'),
|
||||
'/style.css' : serveStatic('style.css', 'text/css'),
|
||||
'/api/orders' : apiOrders,
|
||||
'/api/orders/active': apiActiveOrders,
|
||||
'/api/orders/archived': apiArchivedOrders,
|
||||
'/api/orders/search': apiSearchOrders,
|
||||
'/api/orders/pending': apiPendingCount,
|
||||
'/api/orders/recent': apiRecentOrders,
|
||||
'/api/orders/analyze': apiAnalyze,
|
||||
'/api/health' : apiHealth,
|
||||
};
|
||||
|
||||
function serveStatic(file, mime) {
|
||||
return (req, res) => {
|
||||
const p = path.join(__dirname, 'public', file);
|
||||
try {
|
||||
let data = fs.readFileSync(p, 'utf-8');
|
||||
// 注入站点名称
|
||||
data = data.replace(/__SITE_NAME__/g, CONFIG.siteName);
|
||||
data = data.replace(/__ADMIN_TOKEN__/g, CONFIG.adminToken);
|
||||
res.writeHead(200, { 'Content-Type': mime });
|
||||
res.end(data);
|
||||
} catch {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function json(res, data, code = 200) {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise(resolve => {
|
||||
let d = '';
|
||||
req.on('data', c => d += c);
|
||||
req.on('end', () => { try { resolve(JSON.parse(d)); } catch { resolve({}); } });
|
||||
});
|
||||
}
|
||||
|
||||
function parseUrl(req) {
|
||||
return new URL(req.url, `http://localhost:${CONFIG.port}`);
|
||||
}
|
||||
|
||||
function getClientIp(req) {
|
||||
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket?.remoteAddress || '0.0.0.0';
|
||||
}
|
||||
|
||||
// --- API Handlers ---
|
||||
|
||||
async function apiOrders(req, res) {
|
||||
const url = parseUrl(req);
|
||||
const parts = url.pathname.split('/');
|
||||
const id = parts.length >= 4 ? parts[3] : null;
|
||||
const ip = getClientIp(req);
|
||||
// Special sub-paths - redirect to specific handlers
|
||||
if (id === 'active') return apiActiveOrders(req, res);
|
||||
if (id === 'archived') return apiArchivedOrders(req, res);
|
||||
if (id === 'search') return apiSearchOrders(req, res);
|
||||
if (id === 'pending') return apiPendingCount(req, res);
|
||||
if (id === 'recent') return apiRecentOrders(req, res);
|
||||
if (id === 'analyze') return apiAnalyze(req, res);
|
||||
|
||||
// GET
|
||||
if (req.method === 'GET') {
|
||||
if (id) {
|
||||
const order = getOrder(id);
|
||||
if (!order) return json(res, { error: '订单不存在' }, 404);
|
||||
return json(res, order);
|
||||
}
|
||||
return json(res, getAllOrders());
|
||||
}
|
||||
|
||||
// POST — 创建订单(含垃圾过滤)
|
||||
if (req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
if (!body.requirements || !body.contact) {
|
||||
return json(res, { error: '需求和联系方式不能为空' }, 400);
|
||||
}
|
||||
|
||||
// 垃圾过滤三道关
|
||||
const security = filterOrder(body, ip);
|
||||
if (!security.allowed) {
|
||||
return json(res, {
|
||||
error: '提交被拒绝',
|
||||
issues: security.issues,
|
||||
flagged: true,
|
||||
}, 429);
|
||||
}
|
||||
|
||||
const order = createOrder({
|
||||
name: body.name,
|
||||
contact: body.contact,
|
||||
contactType: body.contactType || '微信',
|
||||
requirements: body.requirements,
|
||||
budget: body.budget || '',
|
||||
expectedDays: parseInt(body.expectedDays) || 0,
|
||||
source: body.source || 'web',
|
||||
});
|
||||
|
||||
// 如果被标记为可疑,自动标记
|
||||
if (security.flagged) {
|
||||
flagOrder(order.id, true, security.flagReasons?.join('; ') || '系统自动标记');
|
||||
addComment(order.id, {
|
||||
author: '系统', content: `⚠️ 此订单被自动标记为可疑: ${security.flagReasons?.join('、') || '内容异常'}。请审核。`, side: 'dev'
|
||||
});
|
||||
}
|
||||
|
||||
// AI 确认(异步)
|
||||
try {
|
||||
const msg = await generateConfirmation(order);
|
||||
addComment(order.id, { author: '系统', content: msg, side: 'dev' });
|
||||
} catch {}
|
||||
|
||||
return json(res, {
|
||||
success: true,
|
||||
order,
|
||||
flagged: security.flagged,
|
||||
flagReasons: security.flagged ? security.flagReasons : undefined,
|
||||
}, 201);
|
||||
}
|
||||
|
||||
// PUT
|
||||
if (req.method === 'PUT') {
|
||||
const body = await readBody(req);
|
||||
let result;
|
||||
if (body.action === 'status') result = updateStatus(id, body.status);
|
||||
else if (body.action === 'subtask') result = addSubTask(id, body);
|
||||
else if (body.action === 'sub-status') result = updateSubStatus(id, body.subId, body.status);
|
||||
else if (body.action === 'comment') result = addComment(id, body);
|
||||
else if (body.action === 'flag') result = flagOrder(id, body.flagged, body.reason);
|
||||
else if (body.action === 'progress') result = updateProgress(id, body.progress, body.note);
|
||||
else if (body.action === 'set-expected') result = setExpectedDays(id, body.days);
|
||||
else if (body.action === 'deliver') result = deliverOrder(id);
|
||||
else if (body.action === 'archive') result = archiveOrder(id);
|
||||
if (!result) return json(res, { error: '操作失败' }, 400);
|
||||
return json(res, { success: true, data: result });
|
||||
}
|
||||
|
||||
// DELETE
|
||||
if (req.method === 'DELETE') {
|
||||
const ok = deleteOrder(id);
|
||||
return json(res, { success: ok });
|
||||
}
|
||||
}
|
||||
|
||||
async function apiSearchOrders(req, res) {
|
||||
const q = parseUrl(req).searchParams.get('q') || '';
|
||||
json(res, findOrder(q));
|
||||
}
|
||||
|
||||
function apiPendingCount(req, res) {
|
||||
json(res, { count: getPendingCount() });
|
||||
}
|
||||
|
||||
function apiActiveOrders(req, res) {
|
||||
json(res, getActiveOrders());
|
||||
}
|
||||
|
||||
function apiArchivedOrders(req, res) {
|
||||
json(res, getArchivedOrders());
|
||||
}
|
||||
|
||||
function apiRecentOrders(req, res) {
|
||||
json(res, getRecentOrders(120));
|
||||
}
|
||||
|
||||
async function apiAnalyze(req, res) {
|
||||
const body = await readBody(req);
|
||||
if (!body.text) return json(res, { error: '缺少需求文本' }, 400);
|
||||
try {
|
||||
const analysis = await analyzeRequirement(body.text);
|
||||
json(res, analysis || { error: '分析失败' });
|
||||
} catch (e) {
|
||||
json(res, { error: e.message }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
function apiHealth(req, res) {
|
||||
json(res, {
|
||||
status: 'ok',
|
||||
site: CONFIG.siteName,
|
||||
version: '1.0.0',
|
||||
aiEnabled: !!CONFIG.deepseekKey && CONFIG.deepseekKey !== 'your-api-key-here',
|
||||
docker: !!process.env.DOCKER,
|
||||
});
|
||||
}
|
||||
|
||||
// Server
|
||||
const server = http.createServer((req, res) => {
|
||||
const pathname = parseUrl(req).pathname;
|
||||
if (pathname.startsWith('/api/orders')) return apiOrders(req, res);
|
||||
const handler = routes[pathname];
|
||||
if (handler) handler(req, res);
|
||||
else { res.writeHead(404); res.end('Not found'); }
|
||||
});
|
||||
|
||||
server.listen(CONFIG.port, '0.0.0.0', () => {
|
||||
console.log(`🧰 zhiqiu-order-system v1.0`);
|
||||
console.log(` 📋 客户提交: http://0.0.0.0:${CONFIG.port}/submit`);
|
||||
console.log(` 🔧 管理面板: http://0.0.0.0:${CONFIG.port}/admin`);
|
||||
console.log(` 🔍 订单追踪: http://0.0.0.0:${CONFIG.port}/track`);
|
||||
console.log(` 🤖 AI 辅助: ${CONFIG.deepseekKey && CONFIG.deepseekKey !== 'your-api-key-here' ? '已启用' : '未配置'}`);
|
||||
});
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
/**
|
||||
* spam-filter.js - 垃圾订单过滤模块
|
||||
* 三道防线:频率限制 → 蜜罐检测 → 内容分析
|
||||
*/
|
||||
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1小时
|
||||
const MAX_ORDERS_PER_IP = 3; // 每 IP 每小时最多 3 单
|
||||
const MAX_ORDERS_PER_CONTACT = 2; // 同一联系方式最多 2 单未完成
|
||||
|
||||
// 内存中的速率限制表
|
||||
const ipCounter = new Map();
|
||||
const suspiciousPatterns = [
|
||||
/[a-z]{20,}/i, // 超长无意义字母
|
||||
/(.)\1{10,}/, // 重复字符超过10次
|
||||
/[\u4e00-\u9fff]{50,}/, // 超长无标点中文
|
||||
/http[s]?:\/\/(?!.*订单|.*需求|.*开发)/i, // 非常规链接
|
||||
];
|
||||
|
||||
/** 清理过期 IP 记录 */
|
||||
function cleanExpired() {
|
||||
const now = Date.now();
|
||||
for (const [ip, data] of ipCounter) {
|
||||
if (now - data.windowStart > RATE_LIMIT_WINDOW) ipCounter.delete(ip);
|
||||
}
|
||||
}
|
||||
|
||||
/** 检查 IP 频率限制 */
|
||||
export function checkRateLimit(ip) {
|
||||
cleanExpired();
|
||||
const now = Date.now();
|
||||
let data = ipCounter.get(ip);
|
||||
if (!data || now - data.windowStart > RATE_LIMIT_WINDOW) {
|
||||
data = { windowStart: now, count: 0 };
|
||||
ipCounter.set(ip, data);
|
||||
}
|
||||
data.count++;
|
||||
return {
|
||||
allowed: data.count <= MAX_ORDERS_PER_IP,
|
||||
remaining: Math.max(0, MAX_ORDERS_PER_IP - data.count),
|
||||
total: data.count,
|
||||
};
|
||||
}
|
||||
|
||||
/** 检查蜜罐字段(隐藏字段,机器人会填写) */
|
||||
export function checkHoneypot(body) {
|
||||
// website / callback_url 是蜜罐字段,正常用户看不到
|
||||
if (body.website || body.callback_url) {
|
||||
return { flagged: true, reason: '蜜罐字段被触发(疑似机器人)' };
|
||||
}
|
||||
// 如果要求必填的联系方式为空但蜜罐填了
|
||||
if (!body.contact && (body.website || body.callback_url)) {
|
||||
return { flagged: true, reason: '机器人提交' };
|
||||
}
|
||||
return { flagged: false };
|
||||
}
|
||||
|
||||
/** 检查内容是否可疑 */
|
||||
export function checkContent(body) {
|
||||
const text = `${body.requirements || ''} ${body.name || ''} ${body.contact || ''}`;
|
||||
const reasons = [];
|
||||
|
||||
// 模式匹配
|
||||
for (const pattern of suspiciousPatterns) {
|
||||
if (pattern.test(text)) {
|
||||
reasons.push('内容模式异常');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 联系方式和需求内容完全没有关联
|
||||
const contact = (body.contact || '').trim();
|
||||
const req = (body.requirements || '').trim();
|
||||
if (contact && req && req.length < 5) {
|
||||
reasons.push('需求描述过短');
|
||||
}
|
||||
|
||||
// 同一联系方式重复提交
|
||||
if (body.contact) {
|
||||
// 这个在 order-manager 里检查
|
||||
}
|
||||
|
||||
return {
|
||||
flagged: reasons.length > 0,
|
||||
reasons,
|
||||
};
|
||||
}
|
||||
|
||||
/** 三重检查:完整过滤入口 */
|
||||
export function filterOrder(body, ip) {
|
||||
const issues = [];
|
||||
|
||||
// 1. 蜜罐检测
|
||||
const hp = checkHoneypot(body);
|
||||
if (hp.flagged) issues.push(hp.reason);
|
||||
|
||||
// 2. 频率限制
|
||||
const rl = checkRateLimit(ip);
|
||||
if (!rl.allowed) issues.push(`提交过于频繁(本小时第 ${rl.total} 次,上限 ${MAX_ORDERS_PER_IP})`);
|
||||
if (rl.total >= MAX_ORDERS_PER_IP) {
|
||||
return {
|
||||
allowed: false,
|
||||
issues: [`提交次数已达上限(${MAX_ORDERS_PER_IP}次/小时),请稍后再试`],
|
||||
flagged: true,
|
||||
rateInfo: rl,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. 内容检查
|
||||
const cc = checkContent(body);
|
||||
if (cc.flagged) issues.push(...cc.reasons);
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
issues,
|
||||
flagged: issues.length > 0,
|
||||
flagReasons: issues,
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取速率限制状态 */
|
||||
export function getRateStatus(ip) {
|
||||
cleanExpired();
|
||||
const data = ipCounter.get(ip);
|
||||
if (!data) return { remaining: MAX_ORDERS_PER_IP, total: 0 };
|
||||
const elapsed = Date.now() - data.windowStart;
|
||||
const remaining = Math.max(0, MAX_ORDERS_PER_IP - data.count);
|
||||
return {
|
||||
remaining,
|
||||
total: data.count,
|
||||
resetIn: Math.max(0, Math.ceil((RATE_LIMIT_WINDOW - elapsed) / 1000)),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
"""
|
||||
B站短剧采集器 · 公开API版
|
||||
不需要登录,直接搜短剧数据
|
||||
"""
|
||||
|
||||
import requests, json, time, os
|
||||
from datetime import datetime
|
||||
|
||||
DATA_DIR = '/opt/zhiqiu-tools/data/drama'
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
|
||||
HEADERS = {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||
'Referer': 'https://www.bilibili.com/'
|
||||
}
|
||||
|
||||
def search_bilibili(keyword, page=1):
|
||||
"""搜索B站视频"""
|
||||
url = 'https://api.bilibili.com/x/web-interface/search/type'
|
||||
params = {'search_type': 'video', 'keyword': keyword, 'page': page}
|
||||
r = requests.get(url, params=params, headers=HEADERS, timeout=15)
|
||||
data = r.json()
|
||||
if data.get('code') != 0:
|
||||
return []
|
||||
return data.get('data', {}).get('result', [])
|
||||
|
||||
def format_video(v):
|
||||
"""提取关键字段"""
|
||||
title = v.get('title', '').replace('<em class="keyword">', '').replace('</em>', '')
|
||||
return {
|
||||
'title': title,
|
||||
'play': v.get('play', 0),
|
||||
'author': v.get('author', ''),
|
||||
'duration': v.get('duration', ''),
|
||||
'bvid': v.get('bvid', ''),
|
||||
'aid': v.get('aid', ''),
|
||||
'tag': v.get('tag', ''),
|
||||
'description': v.get('description', '')[:200],
|
||||
'pic': v.get('pic', ''),
|
||||
'source': 'bilibili',
|
||||
'crawl_time': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
def collect(keywords, max_pages=3):
|
||||
"""采集主函数"""
|
||||
all_results = []
|
||||
seen = set()
|
||||
|
||||
for kw in keywords:
|
||||
print(f'\n🔍 搜索: {kw}')
|
||||
for page in range(1, max_pages + 1):
|
||||
try:
|
||||
results = search_bilibili(kw, page)
|
||||
except:
|
||||
print(f' 第{page}页请求失败,跳过')
|
||||
break
|
||||
if not results:
|
||||
print(f' 第{page}页无数据')
|
||||
break
|
||||
count = 0
|
||||
for v in results:
|
||||
vid = v.get('bvid') or v.get('aid')
|
||||
if vid and vid not in seen:
|
||||
seen.add(vid)
|
||||
item = format_video(v)
|
||||
all_results.append(item)
|
||||
count += 1
|
||||
print(f' 第{page}页: +{count}条 (累计{len(all_results)})')
|
||||
time.sleep(0.5)
|
||||
|
||||
# 定期保存中间结果(每轮关键词后)
|
||||
def save_interim(data, tag):
|
||||
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
path = os.path.join(DATA_DIR, f'bili_drama_{tag}_{ts}.json')
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
return path
|
||||
|
||||
save_interim(all_results, 'interim')
|
||||
|
||||
# 按播放量排序
|
||||
|
||||
# 存文件
|
||||
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
path = os.path.join(DATA_DIR, f'bili_drama_{ts}.json')
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(all_results, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f'\n✅ 采集完成: {len(all_results)}条')
|
||||
print(f'📁 已保存: {path}')
|
||||
|
||||
# 打印TOP5
|
||||
print('\n🏆 播放量TOP5:')
|
||||
for i, item in enumerate(all_results[:5], 1):
|
||||
print(f' {i}. [{item["play"]}播放] {item["title"][:50]}')
|
||||
print(f' UP: {item["author"]} | {item["duration"]}')
|
||||
|
||||
return all_results
|
||||
|
||||
if __name__ == '__main__':
|
||||
keywords = ['短剧', '短剧推荐', 'ai短剧', '真人短剧', '爆款短剧']
|
||||
collect(keywords, max_pages=2)
|
||||
Loading…
Reference in New Issue