This commit is contained in:
jp.gao 2025-04-09 20:34:32 +08:00
commit 59e749aa24
18 changed files with 2290 additions and 0 deletions

42
.dockerignore Normal file
View File

@ -0,0 +1,42 @@
# .dockerignore
# Git files
.git
.gitignore
# Python cache files and virtual environment
__pycache__/
*.pyc
*.pyo
*.pyd
venv/
.venv/
env/
ENV/
# IDE / Editor folders
.idea/
.vscode/
# OS files
.DS_Store
Thumbs.db
# Secrets / Instance data (handle secrets via runtime env vars, instance via volumes)
.env
instance/
# Docker files
Dockerfile
docker-compose.yml
# Test files (optional)
tests/
*.test.py
pytest.ini
.pytest_cache/
# Build artifacts (optional)
dist/
build/
*.egg-info/

174
.gitignore vendored Normal file
View File

@ -0,0 +1,174 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc

65
Dockerfile Normal file
View File

@ -0,0 +1,65 @@
# Dockerfile
# Use an official Python runtime as a parent image
# Choose a specific version for reproducibility, slim variants are smaller
FROM python:3.11-slim
# Set environment variables for Python
ENV PYTHONDONTWRITEBYTECODE 1 # Prevents python creating .pyc files
ENV PYTHONUNBUFFERED 1 # Force stdout/stderr streams to be unbuffered
# Set the working directory in the container
WORKDIR /app
# Install system dependencies if needed (e.g., for libraries like Pillow or specific DB drivers)
# RUN apt-get update && apt-get install -y --no-install-recommends some-package && rm -rf /var/lib/apt/lists/*
# For basic SQLite and common libraries, often no extra system packages are needed.
# Create a non-root user and group for security
RUN addgroup --system --gid 1001 appgroup && \
adduser --system --uid 1001 --gid 1001 appuser
# RUN chown -R appuser:appgroup /app # Optional: Set ownership early if needed
# Copy the requirements file first to leverage Docker cache
COPY requirements.txt .
# Install dependencies
# Upgrade pip and install requirements
RUN pip install --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# Copy the application code into the container under /app
# Ensure correct ownership if copying files after creating the user
COPY --chown=appuser:appgroup ./milk_app ./milk_app
# Copy .env file if you rely on it INSIDE the container (less secure for secrets)
# COPY --chown=appuser:appgroup .env .
# Set Flask environment variables for production
# FLASK_APP points to the factory function
ENV FLASK_APP=milk_app.app:create_app()
# Ensure Debug mode is OFF for production
ENV FLASK_DEBUG=0
# Set Database URL to use a path inside the container (ideally mounted as a volume)
# Using /app/instance ensures it's within our workdir and owned by appuser if volume isn't mounted yet
ENV DATABASE_URL=sqlite:////app/instance/milk_tracker.db
# IMPORTANT: Set FLASK_SECRET_KEY and JWT_SECRET_KEY via runtime environment variables (-e flags or docker-compose)
# DO NOT hardcode secrets here!
# Example placeholders (WILL NOT WORK unless overridden at runtime):
# ENV FLASK_SECRET_KEY=your_flask_secret_key_at_runtime
# ENV JWT_SECRET_KEY=your_jwt_secret_key_at_runtime
# Ensure the instance folder exists and has correct permissions for the non-root user
# Gunicorn/Flask will run as appuser and needs to write the DB here if it doesn't exist
# The directory creation is handled in create_app now, but setting ownership is good.
RUN mkdir -p /app/instance && chown -R appuser:appgroup /app/instance
# Switch to the non-root user
USER appuser
# Expose the port Gunicorn will run on
EXPOSE 5000
# Define the command to run the application using Gunicorn
# Listen on 0.0.0.0 inside the container
# Number of workers can be adjusted or passed as runtime ENV var
CMD ["gunicorn", "--workers", "3", "--bind", "0.0.0.0:5000", "milk_app.app:create_app()"]

0
milk_app/__init__.py Normal file
View File

123
milk_app/app.py Normal file
View File

@ -0,0 +1,123 @@
import os
import re # For path parsing
from flask import Flask
from flask_jwt_extended import JWTManager
from flask_cors import CORS
from dotenv import load_dotenv
# Import database components
from .database import db # Assuming db = SQLAlchemy() is in database.py
# Import blueprints
from .auth import auth_bp
from .milk import milk_bp
from .consumption import consumption_bp
from .overview import overview_bp
from .profile import profile_bp
from .devices import device_bp
from .main_routes import main_bp # Import main routes
load_dotenv() # Load environment variables from .env file
def create_app(database_uri_override=None): # Allow overriding URI for testing etc.
# instance_relative_config=True tells Flask instance folder is relative to app root
app = Flask(__name__, instance_relative_config=True)
# --- Ensure instance folder exists ---
# Flask uses app.instance_path for this. os.makedirs won't hurt if it exists.
try:
# app.instance_path is the absolute path to the instance folder
os.makedirs(app.instance_path, exist_ok=True)
print(f"Checked/Created instance folder: {app.instance_path}")
except OSError as e:
print(f"CRITICAL: Could not create instance folder at {app.instance_path}: {e}")
# Depending on the error, you might want to exit or raise
raise # Re-raise the error as this is likely fatal
# --- Configurations ---
# Load default config, then potentially override from .env or function arg
app.config.from_mapping(
SECRET_KEY=os.environ.get('FLASK_SECRET_KEY', 'default-flask-secret-key-change-me'),
JWT_SECRET_KEY=os.environ.get('JWT_SECRET_KEY', 'default-jwt-secret-key-change-me'),
# Default to using the instance folder, common Flask practice
SQLALCHEMY_DATABASE_URI=f"sqlite:///{os.path.join(app.instance_path, 'milk_tracker.db')}",
SQLALCHEMY_TRACK_MODIFICATIONS=False,
JSON_SORT_KEYS=False # Keep JSON order as defined in dicts
)
# Override DB URI if specified in environment or function call
env_db_uri = os.environ.get('DATABASE_URL')
final_db_uri = database_uri_override or env_db_uri or app.config['SQLALCHEMY_DATABASE_URI']
app.config['SQLALCHEMY_DATABASE_URI'] = final_db_uri
print(f"Using Database URI: {app.config['SQLALCHEMY_DATABASE_URI']}")
# --- Ensure Database DIRECTORY Exists BEFORE db.init_app ---
db_uri = app.config['SQLALCHEMY_DATABASE_URI']
if db_uri.startswith('sqlite:///'):
# Extract path part after 'sqlite:///'
db_path_part = db_uri[len('sqlite:///'):]
db_abs_path = None # Initialize
# Handle Windows drive letters (e.g., C:/...) if present after ///
# Use os.path.splitdrive for robustness
drive, path_no_drive = os.path.splitdrive(db_path_part)
if drive:
db_abs_path = os.path.abspath(db_path_part) # It's an absolute Windows path
# Handle absolute Unix paths (starting with /)
elif os.path.isabs(db_path_part):
db_abs_path = db_path_part
# Otherwise, treat as relative path (build from instance path)
else:
db_abs_path = os.path.join(app.instance_path, db_path_part)
# Get the directory containing the potentially non-existent db file
db_dir = os.path.dirname(db_abs_path)
# Create the directory if it doesn't exist
if db_dir: # Check if db_dir is not empty (i.e., not just a filename)
try:
os.makedirs(db_dir, exist_ok=True)
print(f"Checked/Created database directory: {db_dir}")
except OSError as e:
print(f"CRITICAL: Could not create database directory {db_dir}: {e}")
raise # Re-raise as this is likely fatal
# else: print("Database file is in the root or instance folder, no sub-directory needed.")
# --- Initialize Extensions ---
# Now that the directory should exist, this should succeed
try:
db.init_app(app)
print("SQLAlchemy initialized.")
except Exception as e:
print(f"CRITICAL: Failed to initialize SQLAlchemy: {e}")
raise # Re-raise
JWTManager(app)
CORS(app) # Allow requests from your frontend domain in production
# --- Create Database Tables ---
# This ensures tables are created *after* successful initialization
try:
with app.app_context():
db.create_all()
print(f"Database tables checked/created.")
except Exception as e:
print(f"CRITICAL: Failed to create database tables: {e}")
raise # Re-raise
# --- Register Blueprints ---
app.register_blueprint(auth_bp)
app.register_blueprint(milk_bp)
app.register_blueprint(consumption_bp)
app.register_blueprint(overview_bp)
app.register_blueprint(profile_bp)
app.register_blueprint(device_bp)
app.register_blueprint(main_bp) # Register the main route for serving HTML
@app.route('/health') # Add a simple health check endpoint
def health():
return "API OK", 200
return app

50
milk_app/auth.py Normal file
View File

@ -0,0 +1,50 @@
from flask import Blueprint, request, jsonify
from werkzeug.security import check_password_hash
from flask_jwt_extended import create_access_token, jwt_required, get_jwt_identity
from .database import db, User
auth_bp = Blueprint('auth', __name__, url_prefix='/api/auth')
@auth_bp.route('/register', methods=['POST'])
def register():
data = request.get_json()
username = data.get('username')
password = data.get('password')
if not username or not password:
return jsonify({"error": "Username and password required"}), 400
if User.query.filter_by(username=username).first():
return jsonify({"error": "Username already exists"}), 409 # Conflict
new_user = User(username=username)
new_user.set_password(password)
db.session.add(new_user)
try:
db.session.commit()
return jsonify({"message": "User registered successfully"}), 201
except Exception as e:
db.session.rollback()
print(f"Error registering user: {e}")
return jsonify({"error": "Registration failed"}), 500
@auth_bp.route('/login', methods=['POST'])
def login():
data = request.get_json()
username = data.get('username')
password = data.get('password')
if not username or not password:
return jsonify({"error": "Username and password required"}), 400
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
# --- SOLUTION: Convert user.id to string ---
identity_data = str(user.id)
access_token = create_access_token(identity=identity_data)
# ------------------------------------------
return jsonify(access_token=access_token)
else:
return jsonify({"error": "Invalid credentials"}), 401
# Add /logout, /refresh_token if needed later

96
milk_app/consumption.py Normal file
View File

@ -0,0 +1,96 @@
import datetime
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from .database import db, ConsumptionRecord, MilkBatch, User
from .utils import convert_volume
consumption_bp = Blueprint('consumption', __name__, url_prefix='/api/consumption')
@consumption_bp.route('', methods=['POST'])
@jwt_required()
def record_consumption():
current_user_id = get_jwt_identity()
data = request.get_json()
if not data: return jsonify({"error": "Request must be JSON"}), 400
batch_id = data.get('milkBatchId')
amount_str = data.get('amountConsumed')
unit = data.get('unitConsumed') # 'ml', 'L', 'items'
consumed_at_str = data.get('dateConsumed', datetime.datetime.utcnow().isoformat())
# --- Validation ---
if not batch_id or amount_str is None or not unit:
return jsonify({"error": "Missing fields: milkBatchId, amountConsumed, unitConsumed"}), 400
if unit not in ['ml', 'L', 'items']:
return jsonify({"error": "Invalid unitConsumed."}), 400
try:
amount = float(amount_str)
if amount <= 0: raise ValueError("Amount must be positive.")
except (ValueError, TypeError):
return jsonify({"error": "Invalid number format for amountConsumed."}), 400
try:
# Attempt to parse date, fallback to now if invalid format
consumed_time = datetime.datetime.fromisoformat(consumed_at_str.replace('Z', '+00:00'))
except ValueError:
consumed_time = datetime.datetime.utcnow()
# --- Find Batch & Check Status ---
batch = MilkBatch.query.filter_by(id=batch_id, user_id=current_user_id, is_deleted=False).first()
if not batch: return jsonify({"error": "Milk batch not found or invalid."}), 404
if batch.remaining_volume < 0.001: return jsonify({"error": "Selected milk batch is empty."}), 400
if batch.expiry_date < datetime.date.today():
# Optionally warn or prevent consuming expired? For now, allow but maybe log.
print(f"Warning: Consuming from expired batch {batch_id}")
# --- Calculate Consumed Volume in Batch's Unit ---
try:
consumed_volume_in_batch_unit = 0
if unit == 'items':
consumed_volume_in_batch_unit = amount * batch.volume_per_item
elif unit == batch.volume_unit:
consumed_volume_in_batch_unit = amount
else: # Convert (e.g., L consumed from ml batch)
consumed_volume_in_batch_unit = convert_volume(amount, unit, batch.volume_unit)
# --- Check Availability & Update ---
# Use tolerance for float comparison
if consumed_volume_in_batch_unit > (batch.remaining_volume + 0.001):
return jsonify({"error": f"Not enough milk. Only {batch.remaining_volume:.2f} {batch.volume_unit} left."}), 400
batch.remaining_volume -= consumed_volume_in_batch_unit
batch.remaining_volume = max(0, batch.remaining_volume) # Prevent going negative
# --- Record Consumption ---
new_record = ConsumptionRecord(
user_id=current_user_id,
milk_batch_id=batch_id,
amount_consumed=amount, # Log the originally entered amount/unit
unit_consumed=unit,
consumed_at=consumed_time
)
db.session.add(new_record)
db.session.commit()
# Return updated batch state
return jsonify(batch.to_dict()), 201
except (ValueError, TypeError) as e:
db.session.rollback()
return jsonify({"error": f"Invalid input or calculation error: {e}"}), 400
except Exception as e:
db.session.rollback()
print(f"Error recording consumption: {e}")
return jsonify({"error": "Database error occurred."}), 500
@consumption_bp.route('', methods=['GET'])
@jwt_required()
def get_consumption_history():
current_user_id = get_jwt_identity()
# Add pagination later if needed
limit = request.args.get('limit', 50, type=int)
offset = request.args.get('offset', 0, type=int)
records = ConsumptionRecord.query.filter_by(user_id=current_user_id)\
.order_by(ConsumptionRecord.consumed_at.desc())\
.limit(limit).offset(offset).all()
return jsonify([record.to_dict() for record in records]), 200

164
milk_app/database.py Normal file
View File

@ -0,0 +1,164 @@
import datetime
from flask_sqlalchemy import SQLAlchemy
from werkzeug.security import generate_password_hash, check_password_hash
from dateutil.relativedelta import relativedelta # pip install python-dateutil
db = SQLAlchemy()
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
password_hash = db.Column(db.String(128), nullable=False)
# Settings stored with the user
expiry_warning_days = db.Column(db.Integer, default=2, nullable=False)
low_stock_threshold_value = db.Column(db.Float) # Value can be ml, L, or items
low_stock_threshold_unit = db.Column(db.String(10)) # 'ml', 'L', 'items'
notify_expiry_warning = db.Column(db.Boolean, default=True, nullable=False)
notify_expired_alert = db.Column(db.Boolean, default=True, nullable=False)
notify_low_stock = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow)
# Relationships
milk_batches = db.relationship('MilkBatch', backref='user', lazy=True, cascade="all, delete-orphan")
consumption_records = db.relationship('ConsumptionRecord', backref='user', lazy=True, cascade="all, delete-orphan")
push_devices = db.relationship('PushDevice', backref='user', lazy=True, cascade="all, delete-orphan")
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def to_dict(self):
# Used for returning user profile/settings
return {
"id": self.id,
"username": self.username,
"expiryWarningDays": self.expiry_warning_days,
"lowStockThresholdValue": self.low_stock_threshold_value,
"lowStockThresholdUnit": self.low_stock_threshold_unit,
"notificationsEnabled": {
"expiry": self.notify_expiry_warning,
"expired": self.notify_expired_alert, # Changed key slightly for clarity
"lowStock": self.notify_low_stock,
}
}
class MilkBatch(db.Model):
__tablename__ = 'milk_batches'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
production_date = db.Column(db.Date) # Store as Date object
expiry_date = db.Column(db.Date, nullable=False) # Store as Date object
initial_quantity_items = db.Column(db.Integer, nullable=False) # e.g., 2 (bottles)
volume_per_item = db.Column(db.Float, nullable=False) # e.g., 1000
volume_unit = db.Column(db.String(5), nullable=False) # 'ml' or 'L'
note = db.Column(db.String(200))
# Store remaining volume always in the primary volume_unit (ml or L)
remaining_volume = db.Column(db.Float, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow)
is_deleted = db.Column(db.Boolean, default=False, nullable=False) # Use for soft delete
consumption_records = db.relationship('ConsumptionRecord', backref='milk_batch', lazy=True)
@property
def days_remaining(self):
"""Calculates days remaining until expiry."""
if self.expiry_date:
return (self.expiry_date - datetime.date.today()).days
return None # Should not happen if expiry_date is required
@property
def status(self):
"""Determines status based on expiry date and user settings."""
days = self.days_remaining
if days is None: return 'normal'
warning_days = self.user.expiry_warning_days if self.user else 2
if days < 0:
return 'expired'
elif days <= warning_days:
return 'warning'
else:
return 'normal'
@property
def initial_total_volume(self):
"""Calculate initial total volume in the batch's unit."""
return self.initial_quantity_items * self.volume_per_item
@property
def remaining_items_approx(self):
"""Calculate approximate remaining items."""
if self.volume_per_item > 0:
# Use a small tolerance to handle floating point issues near zero
if self.remaining_volume < 0.001:
return 0
return max(0, round(self.remaining_volume / self.volume_per_item, 2))
return 0
def to_dict(self):
"""Serializes the object to a dictionary."""
return {
"id": self.id,
"userId": self.user_id,
"productionDate": self.production_date.isoformat() if self.production_date else None,
"expiryDate": self.expiry_date.isoformat(),
"initialQuantityItems": self.initial_quantity_items,
"volumePerItem": self.volume_per_item,
"volumeUnit": self.volume_unit,
"note": self.note,
"remainingVolume": round(self.remaining_volume, 2), # Round for display
"initialTotalVolume": round(self.initial_total_volume, 2),
"remainingItemsApprox": self.remaining_items_approx,
"createdAt": self.created_at.isoformat(),
"isDeleted": self.is_deleted,
# Calculated properties
"daysRemaining": self.days_remaining,
"status": self.status,
}
class ConsumptionRecord(db.Model):
__tablename__ = 'consumption_records'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
milk_batch_id = db.Column(db.Integer, db.ForeignKey('milk_batches.id'), nullable=False)
amount_consumed = db.Column(db.Float, nullable=False)
# Store the unit as entered by user for history, but calculation uses batch unit
unit_consumed = db.Column(db.String(10), nullable=False) # 'ml', 'L', 'items'
consumed_at = db.Column(db.DateTime, nullable=False) # Store as DateTime object
created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow)
def to_dict(self):
return {
"id": self.id,
"userId": self.user_id,
"milkBatchId": self.milk_batch_id,
"amountConsumed": self.amount_consumed,
"unitConsumed": self.unit_consumed,
"consumedAt": self.consumed_at.isoformat(),
"createdAt": self.created_at.isoformat(),
}
class PushDevice(db.Model):
__tablename__ = 'push_devices'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
device_type = db.Column(db.String(10), nullable=False) # 'web', 'ios', 'android'
# Token can be very long, especially for web push subscriptions (JSON)
token = db.Column(db.Text, unique=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow)
# Helper function to initialize DB (call once when setting up)
def init_database(app):
with app.app_context():
db.create_all()
print("Database tables created/verified.")

79
milk_app/devices.py Normal file
View File

@ -0,0 +1,79 @@
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from .database import db, PushDevice, User
device_bp = Blueprint('devices', __name__, url_prefix='/api/devices')
@device_bp.route('', methods=['POST'])
@jwt_required()
def register_device():
current_user_id = get_jwt_identity()
data = request.get_json()
if not data: return jsonify({"error": "Request must be JSON"}), 400
device_type = data.get('type')
token = data.get('token')
if not device_type or not token:
return jsonify({"error": "Missing 'type' or 'token'"}), 400
if device_type not in ['web', 'ios', 'android']:
return jsonify({"error": "Invalid device type"}), 400
# Check if token already exists to prevent duplicates
existing = PushDevice.query.filter_by(token=token).first()
if existing:
# Optional: Update user_id if token somehow got registered by another user?
# Or just return OK indicating it's known
if existing.user_id != current_user_id:
# Decide handling: update owner or error? Let's update.
existing.user_id = current_user_id
db.session.commit()
print(f"Warning: Push token {token[:10]}... re-assigned to user {current_user_id}")
return jsonify({"message": "Device already registered"}), 200
new_device = PushDevice(
user_id=current_user_id,
device_type=device_type,
token=token
)
db.session.add(new_device)
try:
db.session.commit()
return jsonify({"message": "Device registered successfully"}), 201
except Exception as e:
db.session.rollback()
# Could be unique constraint violation if race condition, or DB error
print(f"Error registering device: {e}")
# Check again if it exists now (due to race condition)
existing = PushDevice.query.filter_by(token=token).first()
if existing: return jsonify({"message": "Device already registered"}), 200
return jsonify({"error": "Failed to register device"}), 500
@device_bp.route('/<string:token_prefix>', methods=['DELETE'])
@jwt_required()
def unregister_device(token_prefix):
# Note: Deleting by full token in URL can be problematic (length, encoding).
# A better approach might be POST /api/devices/unregister { "token": "..." }
# Or require the client to store the device ID returned on registration and use that.
# For simplicity now, we'll assume we search by token.
current_user_id = get_jwt_identity()
# This is inefficient if tokens are long. A dedicated device ID is better.
# Let's simulate finding based on a prefix if needed, but ideally use full token from body
# device = PushDevice.query.filter(PushDevice.token.like(f'{token_prefix}%'), PushDevice.user_id==current_user_id).first()
# --- Alternative using request body ---
data = request.get_json()
token_to_delete = data.get('token') if data else None
if not token_to_delete:
return jsonify({"error": "Token required in request body for deletion"}), 400
device = PushDevice.query.filter_by(token=token_to_delete, user_id=current_user_id).first()
if not device:
return jsonify({"error": "Device not found or not owned by user"}), 404
db.session.delete(device)
db.session.commit()
return '', 204

15
milk_app/main_routes.py Normal file
View File

@ -0,0 +1,15 @@
from flask import Blueprint, render_template
main_bp = Blueprint('main', __name__)
@main_bp.route('/')
def home():
"""Serves the main index.html page."""
# No JWT required here, JS handles auth state based on stored token
return render_template('index.html')
# Add any other top-level non-API routes here if needed later.
# For example, an '/about' page:
# @main_bp.route('/about')
# def about():
# return render_template('about.html') # Assuming you create about.html

115
milk_app/milk.py Normal file
View File

@ -0,0 +1,115 @@
import datetime
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from .database import db, MilkBatch, User
from .utils import calculate_expiry_date, convert_volume
milk_bp = Blueprint('milk', __name__, url_prefix='/api/milk')
@milk_bp.route('', methods=['POST'])
@jwt_required()
def add_milk():
current_user_id = get_jwt_identity()
data = request.get_json()
if not data: return jsonify({"error": "Request must be JSON"}), 400
expiry_date_str = data.get('expiryDate')
production_date_str = data.get('productionDate')
shelf_life = data.get('shelfLife')
shelf_unit = data.get('shelfUnit', 'days')
initial_quantity_items = data.get('quantity')
volume_per_item = data.get('volumePerItem')
volume_unit = data.get('unit') # 'ml' or 'L'
note = data.get('note')
# --- Validation ---
if not initial_quantity_items or volume_per_item is None or not volume_unit:
return jsonify({"error": "Missing required fields: quantity, volumePerItem, unit"}), 400
if volume_unit not in ['ml', 'L']:
return jsonify({"error": "Invalid volume unit. Use 'ml' or 'L'."}), 400
try:
initial_quantity_items = int(initial_quantity_items)
volume_per_item = float(volume_per_item)
if initial_quantity_items <= 0 or volume_per_item <= 0:
raise ValueError("Quantity and volume must be positive.")
except (ValueError, TypeError):
return jsonify({"error": "Invalid number format for quantity or volumePerItem."}), 400
# --- Determine Expiry Date ---
expiry_date = None
if expiry_date_str:
try:
expiry_date = datetime.date.fromisoformat(expiry_date_str)
except ValueError:
return jsonify({"error": "Invalid expiryDate format. Use YYYY-MM-DD."}), 400
elif production_date_str and shelf_life:
expiry_date = calculate_expiry_date(production_date_str, shelf_life, shelf_unit)
if not expiry_date:
return jsonify({"error": "Invalid productionDate or shelfLife for expiry calculation."}), 400
else:
return jsonify({"error": "Either expiryDate or (productionDate and shelfLife) is required."}), 400
# --- Production Date (Optional) ---
prod_date = None
if production_date_str:
try:
prod_date = datetime.date.fromisoformat(production_date_str)
except ValueError:
# Allow if only expiry date was provided, otherwise error
if not expiry_date_str:
return jsonify({"error": "Invalid productionDate format. Use YYYY-MM-DD."}), 400
# --- Create Batch ---
initial_remaining_volume = initial_quantity_items * volume_per_item
new_batch = MilkBatch(
user_id=current_user_id,
production_date=prod_date,
expiry_date=expiry_date,
initial_quantity_items=initial_quantity_items,
volume_per_item=volume_per_item,
volume_unit=volume_unit,
note=note,
remaining_volume=initial_remaining_volume,
is_deleted=False
)
db.session.add(new_batch)
try:
db.session.commit()
return jsonify(new_batch.to_dict()), 201
except Exception as e:
db.session.rollback()
print(f"Error adding milk batch: {e}")
return jsonify({"error": "Database error occurred."}), 500
@milk_bp.route('', methods=['GET'])
@jwt_required()
def get_milk_batches():
current_user_id = get_jwt_identity()
include_deleted = request.args.get('includeDeleted', 'false').lower() == 'true'
only_active = request.args.get('onlyActive', 'true').lower() == 'true' # Only not empty and not expired
query = MilkBatch.query.filter_by(user_id=current_user_id)
if not include_deleted:
query = query.filter_by(is_deleted=False)
if only_active:
query = query.filter(MilkBatch.remaining_volume > 0.001) # Check remaining volume with tolerance
query = query.filter(MilkBatch.expiry_date >= datetime.date.today())
# Sort by expiry date (soonest first)
batches = query.order_by(MilkBatch.expiry_date.asc()).all()
return jsonify([batch.to_dict() for batch in batches]), 200
@milk_bp.route('/<int:batch_id>', methods=['DELETE'])
@jwt_required()
def delete_milk_batch(batch_id):
current_user_id = get_jwt_identity()
batch = MilkBatch.query.filter_by(id=batch_id, user_id=current_user_id).first()
if not batch: return jsonify({"error": "Milk batch not found or access denied."}), 404
if batch.is_deleted: return jsonify({"message": "Batch already deleted."}), 200
batch.is_deleted = True # Soft delete
db.session.commit()
return '', 204

67
milk_app/overview.py Normal file
View File

@ -0,0 +1,67 @@
import datetime
from flask import Blueprint, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from .database import db, MilkBatch, User
from sqlalchemy import func # For sum
overview_bp = Blueprint('overview', __name__, url_prefix='/api/overview')
@overview_bp.route('', methods=['GET'])
@jwt_required()
def get_overview():
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
if not user: return jsonify({"error": "User not found"}), 404
# Find active (not deleted, not empty, not expired) batches
active_batches = MilkBatch.query.filter(
MilkBatch.user_id == current_user_id,
MilkBatch.is_deleted == False,
MilkBatch.remaining_volume > 0.001,
MilkBatch.expiry_date >= datetime.date.today()
).order_by(MilkBatch.expiry_date.asc()).all()
total_volume_ml = 0
total_items_approx = 0
nearest_expiry_info = None
for batch in active_batches:
# Sum total volume in ml
if batch.volume_unit == 'L':
total_volume_ml += batch.remaining_volume * 1000
else: # Assumed ml
total_volume_ml += batch.remaining_volume
total_items_approx += batch.remaining_items_approx
# Capture the first one (soonest expiry)
if not nearest_expiry_info:
nearest_expiry_info = batch.to_dict()
total_quantity_liters = total_volume_ml / 1000.0
# Basic low stock check (can be enhanced in push notification logic)
is_low_stock = False
if user.low_stock_threshold_value is not None and user.low_stock_threshold_unit:
threshold_value = user.low_stock_threshold_value
threshold_unit = user.low_stock_threshold_unit
try:
if threshold_unit == 'items':
if total_items_approx <= threshold_value: is_low_stock = True
else: # ml or L
# Convert threshold to ml if needed
threshold_ml = convert_volume(threshold_value, threshold_unit, 'ml')
if total_volume_ml <= threshold_ml: is_low_stock = True
except ValueError:
print(f"Warning: Invalid low stock threshold units for user {current_user_id}")
overview_data = {
"totalQuantityLiters": round(total_quantity_liters, 2),
"totalQuantityItemsApprox": round(total_items_approx),
"nearestExpiry": nearest_expiry_info, # Full batch dict or null
"isLowStock": is_low_stock, # Simple flag based on settings
# "estimatedFinishDate": "N/A" # Requires historical analysis - skip for now
}
return jsonify(overview_data), 200

51
milk_app/profile.py Normal file
View File

@ -0,0 +1,51 @@
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from .database import db, User
profile_bp = Blueprint('profile', __name__, url_prefix='/api/user/profile')
@profile_bp.route('', methods=['GET'])
@jwt_required()
def get_user_profile():
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
if not user: return jsonify({"error": "User not found"}), 404
return jsonify(user.to_dict()), 200
@profile_bp.route('', methods=['PUT'])
@jwt_required()
def update_user_profile():
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
if not user: return jsonify({"error": "User not found"}), 404
data = request.get_json()
if not data: return jsonify({"error": "Request must be JSON"}), 400
try:
# Update settings if provided in the request body
if 'expiryWarningDays' in data:
user.expiry_warning_days = int(data['expiryWarningDays'])
if 'lowStockThresholdValue' in data: # Allow setting to null/None
user.low_stock_threshold_value = float(data['lowStockThresholdValue']) if data['lowStockThresholdValue'] is not None else None
if 'lowStockThresholdUnit' in data:
unit = data['lowStockThresholdUnit']
if unit in ['ml', 'L', 'items', None]: # Allow None
user.low_stock_threshold_unit = unit
else: raise ValueError("Invalid lowStockThresholdUnit")
if 'notificationsEnabled' in data:
notif_settings = data['notificationsEnabled']
user.notify_expiry_warning = bool(notif_settings.get('expiry', user.notify_expiry_warning))
user.notify_expired_alert = bool(notif_settings.get('expired', user.notify_expired_alert))
user.notify_low_stock = bool(notif_settings.get('lowStock', user.notify_low_stock))
db.session.commit()
return jsonify(user.to_dict()), 200
except (ValueError, TypeError) as e:
db.session.rollback()
return jsonify({"error": f"Invalid data format: {e}"}), 400
except Exception as e:
db.session.rollback()
print(f"Error updating profile: {e}")
return jsonify({"error": "Database error occurred."}), 500

665
milk_app/static/script.js Normal file
View File

@ -0,0 +1,665 @@
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

367
milk_app/static/style.css Normal file
View File

@ -0,0 +1,367 @@
/* Basic Reset & Defaults */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f8f9fa;
padding-bottom: 60px; /* Space for footer */
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Header */
header {
background-color: #4a90e2; /* Blue theme */
color: white;
padding: 1rem;
text-align: center;
margin-bottom: 1.5rem;
flex-shrink: 0; /* Prevent header from shrinking */
}
/* Main Content Area */
main {
max-width: 900px;
margin: 0 auto;
padding: 0 1rem;
flex-grow: 1; /* Allow main content to grow */
width: 100%; /* Ensure it takes width for centering */
}
h2 {
margin-top: 1.5rem;
margin-bottom: 0.8rem;
color: #4a90e2;
border-bottom: 2px solid #e0e0e0;
padding-bottom: 0.3rem;
}
/* Summary Section */
.summary-container {
display: flex;
flex-wrap: wrap;
gap: 1rem;
background-color: #fff;
padding: 1rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-bottom: 1.5rem;
}
.summary-item {
flex: 1;
min-width: 150px;
text-align: center;
}
.summary-item span {
display: block;
font-size: 0.9rem;
color: #555;
margin-bottom: 0.2rem;
}
.summary-item strong {
font-size: 1.2rem;
color: #333;
}
/* Action Buttons */
.actions {
margin-bottom: 1.5rem;
display: flex; /* Changed back from display: none */
gap: 0.8rem;
flex-wrap: wrap;
}
/* Buttons */
.button {
padding: 0.8rem 1.2rem;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s ease, opacity 0.2s ease;
color: white;
text-align: center;
line-height: 1.2; /* Adjust for better vertical alignment */
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.button.primary { background-color: #4a90e2; }
.button.primary:hover:not(:disabled) { background-color: #357abd; }
.button.secondary { background-color: #5cb85c; }
.button.secondary:hover:not(:disabled) { background-color: #4cae4c; }
.button.tertiary { background-color: #f0ad4e; }
.button.tertiary:hover:not(:disabled) { background-color: #ec971f; }
/* Milk List & Cards */
.milk-list {
display: grid;
gap: 1rem;
/* Responsive grid columns */
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
#no-milk-message {
grid-column: 1 / -1; /* Span across all columns if grid is active */
text-align: center;
color: #666;
padding: 2rem;
background-color: #fff;
border-radius: 8px;
}
.milk-card {
background-color: #fff;
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
border-left: 5px solid #4a90e2; /* Default border color - normal */
display: flex;
flex-direction: column; /* Stack elements vertically */
}
.milk-card.warning { border-left-color: #f0ad4e; } /* Yellow for warning */
.milk-card.expired { border-left-color: #d9534f; opacity: 0.7; } /* Red for expired */
.card-header, .card-body, .card-footer {
margin-bottom: 0.8rem;
}
.card-footer { margin-bottom: 0; margin-top: auto; } /* Push footer to bottom */
.card-header {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem; /* Space between note and expiry */
font-weight: bold;
}
.milk-note{
word-break: break-word; /* Prevent long notes from breaking layout */
}
.milk-expiry {
font-size: 0.9rem;
color: #555;
flex-shrink: 0; /* Prevent expiry date from shrinking too much */
white-space: nowrap; /* Prevent expiry date text wrap */
}
.milk-card.warning .milk-expiry { color: #c77c0e; }
.milk-card.expired .milk-expiry { color: #d9534f; font-weight: bold;}
.milk-quantity { display: block; margin-bottom: 0.3rem;}
progress { width: 100%; height: 8px; border-radius: 4px; overflow: hidden;}
/* Styling progress bar for better appearance */
progress::-webkit-progress-bar { background-color: #eee; border-radius: 4px;}
progress::-webkit-progress-value { background-color: #4a90e2; border-radius: 4px;}
progress::-moz-progress-bar { background-color: #4a90e2; border-radius: 4px;}
.milk-card.warning progress::-webkit-progress-value { background-color: #f0ad4e; }
.milk-card.warning progress::-moz-progress-bar { background-color: #f0ad4e; }
.milk-card.expired progress::-webkit-progress-value { background-color: #d9534f; }
.milk-card.expired progress::-moz-progress-bar { background-color: #d9534f; }
.card-footer {
font-size: 0.85rem;
color: #666;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.card-footer > div { /* Container for buttons */
display: flex;
gap: 0.5rem;
}
.card-action-btn {
padding: 0.3rem 0.6rem;
font-size: 0.8rem;
background-color: #eee;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
color: #333;
}
.card-action-btn:hover:not(:disabled){ background-color: #ddd;}
.card-action-btn.delete-milk-btn { background-color: #f8d7da; border-color: #f5c6cb; color: #721c24;}
.card-action-btn.delete-milk-btn:hover:not(:disabled) { background-color: #f5c6cb;}
.card-action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
background-color: #eee;
}
/* Modals */
.modal {
display: none; /* Hidden by default */
position: fixed; /* Stay in place */
z-index: 1000; /* Sit on top */
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto; /* Enable scroll if needed */
background-color: rgba(0,0,0,0.5); /* Black w/ opacity */
}
.modal-content {
background-color: #fefefe;
margin: 10vh auto; /* Vertical margin using viewport height */
padding: 25px;
border: 1px solid #888;
border-radius: 8px;
width: 90%; /* Responsive width */
max-width: 500px; /* Max width */
position: relative;
}
.close-btn {
color: #aaa;
position: absolute;
top: 10px;
right: 20px;
font-size: 28px;
font-weight: bold;
cursor: pointer;
line-height: 1; /* Ensure consistent positioning */
}
.close-btn:hover,
.close-btn:focus {
color: black;
text-decoration: none;
}
/* Forms */
.form-group {
margin-bottom: 1rem;
}
label, fieldset legend {
display: block;
margin-bottom: 0.3rem;
font-weight: bold;
color: #444;
}
input[type="text"],
input[type="password"],
input[type="date"],
input[type="number"],
input[type="file"],
select {
width: 100%;
padding: 0.7rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
background-color: #fff; /* Ensure background color */
}
input[type="file"] { padding: 0.3rem;}
input:focus, select:focus {
outline: none;
border-color: #4a90e2;
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2);
}
.form-group small {
font-size: 0.8rem;
color: #666;
display: block; /* Ensure it takes its own line */
margin-top: 0.2rem;
}
hr { margin: 1rem 0; border: 0; border-top: 1px solid #eee; }
.quick-consume-buttons {
display: flex;
gap: 0.5rem;
margin-bottom: 0.8rem;
flex-wrap: wrap;
}
.quick-consume-buttons .button { padding: 0.5rem 0.8rem; font-size: 0.9rem;}
/* Auth Modal Specifics */
.auth-actions {
display: flex;
gap: 1rem;
justify-content: center;
}
#auth-error {
margin-top: 0.8rem;
text-align: center;
}
/* Settings Page */
.page {
background-color: #fff;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
fieldset {
border: 1px solid #ccc;
padding: 0.8rem;
border-radius: 4px;
margin-bottom: 1rem;
}
fieldset legend { font-size: 1rem; padding: 0 0.3rem; font-weight: bold; color: #444; }
fieldset label { font-weight: normal; margin-bottom: 0.2rem; display: inline-block; margin-right: 1rem;}
fieldset input[type="checkbox"] { margin-right: 0.5rem; vertical-align: middle; }
/* Footer */
footer {
text-align: center;
margin-top: 2rem;
padding: 1rem;
font-size: 0.9rem;
color: #777;
background-color: #e9ecef;
flex-shrink: 0; /* Prevent footer from shrinking */
width: 100%;
/* Removed position: fixed */
}
/* Responsive Adjustments */
@media (max-width: 600px) {
.modal-content {
width: 95%;
margin: 5vh auto; /* Adjust vertical margin for smaller screens */
padding: 20px;
}
.actions {
flex-direction: column; /* Stack action buttons */
align-items: stretch; /* Make buttons full width */
}
.summary-container {
flex-direction: column;
align-items: center;
text-align: center;
}
.card-header { flex-direction: column; align-items: flex-start;} /* Stack note and expiry on small screens */
.card-footer { flex-direction: column; align-items: flex-start;}
.card-footer > div { width: 100%; justify-content: space-around; margin-top: 0.5rem;} /* Space out buttons */
.auth-actions { flex-direction: column; } /* Stack login/register buttons */
}

View File

@ -0,0 +1,183 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>牛奶管家</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<header>
<h1>🥛 牛奶管家</h1>
</header>
<main>
<section id="dashboard">
<h2>概览</h2>
<div class="summary-container">
<div class="summary-item">
<span>当前总计</span>
<strong id="total-quantity">-- L / -- 盒</strong>
</div>
<div class="summary-item">
<span>最近过期</span>
<strong id="nearest-expiry">--</strong>
</div>
<div class="summary-item">
<span>预计喝完</span>
<strong id="estimated-finish">--</strong>
</div>
</div>
<div class="actions" style="display: none;"> <button id="add-milk-btn" class="button primary"> 添加入库</button>
<button id="record-consumption-btn" class="button secondary"> 记录消耗</button>
<button id="settings-btn" class="button tertiary">⚙️ 设置</button>
</div>
<h2>我的牛奶</h2>
<div id="milk-list" class="milk-list">
<p id="no-milk-message">请先登录以查看或添加牛奶记录。</p>
</div>
</section>
<section id="settings-page" class="page" style="display: none;">
<h2>设置</h2>
<form id="settings-form">
<div class="form-group">
<label for="expiry-warning-days">临期提醒提前天数:</label>
<input type="number" id="expiry-warning-days" name="expiryWarningDays" value="2" min="1">
</div>
<div class="form-group">
<label for="low-stock-threshold">低库存提醒阈值:</label>
<input type="number" id="low-stock-threshold" name="lowStockThresholdValue" value="500" min="0" placeholder="留空则不提醒">
<select id="low-stock-unit" name="lowStockThresholdUnit">
<option value="ml">ml</option>
<option value="L">L</option>
<option value="items">盒/瓶</option>
</select>
</div>
<fieldset>
<legend>开启推送通知 (未来功能):</legend>
<label><input type="checkbox" name="enableExpiryWarning" checked> 临期提醒</label><br>
<label><input type="checkbox" name="enableExpiredAlert" checked> 过期提醒</label><br>
<label><input type="checkbox" name="enableLowStock" checked> 低库存提醒</label><br>
</fieldset>
<button type="submit" class="button primary">保存设置</button>
<button type="button" id="back-to-dashboard-btn" class="button secondary">返回看板</button>
</form>
</section>
</main>
<div id="add-milk-modal" class="modal">
<div class="modal-content">
<span class="close-btn" id="close-add-modal">&times;</span>
<h2>添加入库</h2>
<form id="add-milk-form">
<p>请填写信息:</p>
<div class="form-group">
<label for="production-date">生产日期 (可选):</label>
<input type="date" id="production-date" name="productionDate">
</div>
<div class="form-group">
<label for="shelf-life">保质期 (与生产日期配合):</label>
<input type="number" id="shelf-life" name="shelfLife" min="1" placeholder="例如: 7">
<select name="shelfUnit">
<option value="days" selected></option>
<option value="months"></option>
</select>
</div>
<div class="form-group">
<label for="expiry-date">或 直接输入过期日期:</label>
<input type="date" id="expiry-date" name="expiryDate">
<small>如果填写此项,则忽略生产日期和保质期</small>
</div>
<div class="form-group">
<label for="quantity">数量:</label>
<input type="number" id="quantity" name="quantity" value="1" min="1" required> 盒/瓶
</div>
<div class="form-group">
<label for="volume">单件容量:</label>
<input type="number" id="volume" name="volumePerItem" required min="1" placeholder="例如: 250">
<select name="unit" required>
<option value="ml" selected>ml</option>
<option value="L">L</option>
</select>
</div>
<div class="form-group">
<label for="note">备注 (品牌等, 可选):</label>
<input type="text" id="note" name="note" placeholder="例如: 光明、楼下超市">
</div>
<button type="submit" class="button primary">确认入库</button>
</form>
</div>
</div>
<div id="record-consumption-modal" class="modal">
<div class="modal-content">
<span class="close-btn" id="close-record-modal">&times;</span>
<h2>记录消耗</h2>
<form id="record-consumption-form">
<div class="form-group">
<label for="select-milk">选择消耗的牛奶:</label>
<select id="select-milk" name="milkBatchId" required>
<option value="" disabled selected>请选择...</option>
</select>
</div>
<div class="form-group">
<label>消耗量:</label>
<div class="quick-consume-buttons">
<button type="button" class="button consume-preset" data-amount="250" data-unit="ml">一杯(250ml)</button>
<button type="button" class="button consume-preset" data-amount="0.5" data-unit="items">半盒</button>
<button type="button" class="button consume-preset" data-amount="1" data-unit="items">整盒</button>
</div>
<label for="consume-amount">或手动输入:</label>
<input type="number" id="consume-amount" name="amountConsumed" min="0.1" step="any" required>
<select id="consume-unit" name="unitConsumed" required>
<option value="ml" selected>ml</option>
<option value="L">L</option>
<option value="items">盒/瓶</option>
</select>
</div>
<div class="form-group">
<label for="consume-date">消耗日期:</label>
<input type="date" id="consume-date" name="dateConsumed" required>
</div>
<button type="submit" class="button primary">确认消耗</button>
</form>
</div>
</div>
<div id="auth-modal" class="modal" style="display: none;"> <div class="modal-content">
<span class="close-btn" id="close-auth-modal">&times;</span>
<div id="auth-section">
<h2>登录 / 注册</h2>
<form id="auth-form">
<div class="form-group">
<label for="auth-username">用户名:</label>
<input type="text" id="auth-username" name="username" required>
</div>
<div class="form-group">
<label for="auth-password">密码:</label>
<input type="password" id="auth-password" name="password" required>
</div>
<div class="form-group auth-actions">
<button type="button" id="login-btn" class="button primary">登录</button>
<button type="button" id="register-btn" class="button secondary">注册</button>
</div>
<p id="auth-error" style="color: red; display: none;"></p>
</form>
</div>
</div>
</div>
<footer>
<p>&copy; <span id="current-year"></span> 牛奶管家</p>
<div id="auth-status" style="font-size: 0.8em; margin-top: 5px;">未登录</div>
<button id="logout-btn" class="button tertiary" style="display: none; padding: 0.3em 0.6em; font-size: 0.8em;">登出</button>
</footer>
<script src="{{ url_for('static', filename='script.js') }}"></script>
</body>
</html>

34
milk_app/utils.py Normal file
View File

@ -0,0 +1,34 @@
import datetime
from dateutil.relativedelta import relativedelta
def calculate_expiry_date(production_date_str, shelf_life, shelf_unit):
"""Calculates expiry date from production date and shelf life."""
if not production_date_str or not shelf_life or not shelf_unit:
return None
try:
production_date = datetime.date.fromisoformat(production_date_str)
life = int(shelf_life)
if life <= 0: return None # Shelf life must be positive
if shelf_unit == 'days':
return production_date + datetime.timedelta(days=life)
elif shelf_unit == 'months':
return production_date + relativedelta(months=life)
else:
return None # Invalid unit
except (ValueError, TypeError):
return None # Invalid date format or shelf life
def convert_volume(amount, from_unit, to_unit):
"""Converts volume between ml and L."""
if from_unit == to_unit:
return float(amount)
try:
amount = float(amount)
if from_unit == 'ml' and to_unit == 'L':
return amount / 1000.0
if from_unit == 'L' and to_unit == 'ml':
return amount * 1000.0
raise ValueError("Invalid unit conversion")
except (ValueError, TypeError):
raise ValueError(f"Invalid amount or units for conversion: {amount}, {from_unit}, {to_unit}")

BIN
requirements.txt Normal file

Binary file not shown.