first commit

This commit is contained in:
0Xiao0
2026-03-23 10:45:02 +08:00
commit e6e4cd8119
40 changed files with 5364 additions and 0 deletions

971
export_web_view.py Normal file
View File

@ -0,0 +1,971 @@
import api
import config
import logging
import json
from get_records import get_all_records
LOG_FORMAT = "%(asctime)s - %(levelname)s - %(message)s"
TABS = config.WEB_VIEW_TABS
logging.basicConfig(format=LOG_FORMAT, level=logging.INFO)
HTML_TEMPLATE = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bitable Records - Interactive View</title>
<style>
:root {
--bg-color: #f8fafc;
--text-color: #334155;
--card-bg: #ffffff;
--border-color: #e2e8f0;
--accent-color: #3b82f6;
--hover-bg: #f1f5f9;
--tag-bg: #e0e7ff;
--tag-text: #3730a3;
--text-muted: #64748b;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
margin: 0;
padding: 40px 20px;
line-height: 1.5;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: var(--card-bg);
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
padding: 30px;
border: 1px solid var(--border-color);
}
h1 {
text-align: center;
font-weight: 700;
margin-bottom: 30px;
font-size: 28px;
color: #0f172a;
}
.toolbar {
display: flex;
gap: 12px;
margin-bottom: 24px;
padding: 16px;
background: #f8fafc;
border: 1px solid var(--border-color);
border-radius: 8px;
flex-wrap: wrap;
align-items: center;
}
.toolbar input, .toolbar select, .toolbar button {
padding: 8px 12px;
border: 1px solid #cbd5e1;
border-radius: 6px;
font-size: 14px;
outline: none;
background: white;
}
.toolbar input {
flex-grow: 1;
min-width: 200px;
}
.toolbar input:focus, .toolbar select:focus {
border-color: var(--accent-color);
box-shadow: 0 0 0 2px #dbeafe;
}
.toolbar button {
cursor: pointer;
font-weight: 500;
transition: background 0.2s;
}
.toolbar button:hover {
background: var(--hover-bg);
}
.tabs {
display: flex;
border-bottom: 2px solid var(--border-color);
margin-bottom: 20px;
gap: 8px;
overflow-x: auto;
}
.tab {
padding: 10px 20px;
cursor: pointer;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
font-weight: 500;
color: var(--text-muted);
transition: all 0.2s;
white-space: nowrap;
}
.tab:hover {
color: var(--text-color);
background: var(--hover-bg);
border-radius: 6px 6px 0 0;
}
.tab.active {
color: var(--accent-color);
border-bottom-color: var(--accent-color);
}
ul.tree {
list-style-type: none;
padding-left: 0;
margin: 0;
}
ul.tree ul {
list-style-type: none;
padding-left: 28px;
border-left: 2px solid #cbd5e1;
margin-left: 14px;
margin-top: 4px;
}
li { margin: 6px 0; }
.node-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: #ffffff;
border: 1px solid var(--border-color);
border-radius: 6px;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0,0,0,0.02);
}
.node-content:hover {
background: var(--hover-bg);
border-color: #cbd5e1;
}
.dimmed .node-content {
opacity: 0.5;
background: #f8fafc;
}
details { margin-bottom: 6px; }
summary {
list-style: none;
cursor: pointer;
display: block;
user-select: none;
outline: none;
}
summary::-webkit-details-marker { display: none; }
.toggle-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
margin-right: 8px;
color: #94a3b8;
transition: transform 0.2s ease;
font-family: monospace;
font-size: 14px;
}
details[open] > summary .toggle-icon {
transform: rotate(90deg);
color: var(--accent-color);
}
.task-left {
display: flex;
align-items: center;
flex-grow: 1;
min-width: 0;
}
.task-title {
font-weight: 600;
font-size: 15px;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 16px;
}
.task-meta {
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
font-size: 13px;
color: var(--text-muted);
}
.meta-item {
display: flex;
align-items: center;
gap: 6px;
}
.avatar {
width: 20px;
height: 20px;
border-radius: 50%;
background: #e2e8f0;
display: inline-block;
vertical-align: middle;
object-fit: cover;
}
.tags {
display: flex;
gap: 6px;
align-items: center;
}
.tag {
background: var(--tag-bg);
color: var(--tag-text);
padding: 2px 8px;
border-radius: 12px;
font-weight: 600;
font-size: 12px;
white-space: nowrap;
}
.empty-leaf {
padding-left: 32px;
}
.unnamed-task {
color: #94a3b8;
font-style: italic;
}
.stats {
font-size: 14px;
color: var(--text-muted);
margin-bottom: 12px;
text-align: right;
}
</style>
</head>
<body>
<div class="container">
<h1>ALL IN AI</h1>
<div id="globalAiSummaryResult" style="display:none;margin-bottom:20px;padding:16px;background:#ffffff;border:1px solid #c4b5fd;border-left:4px solid #8b5cf6;border-radius:8px;font-size:14px;color:#4c1d95;white-space:pre-wrap;line-height:1.6;max-height:500px;overflow-y:auto;box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05);"></div>
<div class="tabs" id="tabContainer"></div>
<div class="toolbar" id="toolbarFilters">
<input type="text" id="searchInput" placeholder="Search tasks...">
<div style="display:flex; align-items:center; gap:6px;">
<span style="font-size:14px;color:#64748b;">Date:</span>
<input type="date" id="dateFrom" title="Start Date">
<span style="color:#cbd5e1;">-</span>
<input type="date" id="dateTo" title="End Date">
</div>
<select id="statusFilter">
<option value="">All Statuses</option>
</select>
<select id="leaderFilter">
<option value="">All Leaders</option>
</select>
<select id="sortOrder">
<option value="default">Sort: Default</option>
<option value="startDate">Sort: Start Date</option>
<option value="priority">Sort: Priority</option>
</select>
<button id="toggleExpand">Toggle Expand All</button>
</div>
<div class="stats" id="statsDisplay"></div>
<div id="summaryDisplay"></div>
<div id="aiSummaryResult" style="display:none;margin-bottom:16px;padding:16px;background:#ffffff;border:1px solid #bbf7d0;border-radius:8px;font-size:14px;color:#334155;white-space:pre-wrap;line-height:1.6;max-height:500px;overflow-y:auto;box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05);"></div>
<div id="tree-container"></div>
</div>
<script>
const tabData = {RECORDS_JSON}; // Array of {name, records}
let rawRecords = [];
let records = [];
function extractText(val) {
if (Array.isArray(val) && val.length > 0) return val[0].text || '';
return val ? String(val) : '';
}
function formatDate(ts) {
if (!ts) return '';
const d = new Date(Number(ts));
const mo = String(d.getMonth()+1).padStart(2,'0');
const da = String(d.getDate()).padStart(2,'0');
return `${mo}-${da}`;
}
function parseRecords() {
records = rawRecords.map(r => {
const fields = r.fields || {};
const taskNameRaw = fields['Task'] || fields['Task Description'];
const taskName = extractText(taskNameRaw) || 'Untitled Task';
const status = extractText(fields['Status']).trim();
const priority = extractText(fields['Priority']).trim();
const overdue = extractText(fields['Overdue']).trim();
const manday = fields['Manday count'] || '';
const progressNotes = extractText(fields['Progress notes']).trim();
let tcRaw = fields['Target Customers'];
let targetCustomers = '';
if (Array.isArray(tcRaw)) {
targetCustomers = tcRaw.map(x => typeof x === 'object' ? (x.text || '') : String(x)).join(', ').trim();
} else if (tcRaw) {
targetCustomers = String(tcRaw).trim();
}
if (targetCustomers) {
console.log("Parsed Target Customers for [" + taskName + "]:", targetCustomers);
}
let parentRaw = fields['父记录'];
let parentId = null;
let parentName = null;
if (Array.isArray(parentRaw) && parentRaw.length > 0) {
if (parentRaw[0].record_ids && parentRaw[0].record_ids.length > 0) {
parentId = parentRaw[0].record_ids[0];
} else {
parentName = parentRaw[0].text;
}
} else if (typeof parentRaw === 'string') {
parentName = parentRaw.trim();
}
const startDateRaw = fields['Start date'];
const endDateRaw = fields['End Date'];
const startDate = startDateRaw ? Number(startDateRaw) : Infinity;
const endDate = endDateRaw ? Number(endDateRaw) : Infinity;
const leadersRaw = Array.isArray(fields['Task leader']) ? fields['Task leader'] : [];
const leaders = leadersRaw.filter(x => x && typeof x === 'object').map(x => ({name: x.name, avatar: x.avatar_url}));
return {
id: r.record_id,
taskName,
status,
priority,
overdue,
manday,
progressNotes,
targetCustomers,
parentId,
parentName,
startDate,
endDate,
startDateStr: formatDate(startDateRaw),
endDateStr: formatDate(endDateRaw),
leaders
};
});
// Build hierarchy mapping
const nameMap = {};
records.forEach(r => nameMap[r.taskName] = r.id);
records.forEach(r => {
if (!r.parentId && r.parentName && nameMap[r.parentName]) {
r.parentId = nameMap[r.parentName];
}
if (r.parentId === r.id) r.parentId = null;
});
// Populate dropdowns
const statuses = new Set();
const leaderNames = new Set();
records.forEach(r => {
if(r.status) statuses.add(r.status);
r.leaders.forEach(l => leaderNames.add(l.name));
});
const statusSelect = document.getElementById('statusFilter');
statusSelect.innerHTML = '<option value="">All Statuses</option>';
Array.from(statuses).sort().forEach(s => {
const opt = document.createElement('option');
opt.value = opt.textContent = s;
statusSelect.appendChild(opt);
});
const leaderSelect = document.getElementById('leaderFilter');
leaderSelect.innerHTML = '<option value="">All Leaders</option>';
Array.from(leaderNames).sort().forEach(l => {
const opt = document.createElement('option');
opt.value = opt.textContent = l;
leaderSelect.appendChild(opt);
});
}
function switchTab(index) {
if (!tabData[index]) return;
rawRecords = tabData[index].records;
document.querySelectorAll('.tab').forEach((t, i) => {
if (i === index) t.classList.add('active');
else t.classList.remove('active');
});
// Reset filters visual
document.getElementById('searchInput').value = '';
document.getElementById('sortOrder').value = 'default';
parseRecords();
render();
}
function initTabs() {
const container = document.getElementById('tabContainer');
tabData.forEach((tab, index) => {
const btn = document.createElement('button');
btn.className = 'tab';
btn.textContent = tab.name;
btn.onclick = () => switchTab(index);
container.appendChild(btn);
});
const summaryBtn = document.createElement('button');
summaryBtn.className = 'tab';
summaryBtn.style.marginLeft = 'auto'; // Push to the right
summaryBtn.style.color = '#8b5cf6';
summaryBtn.style.fontWeight = 'bold';
summaryBtn.innerHTML = '🌟 全局大总结';
summaryBtn.onclick = window.requestGlobalSummary;
container.appendChild(summaryBtn);
if (tabData.length > 0) {
switchTab(0);
}
}
// UI State
let isExpandAll = false;
document.getElementById('searchInput').addEventListener('input', render);
document.getElementById('statusFilter').addEventListener('change', render);
document.getElementById('leaderFilter').addEventListener('change', render);
document.getElementById('sortOrder').addEventListener('change', render);
document.getElementById('dateFrom').addEventListener('change', render);
document.getElementById('dateTo').addEventListener('change', render);
document.getElementById('toggleExpand').addEventListener('click', () => {
isExpandAll = !isExpandAll;
render();
});
function sortNodes(nodes, sortKey) {
if (sortKey === 'default') return nodes;
nodes.sort((a, b) => {
if (sortKey === 'startDate') return a.startDate - b.startDate;
if (sortKey === 'priority') {
const getP = p => p.includes('High') || p.includes('Important') ? 3 : p.includes('Normal') ? 2 : 1;
return getP(b.priority) - getP(a.priority);
}
return 0;
});
nodes.forEach(n => sortNodes(n.children, sortKey));
return nodes;
}
function filterNodes(nodes, search, statusFilter, leaderFilter, dateFrom, dateTo) {
let result = [];
nodes.forEach(n => {
let matchSearch = n.taskName.toLowerCase().includes(search.toLowerCase()) ||
(n.targetCustomers && n.targetCustomers.toLowerCase().includes(search.toLowerCase()));
let matchStatus = statusFilter === '' || n.status === statusFilter;
let matchLeader = leaderFilter === '' || n.leaders.some(l => l.name === leaderFilter);
let matchDate = true;
if (dateFrom !== -Infinity || dateTo !== Infinity) {
let s = n.startDate !== Infinity ? n.startDate : (n.endDate !== Infinity ? n.endDate : Infinity);
let e = n.endDate !== Infinity ? n.endDate : (n.startDate !== Infinity ? n.startDate : Infinity);
if (s !== Infinity && e !== Infinity) {
matchDate = (s <= dateTo) && (e >= dateFrom);
} else {
matchDate = false;
}
}
let filteredChildren = filterNodes(n.children, search, statusFilter, leaderFilter, dateFrom, dateTo);
let selfMatch = matchSearch && matchStatus && matchLeader && matchDate;
if (selfMatch || filteredChildren.length > 0) {
let cloned = {...n, children: filteredChildren};
cloned.isMatch = selfMatch;
result.push(cloned);
}
});
return result;
}
function genTags(n) {
let html = '<div class="tags">';
if (n.status) {
let bg = '#f1f5f9', fg = '#475569';
if (n.status.includes('Ongoing') || n.status.includes('进行中')) { bg='#dbeafe'; fg='#1e40af'; }
else if (n.status.includes('Completed') || n.status.includes('完成')) { bg='#d1fae5'; fg='#065f46'; }
html += `<span class="tag" style="background:${bg};color:${fg}">${n.status}</span>`;
}
if (n.priority) {
if (n.priority.includes('High') || n.priority.includes('Important')) {
html += `<span class="tag" style="background:#ffedd5;color:#c2410c">${n.priority}</span>`;
} else if (!n.priority.includes('Normal')) {
html += `<span class="tag" style="background:#f1f5f9;color:#475569">${n.priority}</span>`;
}
}
if (n.overdue && !n.overdue.includes('')) {
html += `<span class="tag" style="background:#fee2e2;color:#991b1b">${n.overdue}</span>`;
}
if (n.targetCustomers) {
html += `<span class="tag" style="background:#fce7f3;color:#9d174d">🎯 ${n.targetCustomers}</span>`;
}
html += '</div>';
return html;
}
function genMeta(n) {
let html = '<div class="task-meta">';
if (n.leaders.length > 0) {
html += '<div class="meta-item">';
n.leaders.forEach(l => {
if (l.avatar) html += `<img src="${l.avatar}" class="avatar" title="${l.name}">`;
else html += `<span>${l.name}</span>`;
});
html += '</div>';
}
if (n.startDateStr || n.endDateStr) {
html += `<div class="meta-item">🗓️ ${n.startDateStr} ~ ${n.endDateStr}</div>`;
}
if (n.manday) {
html += `<div class="meta-item">⏱️ ${n.manday}d</div>`;
}
html += '</div>';
return html;
}
window.toggleNotes = function(event, id) {
event.stopPropagation();
event.preventDefault();
const el = document.getElementById('notes-' + id);
if (!el) return;
if (el.style.display === 'none') {
el.style.display = 'block';
} else {
el.style.display = 'none';
}
};
function renderHTML(nodes, isRoot) {
if (nodes.length === 0) return '';
let html = isRoot ? '<ul class="tree">' : '<ul>';
nodes.forEach(n => {
let dimClass = n.isMatch === false ? 'dimmed' : '';
let tags = genTags(n);
let meta = genMeta(n);
// Hide details by default, show toggle button if notes exist
let toggleNotesBtn = '';
let notesHtml = '';
if (n.progressNotes) {
toggleNotesBtn = `<span title="Toggle Progress Notes" style="cursor:pointer; font-size:14px; margin-right:6px;" onclick="window.toggleNotes(event, '${n.id}')">📝</span>`;
notesHtml = `<div id="notes-${n.id}" style="display:none; font-size:13px; color:#64748b; margin-top:8px; padding:8px; background:#f8fafc; border-radius:6px; border:1px dashed #cbd5e1; font-family: monospace; white-space: pre-wrap;">${n.progressNotes}</div>`;
}
html += `<li class="${dimClass}">`;
if (n.children.length > 0) {
let openAttr = isExpandAll ? 'open' : '';
html += `<details ${openAttr}>
<summary>
<div class="node-content" style="flex-wrap: wrap;">
<div style="width:100%; display:flex; align-items:center; justify-content:space-between;">
<div class="task-left">
<span class="toggle-icon">▶</span>
${toggleNotesBtn}
<span class="task-title">${n.taskName}</span>
${tags}
</div>
${meta}
</div>
${notesHtml ? '<div style="width:100%; padding-left:24px;">' + notesHtml + '</div>' : ''}
</div>
</summary>
${renderHTML(n.children, false)}
</details>`;
} else {
let unnamedClass = n.taskName === 'Untitled Task' ? 'unnamed-task' : '';
html += `<div class="node-content empty-leaf" style="flex-wrap: wrap;">
<div style="width:100%; display:flex; align-items:center; justify-content:space-between;">
<div class="task-left">
${toggleNotesBtn}
<span class="task-title ${unnamedClass}">${n.taskName}</span>
${tags}
</div>
${meta}
</div>
${notesHtml ? '<div style="width:100%; padding-left:8px;">' + notesHtml + '</div>' : ''}
</div>`;
}
html += '</li>';
});
html += '</ul>';
return html;
}
function countNodes(nodes) {
return nodes.reduce((sum, n) => sum + 1 + countNodes(n.children), 0);
}
function collectMatchedNodes(nodes) {
let acc = [];
nodes.forEach(n => {
if (n.isMatch) acc.push(n);
acc = acc.concat(collectMatchedNodes(n.children));
});
return acc;
}
function generateSummary(nodes, status, leader) {
let matched = collectMatchedNodes(nodes);
if (matched.length === 0) return '';
let totalMandays = 0;
let completed = 0;
let ongoing = 0;
let taskNames = [];
matched.forEach(r => {
if (r.manday) {
let num = parseFloat(r.manday);
if (!isNaN(num)) totalMandays += num;
}
if (r.status && (r.status.includes('Completed') || r.status.includes('完成'))) completed++;
else if (r.status && (r.status.includes('Ongoing') || r.status.includes('进行中'))) ongoing++;
if (r.taskName && r.taskName !== 'Untitled Task') {
taskNames.push(r.taskName);
}
});
let html = '<div style="margin-bottom:16px; padding:12px 16px; background:#f0fdf4; border: 1px solid #bbf7d0; border-left:4px solid #22c55e; border-radius:6px; font-size:14px; color:#166534; box-shadow: 0 1px 2px rgba(0,0,0,0.05);">';
html += '<div style="font-weight:600; margin-bottom:6px; display:flex; align-items:center; gap:6px;"><span>💡</span>智能总结 (Smart Summary)</div>';
let parts = [];
if (!status && !leader) {
let title = document.getElementById('searchInput') && document.getElementById('searchInput').value ? '搜索结果' : '当前全局';
parts.push(`${title}共有 <strong>${matched.length}</strong> 项任务 (已完成 ${completed} 项,进行中 ${ongoing} 项)。`);
if (totalMandays > 0) parts.push(`预计总人力:<strong>${Math.round(totalMandays*10)/10}</strong> 人日。`);
parts.push(`核心事项概览:`);
} else if (leader && !status) {
parts.push(`<strong>${leader}</strong> 共有 <strong>${matched.length}</strong> 项任务 (已完成 ${completed} 项,进行中 ${ongoing} 项)。`);
if (totalMandays > 0) parts.push(`预计总人力:<strong>${Math.round(totalMandays*10)/10}</strong> 人日。`);
parts.push(`Ta 负责的主要事项有:`);
} else if (status && !leader) {
let isDone = status.includes('Completed') || status.includes('完成');
if (isDone) {
parts.push(`✅ 已经干完了 <strong>${matched.length}</strong> 项任务!`);
if (totalMandays > 0) parts.push(`共计消耗人力:<strong>${Math.round(totalMandays*10)/10}</strong> 人日。`);
parts.push(`具体完成了这些东西:`);
} else {
parts.push(`有 <strong>${matched.length}</strong> 项任务处于 "<strong>${status}</strong>" 状态。`);
if (totalMandays > 0) parts.push(`关联的人力评估:<strong>${Math.round(totalMandays*10)/10}</strong> 人日。`);
parts.push(`具体包含:`);
}
} else if (leader && status) {
parts.push(`<strong>${leader}</strong> 有 <strong>${matched.length}</strong> 项状态为 "<strong>${status}</strong>" 的任务。`);
if (totalMandays > 0) parts.push(`相关人力评估:<strong>${Math.round(totalMandays*10)/10}</strong> 人日。`);
parts.push(`相关任务:`);
}
if (taskNames.length > 0) {
let displayNames = taskNames.slice(0, 10).map(n => `「${n}」`).join('');
if (taskNames.length > 10) displayNames += ` 等等 (共${taskNames.length}个)`;
parts.push(`${displayNames}。`);
}
html += '<div style="line-height: 1.6;">' + parts.join(' ') + '</div>';
window._lastMatchedNodes = matched;
html += `<div style="margin-top:12px;display:flex;gap:8px;align-items:center;">
<button onclick="window.requestAISummary()" style="background:#22c55e;color:white;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-weight:500;font-size:13px;transition:background 0.2s;" onmouseover="this.style.background='#16a34a'" onmouseout="this.style.background='#22c55e'">🤖 请求 AI 深度总结</button>
<button onclick="window.configAI()" style="background:transparent;border:1px solid #bbf7d0;color:#166534;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:13px;transition:background 0.2s;" onmouseover="this.style.background='#dcfce7'" onmouseout="this.style.background='transparent'">⚙️ 配置 API</button>
</div>`;
html += '</div>';
return html;
}
window.configAI = function() {
let currentKey = localStorage.getItem('ai_api_key') || '{DEFAULT_AI_KEY}';
let currentUrl = localStorage.getItem('ai_base_url') || '{DEFAULT_AI_URL}';
let currentModel = localStorage.getItem('ai_model') || '{DEFAULT_AI_MODEL}';
let key = prompt('请输入您的 API Key (无需则留空或取消):', currentKey);
if (key === null) return;
let url = prompt('请输入 API Base URL (例如 https://api.openai.com/v1 或兼容地址):', currentUrl);
if (url === null) return;
let model = prompt('请输入模型名称 (例如 gpt-4o, deepseek-chat):', currentModel);
if (model === null) return;
localStorage.setItem('ai_api_key', key);
localStorage.setItem('ai_base_url', url);
localStorage.setItem('ai_model', model);
alert('AI 配置已成功保存在浏览器本地!');
};
async function fetchAndStreamAI(promptText, box) {
let key = localStorage.getItem('ai_api_key') || '{DEFAULT_AI_KEY}';
let url = localStorage.getItem('ai_base_url') || '{DEFAULT_AI_URL}';
let model = localStorage.getItem('ai_model') || '{DEFAULT_AI_MODEL}';
if (!key && url.includes('openai.com')) {
alert('系统检测到您未配置 API Key请先进行配置');
window.configAI();
key = localStorage.getItem('ai_api_key');
if (!key) return;
}
box.style.display = 'block';
box.textContent = '🚀 正在请求 AI这可能需要一点时间请稍等...\n如果长时间不响应请检查浏览器控制台报错或网络代理。';
try {
let fetchUrl = url;
if (fetchUrl.endsWith('/')) fetchUrl = fetchUrl.slice(0, -1);
if (!fetchUrl.endsWith('/chat/completions')) fetchUrl += '/chat/completions';
const response = await fetch(fetchUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + key
},
body: JSON.stringify({
model: model,
messages: [
{ role: 'system', content: '你是一位资深项目负责人,擅长做简洁、高价值的工作汇报,突出结果与业务价值,而非技术细节。' },
{ role: 'user', content: promptText }
],
stream: true,
temperature: 0.7
})
});
if (!response.ok) {
const errBox = await response.text();
box.textContent = `网络请求失败 (状态码 ${response.status}): \n${errBox}\n\n请确认提供的 API Base URL(${url}) 和 API Key 是否正确。`;
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let content = '';
box.textContent = '思考中...';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (line.trim().startsWith('data:') && line.trim() !== 'data: [DONE]') {
try {
let jsonStr = line.replace(/^data:\s*/, '');
const parsed = JSON.parse(jsonStr);
if (parsed.choices && parsed.choices[0].delta && parsed.choices[0].delta.content) {
content += parsed.choices[0].delta.content;
let displayContent = content.replace(/<think>[\s\S]*?(?:<\/think>\n*|$)/gi, '');
if (content.toLowerCase().includes('<think>') && !content.toLowerCase().includes('</think>')) {
displayContent = "🤔 正在深度思考中...\n\n" + displayContent;
}
box.textContent = displayContent;
box.scrollTop = box.scrollHeight;
}
} catch(e) {}
}
}
}
} catch(e) {
box.textContent = `发生异常: ${e.message}\n请检查网络连通性或 API 跨域(CORS)限制问题。`;
}
}
window.requestAISummary = function() {
const matched = window._lastMatchedNodes || [];
if (matched.length === 0) {
alert("当前视图没有任何任务记录,无需总结。");
return;
}
const tasksForAI = matched.map(m => ({
TaskName: m.taskName,
Status: m.status,
Priority: m.priority,
Leaders: m.leaders.map(l => l.name).join(','),
ProgressNotes: m.progressNotes || "",
Manday: m.manday || "0",
TargetCustomers: m.targetCustomers || ""
}));
const promptText = `请将以下多个任务整理汇报摘要:
要求:
1. 每个任务用3-4行总结须明确提及该任务相关的 TargetCustomers 目标客户信息)
2. 最后增加【整体情况总结】:
- 完成情况(完成/进行中)
- 当前重点方向(包含核心目标客户的总体进展)
输出结构:
(多个任务总结)
【整体情况总结】
- 数据如下:
${JSON.stringify(tasksForAI, null, 2)}`;
const box = document.getElementById('aiSummaryResult');
fetchAndStreamAI(promptText, box);
};
window.requestGlobalSummary = function() {
let allTasks = [];
tabData.forEach(tab => {
tab.records.forEach(r => {
const fields = r.fields || {};
const taskNameRaw = fields['Task'] || fields['Task Description'];
let taskName = '';
if (Array.isArray(taskNameRaw) && taskNameRaw.length > 0) taskName = taskNameRaw[0].text || '';
else taskName = taskNameRaw ? String(taskNameRaw) : 'Untitled Task';
let statusRaw = fields['Status'];
let status = '';
if (Array.isArray(statusRaw) && statusRaw.length > 0) status = statusRaw[0].text || '';
else status = statusRaw ? String(statusRaw) : '';
let targetCustomersRaw = fields['Target Customers'];
let targetCustomers = '';
if (Array.isArray(targetCustomersRaw)) {
targetCustomers = targetCustomersRaw.map(x => typeof x === 'object' ? (x.text || '') : String(x)).join(', ').trim();
} else if (targetCustomersRaw) {
targetCustomers = String(targetCustomersRaw).trim();
}
allTasks.push({
TabName: tab.name,
TaskName: taskName,
Status: status.trim(),
TargetCustomers: targetCustomers.trim()
});
});
});
if (allTasks.length === 0) {
alert("所有视图均没有任务记录,无需总结。");
return;
}
const promptText = `请将以下来自多个部门/维度的表格任务进行全局大总结:
要求:
1. 按照不同的 Tab (即数据来源) 独立提取核心进展与问题。
2. 特别留意并提炼出 TargetCustomers (目标客户) 的主要推行情况。
3. 最后给出一个【全局统筹结论】,指出哪些方向正常,哪些可能存在延期或需要重点关注。
4. 尽量言简意赅,不要只是把每一条数据罗列一遍,要有跨表分析和高层次的提炼。
- 数据如下:
${JSON.stringify(allTasks, null, 2)}`;
const box = document.getElementById('globalAiSummaryResult');
fetchAndStreamAI(promptText, box);
};
function render() {
const search = document.getElementById('searchInput').value;
const status = document.getElementById('statusFilter').value;
const leader = document.getElementById('leaderFilter').value;
const sortKey = document.getElementById('sortOrder').value;
let dfRaw = document.getElementById('dateFrom').value;
let dtRaw = document.getElementById('dateTo').value;
const dateFrom = dfRaw ? new Date(dfRaw).getTime() : -Infinity;
const dateTo = dtRaw ? new Date(dtRaw).getTime() + 86400000 - 1 : Infinity;
let nodeMap = {};
records.forEach(r => { nodeMap[r.id] = {...r, children: []}; });
let roots = [];
records.forEach(r => {
let n = nodeMap[r.id];
if (r.parentId && nodeMap[r.parentId]) nodeMap[r.parentId].children.push(n);
else roots.push(n);
});
// Sort logic inside roots recursively helps visual grouping
roots.sort((a,b) => b.children.length - a.children.length); // roots with children on top by default
let filtered = filterNodes(roots, search, status, leader, dateFrom, dateTo);
let sorted = sortNodes(filtered, sortKey);
let renderedHtml = renderHTML(sorted, true);
document.getElementById('tree-container').innerHTML = renderedHtml || '<p style="text-align:center;color:#64748b;margin-top:40px;">No matching records found.</p>';
let visibleCount = countNodes(sorted);
let isFilterActive = search !== '' || status !== '' || leader !== '';
let msg = `Showing ${visibleCount} / ${records.length} tasks`;
if (isFilterActive) {
msg += ' (Filtered)';
}
document.getElementById('statsDisplay').textContent = msg;
document.getElementById('summaryDisplay').innerHTML = generateSummary(filtered, status, leader);
// Clear AI Summary on any filter change so it doesn't show stale info
let aiBox = document.getElementById('aiSummaryResult');
if (aiBox) {
aiBox.style.display = 'none';
aiBox.textContent = '';
}
}
// Initialize tabs and first render
initTabs();
</script>
</body>
</html>
"""
def build_html(client, access_token):
tab_data_list = []
for tab in TABS:
logging.info(f"Fetching records for tab [{tab['name']}]...")
records = get_all_records(client, access_token, tab['app_token'], tab['table_id'])
logging.info(f"Tab [{tab['name']}] retrieved {len(records)} records.")
tab_data_list.append({
"name": tab['name'],
"records": records
})
# Dump records to json
records_json = json.dumps(tab_data_list, ensure_ascii=False)
final_html = HTML_TEMPLATE.replace('{RECORDS_JSON}', records_json)
final_html = final_html.replace('{DEFAULT_AI_KEY}', config.AI_API_KEY or '')
final_html = final_html.replace('{DEFAULT_AI_URL}', config.AI_BASE_URL or 'https://api.openai.com/v1')
final_html = final_html.replace('{DEFAULT_AI_MODEL}', config.AI_MODEL or 'gpt-4o')
return final_html
def main():
client = api.Client(config.LARK_HOST)
try:
access_token = client.get_tenant_access_token(config.APP_ID, config.APP_SECRET)
except Exception as e:
logging.error(f"Could not get access token: {e}")
return
final_html = build_html(client, access_token)
output_file = "outline_view.html"
with open(output_file, "w", encoding="utf-8") as f:
f.write(final_html)
logging.info(f"Successfully generated HTML view at {output_file}")
print(f"\n✅ HTML view generated! You can open '/home/verachen/Workspace/feishu/bitable_calendar/python/{output_file}' in your browser.")
if __name__ == "__main__":
main()