# 短邮系统实施 - 第三步
## 桌面端集成 + 数据同步验证
**版本**: v1.0
**预计时间**: 2-3小时
**目标**: 在项目详情页集成决策交流功能,验证桌面与移动端数据同步
---
## 🎯 第三步目标
在 `project-detail.html` 中集成决策交流功能:
1. ⬜ 在项目详情页添加"决策交流"标签页
2. ⬜ 展示项目的所有决策记录(时间线视图)
3. ⬜ 支持桌面端创建决策请求
4. ⬜ 支持桌面端响应决策
5. ⬜ 验证与移动端数据同步
---
## 任务1: 分析现有页面结构
### 1.1 读取 project-detail.html
了解现有的标签页结构、样式和JavaScript。
### 1.2 确定集成位置
在现有标签页中添加新的"决策交流"标签:
- 概况
- 任务
- 成员
- **决策交流** ⭐NEW
- 设置
---
## 任务2: 添加决策交流标签页
### 2.1 HTML结构
在标签页导航中添加:
```html
<div class="project-tabs">
<button class="tab-btn active" data-tab="overview">概况</button>
<button class="tab-btn" data-tab="tasks">任务</button>
<button class="tab-btn" data-tab="members">成员</button>
<button class="tab-btn" data-tab="decisions">决策交流</button>
<button class="tab-btn" data-tab="settings">设置</button>
</div>
```
添加标签页内容区域:
```html
<div id="decisions" class="tab-content">
<div class="decisions-header">
<h2>决策交流</h2>
<button class="btn-primary" onclick="openDecisionRequestDialog()">
<span>📤</span> 创建决策请求
</button>
</div>
<div class="decisions-filter">
<button class="filter-btn active" data-filter="all">全部</button>
<button class="filter-btn" data-filter="active">待响应</button>
<button class="filter-btn" data-filter="responded">已响应</button>
</div>
<div class="decisions-timeline" id="decisions-timeline">
<!-- 动态加载内容 -->
</div>
</div>
```
### 2.2 CSS样式
```css
/* 决策交流标签页 */
.decisions-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.decisions-header h2 {
font-size: 24px;
font-weight: 700;
color: #333;
}
.decisions-filter {
display: flex;
gap: 10px;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #eee;
}
.filter-btn {
padding: 8px 20px;
background: white;
border: 2px solid #eee;
border-radius: 20px;
font-size: 14px;
color: #666;
cursor: pointer;
transition: all 0.3s;
}
.filter-btn:hover {
border-color: #667eea;
color: #667eea;
}
.filter-btn.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-color: #667eea;
color: white;
}
/* 决策时间线 */
.decisions-timeline {
position: relative;
}
.decision-thread {
background: white;
border-radius: 15px;
padding: 25px;
margin-bottom: 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
border-left: 4px solid #667eea;
}
.decision-thread.responded {
border-left-color: #4ade80;
}
.thread-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 15px;
}
.thread-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.thread-meta {
display: flex;
gap: 15px;
font-size: 13px;
color: #999;
}
.thread-status {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.thread-status.active {
background: #fef3c7;
color: #d97706;
}
.thread-status.responded {
background: #dcfce7;
color: #16a34a;
}
.thread-content {
margin-bottom: 20px;
color: #666;
line-height: 1.6;
}
.thread-timeline {
display: flex;
flex-direction: column;
gap: 15px;
padding-left: 20px;
border-left: 2px solid #eee;
margin-left: 10px;
}
.timeline-item {
position: relative;
}
.timeline-item::before {
content: '';
position: absolute;
left: -26px;
top: 5px;
width: 12px;
height: 12px;
background: white;
border: 3px solid #667eea;
border-radius: 50%;
}
.timeline-item.response::before {
border-color: #4ade80;
}
.timeline-header {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 5px;
}
.timeline-icon {
font-size: 18px;
}
.timeline-user {
font-weight: 600;
color: #333;
}
.timeline-action {
color: #999;
font-size: 14px;
}
.timeline-time {
color: #999;
font-size: 12px;
}
.timeline-content {
color: #666;
line-height: 1.5;
margin-top: 5px;
}
.ai-suggestion-box {
background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%);
border-radius: 10px;
padding: 15px;
margin-top: 15px;
}
.ai-label {
font-size: 12px;
font-weight: 600;
color: #667eea;
margin-bottom: 8px;
}
.ai-options {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.ai-option-btn {
padding: 6px 14px;
background: white;
border: 2px solid #667eea;
border-radius: 15px;
font-size: 13px;
color: #667eea;
cursor: pointer;
transition: all 0.3s;
}
.ai-option-btn:hover {
background: #667eea;
color: white;
}
/* 响应表单 */
.response-form {
background: #f9fafb;
border-radius: 10px;
padding: 15px;
margin-top: 15px;
}
.response-form textarea {
width: 100%;
padding: 12px;
border: 2px solid #e5e7eb;
border-radius: 8px;
font-size: 14px;
font-family: inherit;
resize: vertical;
min-height: 80px;
margin-bottom: 10px;
}
.response-form textarea:focus {
outline: none;
border-color: #667eea;
}
.response-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
```
---
## 任务3: 实现JavaScript功能
### 3.1 加载决策记录
```javascript
let currentProjectId = null;
let decisionsData = [];
async function loadProjectDecisions(projectId, filter = 'all') {
currentProjectId = projectId;
try {
showLoading('decisions-timeline');
const token = localStorage.getItem('token');
const response = await fetch(`/api/pwp/project/${projectId}/decisions`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await response.json();
if (data.success) {
decisionsData = data.records;
renderDecisions(decisionsData, filter);
} else {
showEmpty('decisions-timeline', '加载失败');
}
} catch (error) {
console.error('Failed to load decisions:', error);
showEmpty('decisions-timeline', '加载失败,请重试');
}
}
function renderDecisions(records, filter = 'all') {
// 按conversationId分组
const conversations = groupDecisionsByConversation(records);
// 过滤
let filtered = conversations;
if (filter === 'active') {
filtered = conversations.filter(c => c.status === 'active');
} else if (filter === 'responded') {
filtered = conversations.filter(c => c.status === 'responded');
}
if (filtered.length === 0) {
showEmpty('decisions-timeline', '暂无决策记录');
return;
}
const container = document.getElementById('decisions-timeline');
container.innerHTML = filtered.map(conv => renderDecisionThread(conv)).join('');
}
function groupDecisionsByConversation(records) {
const grouped = {};
records.forEach(record => {
const convId = record.eventData.conversationId;
if (!convId) return;
if (!grouped[convId]) {
grouped[convId] = {
conversationId: convId,
records: [],
status: 'active',
summary: '',
latestTime: null
};
}
grouped[convId].records.push(record);
// 更新状态
if (record.status === 'responded') {
grouped[convId].status = 'responded';
}
// 更新摘要(从第一条记录获取)
if (record.eventType.includes('requested') && record.eventData.summary) {
grouped[convId].summary = record.eventData.summary;
}
// 更新最新时间
const recordTime = new Date(record.occurredAt);
if (!grouped[convId].latestTime || recordTime > grouped[convId].latestTime) {
grouped[convId].latestTime = recordTime;
}
});
// 转换为数组并排序
return Object.values(grouped)
.map(conv => {
conv.records.sort((a, b) => new Date(a.occurredAt) - new Date(b.occurredAt));
return conv;
})
.sort((a, b) => b.latestTime - a.latestTime);
}
function renderDecisionThread(conversation) {
const firstRecord = conversation.records[0];
const isActive = conversation.status === 'active';
return `
<div class="decision-thread ${conversation.status}">
<div class="thread-header">
<div>
<div class="thread-title">${conversation.summary || '决策交流'}</div>
<div class="thread-meta">
<span>👤 ${firstRecord.user.username}</span>
<span>📅 ${formatDateTime(firstRecord.occurredAt)}</span>
<span class="thread-status ${conversation.status}">
${conversation.status === 'active' ? '待响应' : '已响应'}
</span>
</div>
</div>
</div>
${firstRecord.eventData.content?.text ? `
<div class="thread-content">
${firstRecord.eventData.content.text}
</div>
` : ''}
<div class="thread-timeline">
${conversation.records.map(record => renderTimelineItem(record, isActive)).join('')}
</div>
${isActive && canRespond(firstRecord) ? `
<div class="response-form">
${renderResponseForm(firstRecord)}
</div>
` : ''}
</div>
`;
}
function renderTimelineItem(record, isActive) {
const isRequest = record.eventType.includes('requested');
const isResponse = record.eventType.includes('made') || record.eventType.includes('responded');
return `
<div class="timeline-item ${isResponse ? 'response' : 'request'}">
<div class="timeline-header">
<span class="timeline-icon">${getEventIcon(record.eventType)}</span>
<span class="timeline-user">${record.user.username}</span>
<span class="timeline-action">${getEventTitle(record.eventType)}</span>
<span class="timeline-time">${formatDateTime(record.occurredAt)}</span>
</div>
${isResponse && record.eventData.decision ? `
<div class="timeline-content">
<strong>决策:</strong>${record.eventData.decision}
${record.eventData.comment ? `<br><strong>评论:</strong>${record.eventData.comment}` : ''}
</div>
` : ''}
${isRequest && isActive && record.eventData.aiSuggestions ? `
<div class="ai-suggestion-box">
<div class="ai-label">🤖 AI建议</div>
<div class="ai-options">
${record.eventData.aiSuggestions.options.map(opt =>
`<button class="ai-option-btn" onclick="quickRespond('${record.id}', '${opt}')">${opt}</button>`
).join('')}
</div>
</div>
` : ''}
</div>
`;
}
function canRespond(record) {
const currentUser = JSON.parse(localStorage.getItem('user'));
return record.eventData.toUserId === currentUser.id;
}
function renderResponseForm(record) {
return `
<textarea id="response-comment-${record.id}" placeholder="添加你的评论(可选)"></textarea>
<div class="response-actions">
<button class="btn-secondary" onclick="cancelResponse('${record.id}')">取消</button>
<button class="btn-primary" onclick="submitResponse('${record.id}')">提交响应</button>
</div>
`;
}
```
### 3.2 快速响应
```javascript
async function quickRespond(requestId, decision) {
await submitDecisionResponse(requestId, decision, null);
}
async function submitResponse(requestId) {
const comment = document.getElementById(`response-comment-${requestId}`).value;
const decision = prompt('请输入你的决策:');
if (!decision) return;
await submitDecisionResponse(requestId, decision, comment);
}
async function submitDecisionResponse(requestId, decision, comment) {
const token = localStorage.getItem('token');
try {
const response = await fetch('/api/pwp/decision-responses', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
requestId,
decision,
comment
})
});
const data = await response.json();
if (data.success) {
showToast('✅ 响应已提交');
// 重新加载决策记录
loadProjectDecisions(currentProjectId);
} else {
showToast('提交失败: ' + data.error, 'error');
}
} catch (error) {
console.error('Response error:', error);
showToast('网络错误,请重试', 'error');
}
}
```
### 3.3 创建决策请求对话框
```javascript
async function openDecisionRequestDialog() {
// 获取项目成员
const members = await loadProjectMembers(currentProjectId);
const currentUser = JSON.parse(localStorage.getItem('user'));
const availableMembers = members.filter(m => m.userId !== currentUser.id);
// 显示对话框(使用现有的模态框或创建新的)
const dialog = document.createElement('div');
dialog.className = 'modal-overlay';
dialog.innerHTML = `
<div class="modal-dialog">
<div class="modal-header">
<h3>创建决策请求</h3>
<button class="close-btn" onclick="closeDialog()">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>接收人</label>
<select id="dialog-receiver">
${availableMembers.map(m =>
`<option value="${m.userId}">${m.user.username}</option>`
).join('')}
</select>
</div>
<div class="form-group">
<label>决策类型</label>
<select id="dialog-type">
<option value="DECISION_REQUIRED">一般决策</option>
<option value="TASK_ASSIGNMENT">任务分配</option>
<option value="DELIVERABLE_SUBMISSION">交付物提交</option>
<option value="FEEDBACK_REQUEST">反馈请求</option>
</select>
</div>
<div class="form-group">
<label>摘要</label>
<input type="text" id="dialog-summary" placeholder="简短描述决策事项">
</div>
<div class="form-group">
<label>详细内容</label>
<textarea id="dialog-content" rows="5" placeholder="详细描述背景和需要决策的内容"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="closeDialog()">取消</button>
<button class="btn-primary" onclick="submitDecisionRequest()">发送</button>
</div>
</div>
`;
document.body.appendChild(dialog);
}
async function submitDecisionRequest() {
const toUserId = document.getElementById('dialog-receiver').value;
const protocolType = document.getElementById('dialog-type').value;
const summary = document.getElementById('dialog-summary').value;
const content = document.getElementById('dialog-content').value;
if (!summary) {
alert('请填写摘要');
return;
}
const token = localStorage.getItem('token');
try {
const response = await fetch('/api/pwp/decision-requests', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
toUserId,
projectId: currentProjectId,
summary,
content: content ? { text: content } : null,
protocolType
})
});
const data = await response.json();
if (data.success) {
closeDialog();
showToast('✅ 决策请求已发送');
loadProjectDecisions(currentProjectId);
} else {
showToast('发送失败: ' + data.error, 'error');
}
} catch (error) {
console.error('Submit error:', error);
showToast('网络错误,请重试', 'error');
}
}
```
---
## 任务4: 数据同步验证
### 4.1 测试场景
**场景1: 桌面创建 → 移动查看**
1. 在桌面端project-detail.html创建决策请求
2. 在移动端shortmail-app.html查看收件箱
3. 验证数据同步
**场景2: 移动响应 → 桌面查看**
1. 在移动端响应决策
2. 在桌面端刷新决策交流标签
3. 验证响应显示
**场景3: 决策链一致性**
1. 在移动端查看决策链
2. 在桌面端查看决策交流
3. 验证数据完全一致
---
## 验收标准
```
✅ 桌面端集成
├─ 决策交流标签页添加成功
├─ 样式美观且与现有页面一致
├─ 响应式设计(桌面端优化)
└─ 与移动端视觉风格呼应
✅ 功能完整
├─ 加载项目决策记录
├─ 按对话分组显示
├─ 时间线可视化
├─ 创建决策请求
├─ 快速响应决策
└─ 添加评论响应
✅ 数据同步
├─ 桌面端创建 → 移动端可见
├─ 移动端响应 → 桌面端可见
├─ 决策链完整一致
└─ 实时更新(刷新后)
✅ 用户体验
├─ 加载状态友好
├─ 空状态提示清晰
├─ 错误处理完善
└─ Toast提示及时
```
---
## 下一步
Step 3 完成后,继续:
- **Step 4**: 全面测试和优化
- **部署**: 准备生产环境部署
---
**预计时间**: 2-3小时
**【五行属性】**: 🌳木(桌面端实现) + 🏔️土(数据一致性)
**【道法术势器】**: 器(完善产品矩阵)
**【心法】**: 桌面移动双端器,数据同源势相通