Files
feishu_bitable/export_web_view.py
2026-03-31 17:24:01 +08:00

1054 lines
42 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
<button id="refreshData" style="background:#3b82f6;color:white;border:none;" onclick="window.refreshDataAPI()">🔄 Refresh</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>
<div id="settingsModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.5); z-index:9999; align-items:center; justify-content:center;">
<div style="background:white; padding:24px; border-radius:12px; width:600px; max-width:90%; max-height:90vh; overflow-y:auto; box-shadow:0 10px 25px rgba(0,0,0,0.2); max-height: 80vh;">
<h2 style="margin-top:0;">配置 AI 参数与 Prompts</h2>
<label style="display:block; margin-top:12px; font-weight:bold;">API Key</label>
<input type="text" id="settingApiKey" style="width:100%; padding:8px; margin-top:4px; border:1px solid #ccc; border-radius:4px; box-sizing: border-box;" />
<label style="display:block; margin-top:12px; font-weight:bold;">API Base URL <span style="font-weight:normal; font-size:12px; color:#64748b;">(如 https://api.openai.com/v1)</span></label>
<input type="text" id="settingApiUrl" style="width:100%; padding:8px; margin-top:4px; border:1px solid #ccc; border-radius:4px; box-sizing: border-box;" />
<label style="display:block; margin-top:12px; font-weight:bold;">Model <span style="font-weight:normal; font-size:12px; color:#64748b;">(如 gpt-4o, deepseek-chat)</span></label>
<input type="text" id="settingApiModel" style="width:100%; padding:8px; margin-top:4px; border:1px solid #ccc; border-radius:4px; box-sizing: border-box;" />
<label style="display:block; margin-top:12px; font-weight:bold;">System Prompt</label>
<textarea id="settingSystemPrompt" rows="3" style="width:100%; padding:8px; margin-top:4px; border:1px solid #ccc; border-radius:4px; font-family:inherit; box-sizing: border-box;"></textarea>
<label style="display:block; margin-top:12px; font-weight:bold;">Tab Prompt (Tab AI Summary)</label>
<textarea id="settingTabPrompt" rows="5" style="width:100%; padding:8px; margin-top:4px; border:1px solid #ccc; border-radius:4px; font-family:inherit; box-sizing: border-box;"></textarea>
<label style="display:block; margin-top:12px; font-weight:bold;">Global Prompt (Global AI Summary)</label>
<textarea id="settingGlobalPrompt" rows="5" style="width:100%; padding:8px; margin-top:4px; border:1px solid #ccc; border-radius:4px; font-family:inherit; box-sizing: border-box;"></textarea>
<div style="margin-top:20px; display:flex; justify-content:flex-end; gap:12px;">
<button onclick="document.getElementById('settingsModal').style.display='none'" style="padding:8px 16px; border:1px solid #ccc; background:white; cursor:pointer; border-radius:6px;">取消</button>
<button onclick="window.saveSettings()" style="padding:8px 16px; border:none; background:#3b82f6; color:white; cursor:pointer; border-radius:6px;">保存</button>
</div>
</div>
</div>
<script>
const defaultSystemPrompt = '你是一位资深项目负责人,擅长做简洁、高价值的工作汇报,突出结果与业务价值,而非技术细节。';
const defaultTabPrompt = `请将以下多个任务整理汇报摘要:\n要求\n1. 每个任务用3-4行总结须明确提及该任务相关的 TargetCustomers 目标客户信息)\n2. 最后增加【整体情况总结】:\n- 完成情况(完成/进行中)\n- 当前重点方向(包含核心目标客户的总体进展)\n\n输出结构\n多个任务总结\n\n【整体情况总结】`;
const defaultGlobalPrompt = `请将以下来自多个部门/维度的表格任务进行全局大总结:\n要求\n1. 按照不同的 Tab (即数据来源) 独立提取核心进展与问题。\n2. 特别留意并提炼出 TargetCustomers (目标客户) 的主要推行情况。\n3. 最后给出一个【全局统筹结论】,指出哪些方向正常,哪些可能存在延期或需要重点关注。\n4. 尽量言简意赅,不要只是把每一条数据罗列一遍,要有跨表分析和高层次的提炼。`;
const tabData = {RECORDS_JSON}; // Array of {name, records}
let rawRecords = [];
let records = [];
window.refreshDataAPI = async function() {
const btn = document.getElementById('refreshData');
if (!btn) return;
const originalText = btn.innerHTML;
btn.innerHTML = '⏳ Pulling latest Feishu data (may take tens of seconds)...';
btn.disabled = true;
try {
const res = await fetch('/api/refresh', {method: 'GET'});
if (!res.ok) {
let msg = res.statusText;
try {
const data = await res.json();
if(data.message) msg = data.message;
} catch(e) {}
alert('Refresh failed: ' + msg + '\\nIt might be a timeout connecting to Feishu, please try again later.');
} else {
alert('Refresh successful! The page will reload to display the latest data.');
window.location.href = '/';
}
} catch(e) {
alert('Refresh request error: ' + e.message + '\\nIf using local files, please ensure the backend server.py is running.');
} finally {
btn.innerHTML = originalText;
btn.disabled = false;
}
};
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 calculateNodeManday(node, coveredByParentWithManday) {
let manday = 0;
let hasManday = false;
if (node.manday) {
let val = parseFloat(node.manday);
if (!isNaN(val) && val > 0) {
// Rule: Divide by number of leaders
let divisor = (node.leaders && node.leaders.length > 0) ? node.leaders.length : 1;
manday = val / divisor;
hasManday = true;
}
}
if (node.isMatch) {
if (coveredByParentWithManday) return 0; // Already counted at a higher level
if (hasManday) return manday; // This node matches and has its own manday, use it and ignore descendants' manday
// Match but no manday on this node, accumulate from matching descendants
return node.children.reduce((acc, child) => acc + calculateNodeManday(child, false), 0);
} else {
// This node doesn't match, check descendants.
// If a parent match already covered this branch, we still pass that info down.
return node.children.reduce((acc, child) => acc + calculateNodeManday(child, coveredByParentWithManday), 0);
}
}
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 = nodes.reduce((sum, n) => sum + calculateNodeManday(n, false), 0);
let completed = 0;
let ongoing = 0;
let taskNames = [];
matched.forEach(r => {
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 currentSys = localStorage.getItem('ai_sys_prompt') || defaultSystemPrompt;
let currentTab = localStorage.getItem('ai_tab_prompt') || defaultTabPrompt;
let currentGlobal = localStorage.getItem('ai_global_prompt') || defaultGlobalPrompt;
document.getElementById('settingApiKey').value = currentKey;
document.getElementById('settingApiUrl').value = currentUrl;
document.getElementById('settingApiModel').value = currentModel;
document.getElementById('settingSystemPrompt').value = currentSys;
document.getElementById('settingTabPrompt').value = currentTab;
document.getElementById('settingGlobalPrompt').value = currentGlobal;
document.getElementById('settingsModal').style.display = 'flex';
};
window.saveSettings = function() {
localStorage.setItem('ai_api_key', document.getElementById('settingApiKey').value);
localStorage.setItem('ai_base_url', document.getElementById('settingApiUrl').value);
localStorage.setItem('ai_model', document.getElementById('settingApiModel').value);
localStorage.setItem('ai_sys_prompt', document.getElementById('settingSystemPrompt').value);
localStorage.setItem('ai_tab_prompt', document.getElementById('settingTabPrompt').value);
localStorage.setItem('ai_global_prompt', document.getElementById('settingGlobalPrompt').value);
document.getElementById('settingsModal').style.display = 'none';
};
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}';
let sysPrompt = localStorage.getItem('ai_sys_prompt') || defaultSystemPrompt;
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: sysPrompt },
{ 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 || ""
}));
let tabPrompt = localStorage.getItem('ai_tab_prompt') || defaultTabPrompt;
const promptText = `${tabPrompt}
- 数据如下:
${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;
}
let globalPrompt = localStorage.getItem('ai_global_prompt') || defaultGlobalPrompt;
const promptText = `${globalPrompt}
- 数据如下:
${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()