2025-04-09 20:34:32 +08:00

665 lines
28 KiB
JavaScript

document.addEventListener('DOMContentLoaded', function() {
// --- Global Variables & State ---
let jwtToken = localStorage.getItem('jwtToken'); // Load token from storage
let currentUserId = null; // Will be set after login or token validation
let milkDataCache = []; // Cache loaded milk data
// --- DOM Elements ---
const addMilkBtn = document.getElementById('add-milk-btn');
const recordConsumptionBtn = document.getElementById('record-consumption-btn');
const settingsBtn = document.getElementById('settings-btn');
const actionsContainer = document.querySelector('.actions');
const backToDashboardBtn = document.getElementById('back-to-dashboard-btn');
const logoutBtn = document.getElementById('logout-btn');
const addMilkModal = document.getElementById('add-milk-modal');
const recordConsumptionModal = document.getElementById('record-consumption-modal');
const authModal = document.getElementById('auth-modal'); // Auth modal
const closeAddModalBtn = document.getElementById('close-add-modal');
const closeRecordModalBtn = document.getElementById('close-record-modal');
const closeAuthModalBtn = document.getElementById('close-auth-modal'); // Auth close
const dashboardPage = document.getElementById('dashboard');
const settingsPage = document.getElementById('settings-page');
const addMilkForm = document.getElementById('add-milk-form');
const recordConsumptionForm = document.getElementById('record-consumption-form');
const settingsForm = document.getElementById('settings-form');
const authForm = document.getElementById('auth-form'); // Auth form
const loginBtn = document.getElementById('login-btn'); // Login button
const registerBtn = document.getElementById('register-btn'); // Register button
const milkListContainer = document.getElementById('milk-list');
const noMilkMessage = document.getElementById('no-milk-message');
const consumeDateInput = document.getElementById('consume-date');
const authStatusDiv = document.getElementById('auth-status'); // Auth status display
const authErrorP = document.getElementById('auth-error'); // Auth error display
// --- API Helper ---
async function fetchApi(endpoint, options = {}) {
const headers = {
'Content-Type': 'application/json',
...options.headers, // Allow overriding headers
};
// Add JWT token if available
if (jwtToken) {
headers['Authorization'] = `Bearer ${jwtToken}`;
}
const config = {
...options,
headers: headers,
};
// Construct full API URL (assuming API is served from the same origin)
const url = `/api${endpoint}`; // Prepend /api
try {
const response = await fetch(url, config);
// Handle unauthorized errors (e.g., expired token)
if (response.status === 401 || response.status === 422) { // 422 Unprocessable Entity (often used by flask-jwt for missing/bad token)
console.error('Authentication error:', response.status);
handleLogout(true); // Force logout and show login modal
throw new Error('Authentication Required'); // Stop further processing
}
// Get response body (if any)
const responseData = response.headers.get('Content-Type')?.includes('application/json')
? await response.json() // Parse JSON if applicable
: null; // Handle non-JSON responses if necessary
if (!response.ok) {
// Try to get error message from response body
const errorMessage = responseData?.error || responseData?.message || `HTTP error ${response.status}`;
console.error(`API Error (${response.status}) on ${endpoint}:`, errorMessage, responseData);
throw new Error(errorMessage);
}
console.log(`API Success on ${endpoint}: Status ${response.status}`);
return responseData; // Return JSON data or null
} catch (error) {
console.error(`Workspace failed for ${endpoint}:`, error);
// Display error to user? (e.g., a general banner)
displayGlobalError(`请求失败: ${error.message}`);
throw error; // Re-throw error so calling function knows it failed
}
}
// --- Authentication Functions ---
function showLoginModal() {
clearAuthForm();
authErrorP.style.display = 'none';
openModal(authModal);
}
function clearAuthForm() {
const usernameInput = document.getElementById('auth-username');
const passwordInput = document.getElementById('auth-password');
if (usernameInput) usernameInput.value = '';
if (passwordInput) passwordInput.value = '';
}
async function handleLogin(event) {
event?.preventDefault(); // Prevent default if called from button click
const username = document.getElementById('auth-username').value;
const password = document.getElementById('auth-password').value;
authErrorP.style.display = 'none';
if (!username || !password) {
displayAuthError('请输入用户名和密码');
return;
}
try {
const data = await fetchApi('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
if (data && data.access_token) {
jwtToken = data.access_token;
localStorage.setItem('jwtToken', jwtToken);
console.log('Login successful');
closeModal(authModal);
initializeApp(); // Reload data and update UI for logged-in user
} else {
displayAuthError('登录失败,请检查凭证');
}
} catch (error) {
// Error display handled by fetchApi or display specific message
displayAuthError(`登录失败: ${error.message}`);
}
}
async function handleRegister(event) {
event?.preventDefault();
const username = document.getElementById('auth-username').value;
const password = document.getElementById('auth-password').value;
authErrorP.style.display = 'none';
if (!username || !password) {
displayAuthError('请输入用户名和密码');
return;
}
if (password.length < 4) { // Example basic validation
displayAuthError('密码长度至少需要4位');
return;
}
try {
await fetchApi('/auth/register', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
// Registration successful, now attempt login
alert('注册成功!请现在登录。');
// Maybe prefill username?
document.getElementById('auth-password').value = ''; // Clear password
// Or automatically log in: await handleLogin();
} catch (error) {
displayAuthError(`注册失败: ${error.message}`);
}
}
function handleLogout(showLogin = false) {
jwtToken = null;
currentUserId = null;
localStorage.removeItem('jwtToken');
console.log('Logged out');
// Reset UI
milkListContainer.innerHTML = '';
noMilkMessage.textContent = '请先登录以查看或添加牛奶记录。';
noMilkMessage.style.display = 'block';
updateOverview('-- L / -- 盒', '--', '--');
updateAuthStatus();
document.getElementById('settings-form').reset(); // Clear settings form
hideMainContent(); // Hide main buttons/content if logged out
if (showLogin) {
showLoginModal();
}
}
function updateAuthStatus() {
if (jwtToken) {
// Try to get username if needed (decode token client-side - simple way)
// Or make a quick /api/user/profile call, but might fail if token JUST expired
let username = '已登录'; // Placeholder
try {
// Basic decoding (assumes standard JWT structure) - NOT FOR VERIFICATION
const payload = JSON.parse(atob(jwtToken.split('.')[1]));
currentUserId = payload.sub; // 'sub' usually holds the user ID/identity
// Fetch username from profile API if needed for display
username = `用户 ${currentUserId}`; // Placeholder until profile loaded
fetchApi('/user/profile').then(profile => {
if(profile && profile.username){
authStatusDiv.textContent = `已登录: ${profile.username}`;
}
}).catch(() => {}); // Ignore errors here, keep placeholder
} catch (e) { console.error("Error decoding token:", e); }
authStatusDiv.textContent = username;
logoutBtn.style.display = 'inline-block';
showMainContent();
} else {
authStatusDiv.textContent = '未登录';
logoutBtn.style.display = 'none';
hideMainContent();
// Optionally show login modal immediately if no token on load
// showLoginModal();
}
}
function hideMainContent(){
// Hide elements that require login
if(actionsContainer) {
actionsContainer.style.display = 'none'; // <-- 修改这里,隐藏容器
}
// (下面几行也可以删掉)
// if(addMilkBtn) addMilkBtn.style.display = 'none';
// if(recordConsumptionBtn) recordConsumptionBtn.style.display = 'none';
// if(settingsBtn) settingsBtn.style.display = 'none';
// Could also hide the summary container etc.
}
function showMainContent(){
// Show elements after login
if(actionsContainer) {
actionsContainer.style.display = 'flex'; // <-- 修改这里,显示容器 (用 flex 因为 CSS 里是 flex 布局)
}
// if(addMilkBtn) addMilkBtn.style.display = 'inline-block';
// if(recordConsumptionBtn) recordConsumptionBtn.style.display = 'inline-block';
// if(settingsBtn) settingsBtn.style.display = 'inline-block';
}
function displayAuthError(message) {
authErrorP.textContent = message;
authErrorP.style.display = 'block';
}
function displayGlobalError(message){
// Implement a more robust global error display if needed
console.error("Global Error:", message);
alert(`操作失败: ${message}`); // Simple alert for now
}
// --- Core Data Loading & Rendering ---
async function loadMilkData() {
console.log("Fetching milk data and overview...");
if (!jwtToken) {
console.log("No token found, skipping data load.");
noMilkMessage.textContent = '请先登录以查看或添加牛奶记录。';
noMilkMessage.style.display = 'block';
milkListContainer.innerHTML = '';
updateOverview('-- L / -- 盒', '--', '--');
return;
}
try {
// Fetch overview and milk list in parallel
const [overviewData, milkBatches] = await Promise.all([
fetchApi('/overview'),
fetchApi('/milk?onlyActive=false') // Get all non-deleted batches initially
]);
milkDataCache = milkBatches || []; // Store fetched data
// Update Overview Section
if (overviewData) {
const nearest = overviewData.nearestExpiry;
const nearestStr = nearest
? `${nearest.note || '牛奶'} - ${nearest.expiryDate} (${nearest.daysRemaining}天)`
: '无临期牛奶';
updateOverview(
`${overviewData.totalQuantityLiters} L / ${overviewData.totalQuantityItemsApprox}`,
nearestStr,
overviewData.estimatedFinishDate || '--'
);
} else {
updateOverview('-- L / -- 盒', '--', '--');
}
// Render Milk List
milkListContainer.innerHTML = ''; // Clear existing list
if (milkBatches && milkBatches.length > 0) {
noMilkMessage.style.display = 'none';
milkBatches.sort((a, b) => new Date(a.expiryDate) - new Date(b.expiryDate)); // Ensure sorted
milkBatches.forEach(renderMilkCard);
populateConsumeDropdown(milkBatches.filter(b => b.remainingVolume > 0.001 && b.status !== 'expired')); // Populate with active, non-expired
} else {
noMilkMessage.textContent = '还没有添加牛奶记录哦。';
noMilkMessage.style.display = 'block';
populateConsumeDropdown([]); // Clear dropdown
}
} catch (error) {
console.error("Failed to load initial data:", error);
noMilkMessage.textContent = '加载牛奶数据失败,请稍后重试。';
noMilkMessage.style.display = 'block';
milkListContainer.innerHTML = '';
updateOverview('错误', '错误', '错误');
// Error should have been handled by fetchApi, maybe logout occurred
}
}
function renderMilkCard(milk) {
const card = document.createElement('article');
card.className = `milk-card ${milk.status || 'normal'}`; // 'normal', 'warning', 'expired'
card.dataset.id = milk.id;
const progressValue = milk.initialTotalVolume > 0 ? (milk.remainingVolume / milk.initialTotalVolume) * 100 : 0;
let expiryText;
if (milk.daysRemaining === undefined || milk.daysRemaining === null){
expiryText = `过期: ${milk.expiryDate}`;
} else if (milk.daysRemaining < 0){
expiryText = `已过期 ${Math.abs(milk.daysRemaining)} 天 (${milk.expiryDate})`;
} else if (milk.daysRemaining === 0){
expiryText = `今天过期 (${milk.expiryDate})`;
} else {
expiryText = `${milk.daysRemaining} 天 (${milk.expiryDate})`;
}
card.innerHTML = `
<div class="card-header">
<span class="milk-note">${milk.note || '牛奶'} (${milk.volumePerItem}${milk.volumeUnit})</span>
<span class="milk-expiry">${expiryText}</span>
</div>
<div class="card-body">
<span class="milk-quantity">剩余: ${milk.remainingVolume.toFixed(1)}${milk.volumeUnit} / ${milk.remainingItemsApprox.toFixed(1)} 盒</span>
<progress value="${progressValue}" max="100" title="${progressValue.toFixed(0)}%"></progress>
</div>
<div class="card-footer">
<span>生产: ${milk.productionDate || '--'}</span>
<div>
<button class="card-action-btn consume-from-card-btn" data-id="${milk.id}" title="记录消耗"${milk.status === 'expired' || milk.remainingVolume < 0.001 ? ' disabled' : ''}>消耗</button>
<button class="card-action-btn delete-milk-btn" data-id="${milk.id}" title="删除记录">删除</button>
</div>
</div>
`;
milkListContainer.appendChild(card);
// Add event listeners for buttons on this card
const consumeBtn = card.querySelector('.consume-from-card-btn');
if(consumeBtn){
consumeBtn.addEventListener('click', (e) => {
if(!e.target.disabled) {
openRecordConsumptionModal(milk.id);
}
});
}
const deleteBtn = card.querySelector('.delete-milk-btn');
if(deleteBtn){
deleteBtn.addEventListener('click', () => deleteMilkBatch(milk.id, milk.note));
}
}
function populateConsumeDropdown(activeBatches) {
const selectMilk = document.getElementById('select-milk');
selectMilk.innerHTML = '<option value="" disabled selected>请选择...</option>'; // Clear existing
if (activeBatches && activeBatches.length > 0) {
// Sort by expiry, soonest first
activeBatches.sort((a, b) => new Date(a.expiryDate) - new Date(b.expiryDate));
activeBatches.forEach(milk => {
const option = document.createElement('option');
option.value = milk.id;
option.textContent = `${milk.note || '牛奶'} (${milk.volumePerItem}${milk.volumeUnit}) - Exp: ${milk.expiryDate} (剩 ${milk.remainingVolume.toFixed(1)}${milk.volumeUnit})`;
selectMilk.appendChild(option);
});
}
}
function updateOverview(totalQtyStr, nearestExpStr, estimatedFinishStr) {
document.getElementById('total-quantity').textContent = totalQtyStr;
document.getElementById('nearest-expiry').textContent = nearestExpStr;
document.getElementById('estimated-finish').textContent = estimatedFinishStr;
}
// --- Form Handling Functions ---
async function handleAddMilkSubmit(event) {
event.preventDefault();
const formData = new FormData(addMilkForm);
const data = Object.fromEntries(formData.entries());
// Basic validation & data prep
const payload = {
productionDate: data.productionDate || null,
shelfLife: data.shelfLife || null,
shelfUnit: data.shelfUnit || null,
expiryDate: data.expiryDate || null,
quantity: parseInt(data.quantity, 10),
volumePerItem: parseFloat(data.volumePerItem),
unit: data.unit,
note: data.note || null,
};
if (!payload.quantity || !payload.volumePerItem || !payload.unit) {
alert("请填写数量、单件容量和单位。"); return;
}
if (!payload.expiryDate && !(payload.productionDate && payload.shelfLife)) {
alert("请提供过期日期,或同时提供生产日期和保质期。"); return;
}
if (payload.expiryDate && payload.productionDate && payload.shelfLife) {
// If all 3 provided, maybe prefer expiryDate or warn? Let API handle/prefer expiry.
payload.productionDate = null; // Example: Clear others if expiryDate is set
payload.shelfLife = null;
payload.shelfUnit = null;
}
console.log("Submitting Add Milk:", payload);
try {
await fetchApi('/milk', {
method: 'POST',
body: JSON.stringify(payload),
});
alert('牛奶入库成功!');
closeModal(addMilkModal);
loadMilkData(); // Refresh list and overview
} catch (error) {
// Error already logged by fetchApi
alert(`入库失败: ${error.message}`);
}
}
async function handleRecordConsumptionSubmit(event) {
event.preventDefault();
const formData = new FormData(recordConsumptionForm);
const data = Object.fromEntries(formData.entries());
const payload = {
milkBatchId: parseInt(data.milkBatchId, 10),
amountConsumed: parseFloat(data.amountConsumed),
unitConsumed: data.unitConsumed,
dateConsumed: data.dateConsumed ? new Date(data.dateConsumed).toISOString() : new Date().toISOString(),
};
if (!payload.milkBatchId || isNaN(payload.milkBatchId) || !payload.amountConsumed || !payload.unitConsumed) {
alert("请选择牛奶、填写消耗量和单位。"); return;
}
console.log("Submitting Record Consumption:", payload);
try {
await fetchApi('/consumption', {
method: 'POST',
body: JSON.stringify(payload),
});
alert('消耗记录成功!');
closeModal(recordConsumptionModal);
loadMilkData(); // Refresh list and overview
} catch (error) {
alert(`记录失败: ${error.message}`);
}
}
async function loadSettings() {
console.log("Loading user settings...");
if (!jwtToken) return; // Should not happen if settings button is shown only when logged in
try {
const profile = await fetchApi('/user/profile');
if (profile) {
document.getElementById('expiry-warning-days').value = profile.expiryWarningDays ?? 2;
document.getElementById('low-stock-threshold').value = profile.lowStockThresholdValue ?? '';
document.getElementById('low-stock-unit').value = profile.lowStockThresholdUnit ?? 'ml';
const notifications = profile.notificationsEnabled || {};
document.querySelector('input[name="enableExpiryWarning"]').checked = notifications.expiry ?? true;
document.querySelector('input[name="enableExpiredAlert"]').checked = notifications.expired ?? true;
document.querySelector('input[name="enableLowStock"]').checked = notifications.lowStock ?? true;
// Update auth status with correct username if not already done
if (profile.username && authStatusDiv.textContent.startsWith('用户')) {
authStatusDiv.textContent = `已登录: ${profile.username}`;
}
}
} catch (error) {
console.error("Failed to load settings:", error);
alert('加载设置失败。');
// Don't switch page if load fails
showPage('dashboard');
}
}
async function handleSettingsSubmit(event) {
event.preventDefault();
const formData = new FormData(settingsForm);
const payload = {
expiryWarningDays: parseInt(formData.get('expiryWarningDays'), 10) || 2,
lowStockThresholdValue: formData.get('lowStockThresholdValue') ? parseFloat(formData.get('lowStockThresholdValue')) : null,
lowStockThresholdUnit: formData.get('lowStockThresholdValue') ? formData.get('lowStockThresholdUnit') : null, // Only set unit if value exists
notificationsEnabled: {
expiry: formData.has('enableExpiryWarning'),
expired: formData.has('enableExpiredAlert'), // Match key used in User.to_dict
lowStock: formData.has('enableLowStock'),
}
};
// Basic validation for threshold
if(payload.lowStockThresholdValue !== null && payload.lowStockThresholdValue < 0){
alert("低库存阈值不能为负数。"); return;
}
if(payload.lowStockThresholdValue !== null && !payload.lowStockThresholdUnit){
alert("设置低库存阈值时请选择单位。"); return;
}
console.log("Saving Settings:", payload);
try {
await fetchApi('/user/profile', {
method: 'PUT',
body: JSON.stringify(payload),
});
alert('设置已保存!');
showPage('dashboard'); // Go back to dashboard
} catch (error) {
alert(`保存设置失败: ${error.message}`);
}
}
async function deleteMilkBatch(batchId, milkNote) {
const note = milkNote || `ID ${batchId}`;
if (confirm(`确定要删除这条牛奶记录吗?\n(${note})`)) {
console.log("Deleting milk batch:", batchId);
try {
await fetchApi(`/milk/${batchId}`, { method: 'DELETE' });
alert('记录已删除。');
loadMilkData(); // Refresh the list
} catch (error) {
alert(`删除失败: ${error.message}`);
}
}
}
// --- Modal Control Functions ---
function openModal(modalElement) {
if (modalElement) modalElement.style.display = 'block';
}
function closeModal(modalElement) {
if (modalElement) modalElement.style.display = 'none';
}
function openRecordConsumptionModal(preselectedMilkId = null) {
// Refresh dropdown with currently active batches from cache
const activeBatches = milkDataCache.filter(b => !b.isDeleted && b.remainingVolume > 0.001 && b.status !== 'expired');
populateConsumeDropdown(activeBatches);
const selectMilk = document.getElementById('select-milk');
if (preselectedMilkId && selectMilk) {
selectMilk.value = preselectedMilkId; // Try to pre-select
} else if (selectMilk && selectMilk.options.length > 1) {
selectMilk.selectedIndex = 1; // Select the first available milk (soonest expiry) if none preselected
}
// Reset form fields
recordConsumptionForm.reset();
consumeDateInput.value = new Date().toISOString().split('T')[0]; // Reset date to today
openModal(recordConsumptionModal);
}
// --- Page Navigation ---
function showPage(pageId) {
document.querySelectorAll('.page').forEach(page => page.style.display = 'none');
dashboardPage.style.display = 'none';
const pageToShow = document.getElementById(pageId);
if (pageToShow) {
// If settings page, load settings first
if (pageId === 'settings-page') {
loadSettings(); // Load settings when showing the page
}
pageToShow.style.display = 'block';
} else {
dashboardPage.style.display = 'block'; // Default to dashboard
}
}
// --- Event Listeners ---
if (addMilkBtn) {
addMilkBtn.addEventListener('click', () => {
addMilkForm.reset(); // Clear form
openModal(addMilkModal);
});
}
if (recordConsumptionBtn) {
recordConsumptionBtn.addEventListener('click', () => openRecordConsumptionModal());
}
if (settingsBtn) {
settingsBtn.addEventListener('click', () => showPage('settings-page'));
}
if (backToDashboardBtn) {
backToDashboardBtn.addEventListener('click', () => showPage('dashboard'));
}
if (logoutBtn) {
logoutBtn.addEventListener('click', () => handleLogout(true)); // Show login after logout
}
// Auth Modal Buttons
if (loginBtn) loginBtn.addEventListener('click', handleLogin);
if (registerBtn) registerBtn.addEventListener('click', handleRegister);
// Close Modal Buttons
if (closeAddModalBtn) closeAddModalBtn.addEventListener('click', () => closeModal(addMilkModal));
if (closeRecordModalBtn) closeRecordModalBtn.addEventListener('click', () => closeModal(recordConsumptionModal));
if (closeAuthModalBtn) closeAuthModalBtn.addEventListener('click', () => closeModal(authModal));
// Close Modal on outside click
window.addEventListener('click', (event) => {
if (event.target == addMilkModal) closeModal(addMilkModal);
if (event.target == recordConsumptionModal) closeModal(recordConsumptionModal);
if (event.target == authModal) closeModal(authModal);
});
// Form Submissions
if (addMilkForm) addMilkForm.addEventListener('submit', handleAddMilkSubmit);
if (recordConsumptionForm) recordConsumptionForm.addEventListener('submit', handleRecordConsumptionSubmit);
if (settingsForm) settingsForm.addEventListener('submit', handleSettingsSubmit);
// Quick Consume Buttons Logic
document.querySelectorAll('.consume-preset').forEach(button => {
button.addEventListener('click', function() {
const amount = this.dataset.amount;
const unit = this.dataset.unit;
document.getElementById('consume-amount').value = amount;
document.getElementById('consume-unit').value = unit;
});
});
// --- Initial App Load ---
function initializeApp(){
console.log("Initializing app...");
updateAuthStatus();
if(jwtToken){
showPage('dashboard'); // Show dashboard if logged in
loadMilkData(); // Load data if logged in
} else {
// Optionally show login immediately on load if not logged in
showLoginModal();
hideMainContent();
noMilkMessage.textContent = '请先登录以查看或添加牛奶记录。';
noMilkMessage.style.display = 'block';
}
// Set current year in footer
document.getElementById('current-year').textContent = new Date().getFullYear();
// Set default consume date
consumeDateInput.value = new Date().toISOString().split('T')[0];
}
initializeApp(); // Start the app
}); // End DOMContentLoaded