feat(frontend): 首页广告位网格溢出根治(minmax(0,1fr) minmax(0,1.2fr)+min-width:0) + 精选推荐对接平台广告接口 GET /app/api/market/ads
This commit is contained in:
1 parent
e38e18fbe7
commit
9b87012248
3 files changed
+74
-7
No files matched your search
@@ -9,7 +9,7 @@ from datetime import datetime
|
|||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import (
|
from ..models import (
|
||||||
User, MarketApp, AppPurchase, AppReview, AppInstallation, Order,
|
User, MarketApp, AppPurchase, AppReview, AppInstallation, Order,
|
||||||
gen_uuid
|
AdBanner, gen_uuid
|
||||||
)
|
)
|
||||||
from ..auth import get_current_user, get_current_user_id, get_current_user_id_optional
|
from ..auth import get_current_user, get_current_user_id, get_current_user_id_optional
|
||||||
from ..middleware.log_service import LogService
|
from ..middleware.log_service import LogService
|
||||||
@@ -17,6 +17,34 @@ from ..middleware.log_service import LogService
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ads")
|
||||||
|
def list_public_ads(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
前台首页「精选推荐」广告位:返回已上架(active)广告,按排序升序,无需登录。
|
||||||
|
供 LandingPage 轮播调用,无鉴权依赖。
|
||||||
|
"""
|
||||||
|
ads = (
|
||||||
|
db.query(AdBanner)
|
||||||
|
.filter(AdBanner.status == "active")
|
||||||
|
.order_by(AdBanner.sort_order.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": a.id,
|
||||||
|
"title": a.title,
|
||||||
|
"image_url": a.image_url,
|
||||||
|
"link_app_id": a.link_app_id,
|
||||||
|
"link_app_name": a.link_app_name,
|
||||||
|
"sort_order": a.sort_order,
|
||||||
|
"click_count": a.click_count,
|
||||||
|
"status": a.status,
|
||||||
|
"created_at": a.created_at.isoformat() if a.created_at else None,
|
||||||
|
}
|
||||||
|
for a in ads
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class UploadAppRequest(BaseModel):
|
class UploadAppRequest(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=200)
|
name: str = Field(min_length=1, max_length=200)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
|||||||
@@ -73,8 +73,13 @@
|
|||||||
}
|
}
|
||||||
.lp-hero-grid {
|
.lp-hero-grid {
|
||||||
max-width: 1200px; margin: 0 auto; padding: 0 var(--space-2xl);
|
max-width: 1200px; margin: 0 auto; padding: 0 var(--space-2xl);
|
||||||
display: grid; grid-template-columns: 1fr 2fr; gap: 0; align-items: center;
|
display: grid;
|
||||||
|
/* minmax(0,..) 锁死轨道最小宽=0,防止右侧轮播的 flex 内容把列撑破而"超出免费注册边框" */
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
|
||||||
|
gap: var(--space-4xl); align-items: center;
|
||||||
}
|
}
|
||||||
|
/* 左右两列都允许收缩,杜绝内容撑破网格导致的水平溢出 */
|
||||||
|
.lp-hero-text { min-width: 0; }
|
||||||
.lp-eyebrow {
|
.lp-eyebrow {
|
||||||
display: inline-block; font-size: var(--text-sm); font-weight: 600; color: var(--color-accent);
|
display: inline-block; font-size: var(--text-sm); font-weight: 600; color: var(--color-accent);
|
||||||
background: var(--color-accent-light); padding: 4px 12px; border-radius: var(--radius-full); margin-bottom: var(--space-lg);
|
background: var(--color-accent-light); padding: 4px 12px; border-radius: var(--radius-full); margin-bottom: var(--space-lg);
|
||||||
@@ -89,12 +94,17 @@
|
|||||||
.lp-stat-num { font-size: var(--text-xl); font-weight: 800; color: var(--color-text-primary); }
|
.lp-stat-num { font-size: var(--text-xl); font-weight: 800; color: var(--color-text-primary); }
|
||||||
.lp-stat-label { font-size: var(--text-sm); color: var(--color-text-tertiary); margin-top: 2px; }
|
.lp-stat-label { font-size: var(--text-sm); color: var(--color-text-tertiary); margin-top: 2px; }
|
||||||
|
|
||||||
.lp-hero-ad { display: flex; flex-direction: column; padding-right: var(--space-3xl); }
|
/* 右侧广告位:min-width:0 让其严格贴合网格右轨道,右边框与顶栏"免费注册"垂直对齐、绝不外溢 */
|
||||||
|
.lp-hero-ad { display: flex; flex-direction: column; min-width: 0; }
|
||||||
.lp-ad-head { display: flex; align-items: baseline; gap: var(--space-md); margin-bottom: var(--space-lg); flex-wrap: wrap; }
|
.lp-ad-head { display: flex; align-items: baseline; gap: var(--space-md); margin-bottom: var(--space-lg); flex-wrap: wrap; }
|
||||||
.lp-ad-title { font-size: var(--text-lg); font-weight: 700; color: var(--color-text-primary); }
|
.lp-ad-title { font-size: var(--text-lg); font-weight: 700; color: var(--color-text-primary); }
|
||||||
.lp-ad-sub { font-size: var(--text-sm); color: var(--color-text-tertiary); }
|
.lp-ad-sub { font-size: var(--text-sm); color: var(--color-text-tertiary); }
|
||||||
.lp-hero-ad .lp-carousel-card { min-height: 300px; }
|
.lp-hero-ad .lp-carousel { width: 100%; }
|
||||||
.lp-hero-ad .lp-cc-content { max-width: 64%; }
|
.lp-hero-ad .lp-carousel-card { min-height: 340px; }
|
||||||
|
.lp-hero-ad .lp-cc-content { max-width: 62%; }
|
||||||
|
.lp-hero-ad .lp-cc-cover { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 0; }
|
||||||
|
.lp-hero-ad .lp-cc-cover + .lp-cc-content { color: #fff; }
|
||||||
|
.lp-hero-ad .lp-cc-scrim { position: absolute; inset: 0; z-index: 1; background: linear-gradient(90deg, rgba(0,0,0,.55) 0%, rgba(0,0,0,.15) 60%, rgba(0,0,0,0) 100%); }
|
||||||
|
|
||||||
/* ===== 轮播广告位 ===== */
|
/* ===== 轮播广告位 ===== */
|
||||||
.lp-band { padding: var(--space-4xl) 0; }
|
.lp-band { padding: var(--space-4xl) 0; }
|
||||||
|
|||||||
@@ -79,7 +79,9 @@ const LandingPage: React.FC = () => {
|
|||||||
const [isPaused, setIsPaused] = useState(false);
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
const [mobileMenu, setMobileMenu] = useState(false);
|
const [mobileMenu, setMobileMenu] = useState(false);
|
||||||
|
|
||||||
const banners = mockAdBanners.filter(b => b.status === 'active');
|
const [banners, setBanners] = useState<AdBanner[]>(
|
||||||
|
mockAdBanners.filter(b => b.status === 'active')
|
||||||
|
);
|
||||||
const publishedApps = mockApps.filter(a => a.status === 'published');
|
const publishedApps = mockApps.filter(a => a.status === 'published');
|
||||||
const topSkills = [...publishedApps].sort((a, b) => b.installCount - a.installCount).slice(0, 5);
|
const topSkills = [...publishedApps].sort((a, b) => b.installCount - a.installCount).slice(0, 5);
|
||||||
|
|
||||||
@@ -94,6 +96,32 @@ const LandingPage: React.FC = () => {
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [banners.length, isPaused, nextSlide]);
|
}, [banners.length, isPaused, nextSlide]);
|
||||||
|
|
||||||
|
// 精选推荐:对接平台「广告位」能力——拉取后台已上架(active)的真实广告轮播;
|
||||||
|
// 无数据或接口异常时回退到内置示例,保证首屏不空。
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
fetch('/app/api/market/ads')
|
||||||
|
.then(r => (r.ok ? r.json() : Promise.reject(r.status)))
|
||||||
|
.then((rows: any[]) => {
|
||||||
|
if (!alive || !Array.isArray(rows) || rows.length === 0) return;
|
||||||
|
const mapped: AdBanner[] = rows.map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
title: a.title,
|
||||||
|
imageUrl: a.image_url || '',
|
||||||
|
linkAppId: a.link_app_id || undefined,
|
||||||
|
linkAppName: a.link_app_name || undefined,
|
||||||
|
sortOrder: a.sort_order ?? 0,
|
||||||
|
clickCount: a.click_count ?? 0,
|
||||||
|
status: (a.status as 'active' | 'inactive') || 'active',
|
||||||
|
createdAt: a.created_at || '',
|
||||||
|
}));
|
||||||
|
setBanners(mapped);
|
||||||
|
setCurrentSlide(0);
|
||||||
|
})
|
||||||
|
.catch(() => { /* 静默失败,保留 mock 回退 */ });
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
// SEO / GEO:动态写入标题与描述
|
// SEO / GEO:动态写入标题与描述
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = '七虎AI · 个人体验 AI、办公提效与 AI 创收的一站式平台';
|
document.title = '七虎AI · 个人体验 AI、办公提效与 AI 创收的一站式平台';
|
||||||
@@ -214,13 +242,14 @@ const LandingPage: React.FC = () => {
|
|||||||
{banners.map(b => (
|
{banners.map(b => (
|
||||||
<div key={b.id} className="lp-slide">
|
<div key={b.id} className="lp-slide">
|
||||||
<div className="lp-carousel-card" style={{ background: gradFor(b.linkAppId || 'a1') }}>
|
<div className="lp-carousel-card" style={{ background: gradFor(b.linkAppId || 'a1') }}>
|
||||||
|
{b.imageUrl && (<><img className="lp-cc-cover" src={b.imageUrl} alt={b.title} loading="lazy" /><span className="lp-cc-scrim" /></>)}
|
||||||
<div className="lp-cc-content">
|
<div className="lp-cc-content">
|
||||||
<div className="lp-cc-badge">推荐</div>
|
<div className="lp-cc-badge">推荐</div>
|
||||||
<h3 className="lp-cc-title">{b.title}</h3>
|
<h3 className="lp-cc-title">{b.title}</h3>
|
||||||
{b.linkAppName && <p className="lp-cc-sub">立即体验 {b.linkAppName}</p>}
|
{b.linkAppName && <p className="lp-cc-sub">立即体验 {b.linkAppName}</p>}
|
||||||
<button className="lp-cc-cta" onClick={goLogin}>查看详情 <Icon d={<polyline points="9 18 15 12 9 6" />} size={14} /></button>
|
<button className="lp-cc-cta" onClick={goLogin}>查看详情 <Icon d={<polyline points="9 18 15 12 9 6" />} size={14} /></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="lp-cc-visual"><span className="c1" /><span className="c2" /><span className="c3" /></div>
|
{!b.imageUrl && <div className="lp-cc-visual"><span className="c1" /><span className="c2" /><span className="c3" /></div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in new issue
Block a user