1054 lines
42 KiB
Python
1054 lines
42 KiB
Python
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()
|