feat: 聚会人员动态管理与排行榜美化

- 支持聚会创建后随时增减参与人员(进行中/已完成均可)
- 美化参与次数排行榜界面,采用渐变进度条风格
- 新上传图片改用相对路径,避免端口变化导致图片无法加载
- 修复外网访问的 ALLOWED_HOSTS 配置

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Developer
2026-03-18 09:15:58 +08:00
co-authored by Claude Opus 4.6
parent 0a50c09dba
commit f1994c869d
4 changed files with 291 additions and 22 deletions
+17
View File
@@ -0,0 +1,17 @@
# Zhangmenu 项目
聚会菜单管理应用
## 启动服务
```bash
python3 manage.py runserver 0.0.0.0:9300
```
访问地址: http://你的IP:9300
## 技术栈
- Django + Django REST Framework
- Bootstrap 5
- SQLite
+1 -1
View File
@@ -48,7 +48,7 @@ def upload_image(request):
media_path = str(saved_path).lstrip('/') media_path = str(saved_path).lstrip('/')
media_url = urljoin(settings.MEDIA_URL, media_path) media_url = urljoin(settings.MEDIA_URL, media_path)
return JsonResponse({'url': request.build_absolute_uri(media_url)}) return JsonResponse({'url': media_url})
class DishCategoryViewSet(viewsets.ModelViewSet): class DishCategoryViewSet(viewsets.ModelViewSet):
+272 -20
View File
@@ -3575,6 +3575,161 @@
gap: 8px; gap: 8px;
} }
/* Participant editing in detail modal */
.participant-edit-wrapper {
margin-top: 8px;
}
.participant-edit-input .form-control {
background: rgba(255,255,255,0.9);
border: 1px solid #e5d3c3;
border-radius: 20px;
padding: 8px 16px;
font-size: 14px;
}
.participant-edit-input .form-control:focus {
background: #fff;
border-color: #c9a86c;
box-shadow: 0 0 0 3px rgba(201,168,108,0.15);
}
.participant-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: linear-gradient(135deg, #f8f4ed 0%, #efe6d8 100%);
border: 1px solid #e5d3c3;
border-radius: 20px;
font-size: 14px;
color: #6b5344;
animation: tagAppear 0.2s ease-out;
}
@keyframes tagAppear {
from { opacity: 0; transform: scale(0.8); }
to { opacity: 1; transform: scale(1); }
}
.participant-remove-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: none;
background: rgba(180,140,100,0.2);
border-radius: 50%;
font-size: 14px;
line-height: 1;
color: #8b6f5a;
cursor: pointer;
transition: all 0.15s;
}
.participant-remove-btn:hover {
background: #e74c3c;
color: white;
}
/* Ranking styles */
.ranking-item {
margin-bottom: 16px;
padding: 12px 14px;
background: rgba(255,255,255,0.5);
border-radius: 12px;
border: 1px solid rgba(0,0,0,0.04);
transition: transform 0.2s, box-shadow 0.2s;
}
.ranking-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
}
.ranking-item.rank-gold {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
border-color: #fbbf24;
}
.ranking-item.rank-silver {
background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%);
border-color: #94a3b8;
}
.ranking-item.rank-bronze {
background: linear-gradient(135deg, #fef3c7 0%, #fed7aa 100%);
border-color: #d97706;
}
.ranking-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.ranking-rank {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
padding: 0 6px;
background: #e5e7eb;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
color: #6b7280;
}
.rank-gold .ranking-rank {
background: linear-gradient(135deg, #fbbf24, #f59e0b);
color: #78350f;
}
.rank-silver .ranking-rank {
background: linear-gradient(135deg, #94a3b8, #64748b);
color: white;
}
.rank-bronze .ranking-rank {
background: linear-gradient(135deg, #d97706, #b45309);
color: white;
}
.ranking-name {
flex: 1;
font-size: 15px;
font-weight: 500;
color: #374151;
}
.ranking-count {
font-size: 14px;
font-weight: 600;
color: #6b7280;
}
.rank-gold .ranking-count {
color: #b45309;
}
.ranking-progress {
height: 10px;
background: rgba(0,0,0,0.06);
border-radius: 5px;
overflow: hidden;
}
.ranking-bar {
height: 100%;
border-radius: 5px;
transition: width 0.5s ease-out;
}
.ingredient-row { .ingredient-row {
margin: 0 0 6px; margin: 0 0 6px;
padding: 12px 10px; padding: 12px 10px;
@@ -4391,7 +4546,12 @@
<i class="bi bi-people-fill"></i> <i class="bi bi-people-fill"></i>
参与人员 参与人员
</h3> </h3>
<div id="order-detail-participants"></div> <div class="participant-edit-wrapper">
<div class="input-group participant-edit-input">
<input type="text" class="form-control" id="detail-participant-input" placeholder="输入人名后回车添加">
</div>
<div id="detail-participants-tags" class="d-flex flex-wrap gap-2 mt-2"></div>
</div>
</div> </div>
<div class="order-detail-section" id="order-ingredients-section"> <div class="order-detail-section" id="order-ingredients-section">
<h3 class="order-detail-section-title"> <h3 class="order-detail-section-title">
@@ -5767,15 +5927,13 @@
ingContainer.innerHTML = '<p class="detail-empty-note">暂时还没有汇总出食材,等选好菜后这里会自动整理。</p>'; ingContainer.innerHTML = '<p class="detail-empty-note">暂时还没有汇总出食材,等选好菜后这里会自动整理。</p>';
} }
// Render participants // Render participants with edit capability
const participantsContainer = document.getElementById('order-detail-participants'); window.detailOrderParticipants = order && order.participants ? [...order.participants] : [];
if (order.participants && order.participants.length > 0) { renderDetailParticipantTags();
participantsContainer.innerHTML = `<div class="participants-wrap">${order.participants.map(name => ` setupDetailParticipantInput();
<span class="participant-chip">${name}</span>
`).join('')}</div>`; // Update summary to reflect changes
} else { updateDetailSummary();
participantsContainer.innerHTML = '<p class="detail-empty-note">这次还没记录参与人。</p>';
}
document.getElementById('btn-complete-order').style.display = order.status === 'in_progress' ? 'block' : 'none'; document.getElementById('btn-complete-order').style.display = order.status === 'in_progress' ? 'block' : 'none';
@@ -5792,6 +5950,89 @@
new bootstrap.Modal(document.getElementById('order-detail-modal')).show(); new bootstrap.Modal(document.getElementById('order-detail-modal')).show();
} }
function renderDetailParticipantTags() {
const container = document.getElementById('detail-participants-tags');
if (!container) return;
container.innerHTML = window.detailOrderParticipants.map(name => `
<span class="participant-tag">
${name}
<button type="button" class="participant-remove-btn" onclick="removeDetailParticipant('${name}')">×</button>
</span>
`).join('');
}
function setupDetailParticipantInput() {
const input = document.getElementById('detail-participant-input');
if (!input) return;
input.onkeypress = function(e) {
if (e.key === 'Enter' && this.value.trim()) {
e.preventDefault();
const name = this.value.trim();
if (name && !window.detailOrderParticipants.includes(name)) {
window.detailOrderParticipants.push(name);
renderDetailParticipantTags();
saveDetailParticipants();
}
this.value = '';
}
};
}
function removeDetailParticipant(name) {
window.detailOrderParticipants = window.detailOrderParticipants.filter(p => p !== name);
renderDetailParticipantTags();
saveDetailParticipants();
}
async function saveDetailParticipants() {
const modal = document.getElementById('order-detail-modal');
const orderId = modal.dataset.orderId;
if (!orderId) return;
try {
const res = await fetch(`${API_BASE}/orders/${orderId}/`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ participants: window.detailOrderParticipants })
});
if (res.ok) {
const updatedOrder = await res.json();
// Update local orders cache
const idx = orders.findIndex(o => o.id == orderId);
if (idx !== -1) {
orders[idx] = updatedOrder;
}
updateDetailSummary();
// Refresh ranking if exists
if (window.participantRanking) {
calculateParticipantRanking();
if (document.getElementById('participant-ranking-modal').classList.contains('show')) {
showParticipantRanking();
}
}
}
} catch (err) {
console.error('Failed to save participants:', err);
}
}
function updateDetailSummary() {
const modal = document.getElementById('order-detail-modal');
const orderId = modal.dataset.orderId;
const order = orders.find(o => o.id == orderId);
if (!order) return;
const dishCount = order.dishes_detail ? order.dishes_detail.length : 0;
const participantCount = window.detailOrderParticipants ? window.detailOrderParticipants.length : 0;
const summaryEl = document.getElementById('order-detail-summary');
if (summaryEl) {
summaryEl.textContent = order.status === 'in_progress'
? `这张备菜清单里有 ${dishCount} 道菜${participantCount > 0 ? `,共 ${participantCount} 位参与人` : ''}`
: `这次聚会已经完成,留下了 ${dishCount} 道上桌菜品${participantCount > 0 ? `${participantCount} 位参与人` : ''}的记录。`;
}
}
// Render party images // Render party images
function renderOrderImages(order) { function renderOrderImages(order) {
@@ -5996,19 +6237,30 @@
const maxCount = Math.max(...ranking.map(r => r.count)); const maxCount = Math.max(...ranking.map(r => r.count));
container.innerHTML = ranking.map((item, index) => { container.innerHTML = ranking.map((item, index) => {
const percentage = maxCount > 0 ? (item.count / maxCount) * 100 : 0; const percentage = maxCount > 0 ? (item.count / maxCount) * 100 : 0;
let rankBadge = ''; let rankClass = '';
if (index === 0) rankBadge = '<span class="badge bg-warning text-dark ms-2">🥇</span>'; let gradient = '';
else if (index === 1) rankBadge = '<span class="badge bg-secondary ms-2">🥈</span>'; if (index === 0) {
else if (index === 2) rankBadge = '<span class="badge bg-brown ms-2">🥉</span>'; rankClass = 'rank-gold';
gradient = 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #d97706 100%)';
} else if (index === 1) {
rankClass = 'rank-silver';
gradient = 'linear-gradient(135deg, #94a3b8 0%, #64748b 100%)';
} else if (index === 2) {
rankClass = 'rank-bronze';
gradient = 'linear-gradient(135deg, #d97706 0%, #b45309 100%)';
} else {
gradient = 'linear-gradient(90deg, #6366f1 0%, #8b5cf6 100%)';
}
return ` return `
<div class="mb-3"> <div class="ranking-item ${rankClass}">
<div class="d-flex justify-content-between align-items-center mb-1"> <div class="ranking-header">
<span class="fw-medium">${index + 1}. ${item.name} ${rankBadge}</span> <span class="ranking-rank">${index + 1}</span>
<span class="text-muted">${item.count}</span> <span class="ranking-name">${item.name}</span>
<span class="ranking-count">${item.count}次</span>
</div> </div>
<div class="progress" style="height: 12px; border-radius: 6px;"> <div class="ranking-progress">
<div class="progress-bar" style="width: ${percentage}%; background: linear-gradient(90deg, #4f46e5, #7c3aed); border-radius: 6px;"></div> <div class="ranking-bar" style="width: ${percentage}%; background: ${gradient};"></div>
</div> </div>
</div> </div>
`; `;
+1 -1
View File
@@ -28,7 +28,7 @@ DEBUG = os.getenv('DJANGO_DEBUG', 'true').lower() in {'1', 'true', 'yes', 'on'}
ALLOWED_HOSTS = [ ALLOWED_HOSTS = [
host.strip() host.strip()
for host in os.getenv('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1,testserver').split(',') for host in os.getenv('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1,testserver,121.41.188.238').split(',')
if host.strip() if host.strip()
] ]