feat: 修复 H5 底部导航覆盖 + 更新项目进度文档

## H5 底部导航修复 (Bug #10)
- 精简 App.vue,移除重复 tabbar,仅保留全局样式
- uni-page 设置 height: calc(100% - 50px) + overflow-y: auto
- 内容区域精确停在底部导航上方,独立滚动不再叠加
- 恢复 custom-tab-bar 组件

## 项目进度文档
- PROGRESS.md 更新至 10 个 Bug 修复
- 新增 H5 底部导航修复记录
- 新增历史变更条目
This commit is contained in:
TradeMate Dev
2026-05-12 20:24:42 +08:00
parent 69e164dcae
commit 7b62c2f8b4
125 changed files with 19725 additions and 728 deletions
+155 -57
View File
@@ -10,6 +10,9 @@ logger = logging.getLogger(__name__)
def check_silent_customers():
from app.database import AsyncSessionLocal
from app.models.customer import Customer
from app.models.user import User
from app.services.push import PushService
from app.services.notification import NotificationService
async def _check():
async with AsyncSessionLocal() as db:
@@ -27,12 +30,26 @@ def check_silent_customers():
)
customers = result.scalars().all()
for c in customers:
if days == 3:
logger.info(f"Customer {c.name} silent for 3 days")
elif days == 7:
logger.info(f"Customer {c.name} silent for 7 days - upgrade")
else:
logger.info(f"Customer {c.name} silent for 14 days - recommend new approach")
messages = {
3: ("跟进提醒", f"客户 {c.name} 已沉默3天,建议发送跟进消息"),
7: ("跟进升级", f"客户 {c.name} 已沉默1周,建议发送优惠或新产品信息"),
14: ("跟进提示", f"客户 {c.name} 已沉默14天,建议换话题重新接触"),
}
title, content = messages.get(days, ("跟进提醒", f"客户 {c.name} 已沉默{days}"))
logger.info(f"Customer {c.name} silent for {days} days")
user_result = await db.execute(
select(User).where(User.id == c.user_id)
)
user = user_result.scalar_one_or_none()
if user:
PushService.send_notification(c.user_id, title, content)
await NotificationService.create_notification(
db, c.user_id, title, content,
notification_type="customer_silent",
reference_type="customer",
reference_id=str(c.id),
)
import asyncio
asyncio.run(_check())
@@ -59,6 +76,8 @@ def batch_translate_texts(texts: list, target_lang: str, user_id: str):
def generate_quotation_pdf(quotation_id: str):
from app.database import AsyncSessionLocal
from app.models.quotation import Quotation, QuotationItem
from app.models.customer import Customer
from app.services.pdf_generator import pdf_generator
async def _generate():
async with AsyncSessionLocal() as db:
@@ -74,62 +93,60 @@ def generate_quotation_pdf(quotation_id: str):
)
items = items_result.scalars().all()
pdf_content = generate_pdf_text(q, items)
customer = None
if q.customer_id:
cust_result = await db.execute(
select(Customer).where(Customer.id == q.customer_id)
)
customer = cust_result.scalar_one_or_none()
return {"pdf_content": pdf_content, "quotation_id": str(q.id)}
data = {
"quotation_number": f"{str(q.id)[:8].upper()}",
"customer_name": customer.name if customer else "",
"customer_company": customer.company if customer else "",
"customer_country": customer.country if customer else "",
"date": q.created_at.strftime("%Y-%m-%d") if q.created_at else "",
"valid_until": q.valid_until or "",
"currency": q.currency or "USD",
"items": [
{
"product_name": i.product_name,
"description": i.description,
"quantity": i.quantity,
"unit_price": i.unit_price,
"total_price": i.total_price,
"unit": i.unit or "pcs",
}
for i in items
],
"subtotal": q.subtotal or 0,
"discount": q.discount or 0,
"shipping": q.shipping or 0,
"total": q.total or q.subtotal or 0,
"payment_terms": q.payment_terms or "",
"delivery_terms": q.delivery_terms or "",
"lead_time": q.lead_time or "",
"notes": q.notes or "",
}
pdf_bytes = pdf_generator.generate_quotation(data)
if pdf_bytes:
upload_dir = settings.UPLOAD_DIR
pdf_path = os.path.join(upload_dir, f"quotation_{quotation_id}.pdf")
os.makedirs(upload_dir, exist_ok=True)
with open(pdf_path, "wb") as f:
f.write(pdf_bytes)
q.pdf_url = pdf_path
await db.flush()
return {"success": True, "pdf_path": pdf_path, "quotation_id": str(q.id)}
else:
return {"error": "PDF generation failed (weasyprint not available)", "quotation_id": str(q.id)}
import asyncio
return asyncio.run(_generate())
def generate_pdf_text(quotation, items):
from datetime import datetime
lines = [
"=" * 60,
f"QUOTATION",
f"#{str(quotation.id)[:8].upper()}",
"=" * 60,
f"Date: {datetime.utcnow().strftime('%Y-%m-%d')}",
]
if quotation.valid_until:
lines.append(f"Valid Until: {quotation.valid_until}")
lines.append("")
lines.append(f"{'Item':<30} {'Qty':<8} {'Unit Price':<12} {'Total':<12}")
lines.append("-" * 62)
for item in items:
lines.append(
f"{item.product_name:<30} {item.quantity:<8} ${item.unit_price:<10.2f} ${item.total_price:<10.2f}"
)
lines.append("-" * 62)
if quotation.subtotal:
lines.append(f"{'Subtotal':>48} ${quotation.subtotal:<10.2f}")
if quotation.discount:
lines.append(f"{'Discount':>48} -${quotation.discount:<10.2f}")
if quotation.shipping:
lines.append(f"{'Shipping':>48} ${quotation.shipping:<10.2f}")
lines.append(f"{'TOTAL':>48} ${quotation.total or quotation.subtotal or 0:<10.2f}")
lines.append("")
if quotation.payment_terms:
lines.append(f"Payment Terms: {quotation.payment_terms}")
if quotation.delivery_terms:
lines.append(f"Delivery Terms: {quotation.delivery_terms}")
if quotation.lead_time:
lines.append(f"Lead Time: {quotation.lead_time}")
if quotation.notes:
lines.append(f"Notes: {quotation.notes}")
lines.append("=" * 60)
lines.append("Generated by TradeMate")
return "\n".join(lines)
@shared_task
def process_corpus_quality():
from app.database import AsyncSessionLocal
@@ -155,6 +172,71 @@ def process_corpus_quality():
return asyncio.run(_process())
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def process_customer_import(self, user_id: str, records: list):
from app.database import AsyncSessionLocal
from app.services.customer import CustomerService
async def _import():
async with AsyncSessionLocal() as db:
svc = CustomerService(db)
imported = 0
errors = []
for i, record in enumerate(records):
try:
await svc.create_customer(user_id, record)
imported += 1
except Exception as e:
errors.append(f"Row {i+2}: {str(e)}")
return {"imported": imported, "total": len(records), "errors": errors}
import asyncio
return asyncio.run(_import())
@shared_task
def run_daily_corpus_training():
from app.database import AsyncSessionLocal
from app.services.corpus_trainer import CorpusTrainer
async def _train():
async with AsyncSessionLocal() as db:
trainer = CorpusTrainer(db)
result = await trainer.run_pipeline()
logger.info(f"Daily corpus training complete: {result}")
return result
import asyncio
return asyncio.run(_train())
@shared_task
def update_customer_health_cache():
from app.database import AsyncSessionLocal
from app.services.customer_health import CustomerHealthService
from app.models.user import User
from app.config import settings
async def _update():
async with AsyncSessionLocal() as db:
result = await db.execute(select(User.id))
user_ids = result.scalars().all()
svc = CustomerHealthService(db)
for uid in user_ids:
try:
overview = await svc.get_health_overview(uid)
scores = await svc.get_all_health_scores(uid)
except Exception as e:
logger.error(f"Health cache failed for user {uid}: {e}")
return f"Updated health cache for {len(user_ids)} users"
import asyncio
return asyncio.run(_update())
@shared_task
def cleanup_old_sessions():
import redis.asyncio as aioredis
@@ -190,4 +272,20 @@ def send_followup_reminder(customer_id: str, user_id: str):
return {"error": "Customer not found"}
import asyncio
return asyncio.run(_send())
return asyncio.run(_send())
@shared_task
def check_followup_engine():
from app.database import AsyncSessionLocal
from app.services.followup_engine import FollowupEngine
async def _check():
async with AsyncSessionLocal() as db:
engine = FollowupEngine(db)
result = await engine.scan_and_followup()
logger.info(f"Followup engine check complete: {result}")
return result
import asyncio
return asyncio.run(_check())