菜单添加页面模板管理页面,增加一个纯图平铺模板,优化预览手机屏效果

This commit is contained in:
2026-04-17 08:35:45 +08:00
parent 9a6418eb93
commit 052195abab
36 changed files with 1534 additions and 708 deletions

60
RFP.txt
View File

@@ -1,60 +0,0 @@
6. Scope of Work
6.1 Project Background
The project is aiming to setup a Red note mini app portal for Audi China Red note account. With the portals settings, customers can see car models on Red note mini app, and fulfill forms to submit their contact info.
This RFP scope including: 2026 Red note mini app function setup + Application Operation, and year 2027 and 2028 application operation.
6.2 Project Targets
The project targets to setup a Red note mini app portal, as well as the operation to the application and cloud environment.
The portal allows user to edit the pages content which show on Red not mini app and manage API connections to Red note mini app.
6.3 Project Deliverables
6.3.1. Function Deliverables
Setup Red note mini app portal service:
• Platform preparation: Setup a 3rd party platform to manage Red note mini app display content with Audi China account, the platform connect to Red note mini app backend.
• Environment Preparation: 3rd party platform is to be setup on a secured private cloud environment .
• Project Management: Daily account service, timeline management, risk management, quality control, meeting material preparation and other related Red note mini app topics will be discussed on weekly meeting.
Portal maintenance and Content operation service:
• Portal maintenance: Keep the Red note mini app portal available and operational.
• Content operation: Maintain the Red note mini app content as marketing department required. And keep the content update with JVs official websites car model images in weekly regularity .
• Project Status Tracking: Hold weekly meetings for progress updates, issue resolve.
Risk Management, including but not limited to below:
• Data Loss: Verify backups and retain source environment for ≥1 month
• Performance Degradation: Implement monitoring and optimization
• Integration Failure: Establish testing and troubleshooting mechanisms
• Contingency Planning: Prepare rollback and incident response procedures
Operational Requirement, including but not limited to below:
• Application Support:
Operation Time Service Hours Stand-by Hours
Priority critical & high
Monday - Friday 8:30 17:30 17:30 8:30
Saturday-Sunday and Public Holidays
N/A 00:00 24:00
- Monthly operation report required and review with Audi China
- Critical/High Incident report is required
6.3.2. Non-function Requirement
• Vendor Qualifications
Red note mini app knowledge: Comprehensive expertise in integration, applications, cloud, security, and data
Certifications: PMP certification
Operations Experience: Experienced in Red note application operation
• Reusability requirements
Explain how to maximize the use of existing technology platforms, infrastructure, and IT skills (e.g., existing web applications and hardware platforms).
• Openness requirements
A standard open platform interface is used to support data exchange and sharing with other systems. In case theres migration to other platform, the content should be able to easily migrate.
IMPORTANT: The Vendor of choice must have a sufficient number of resources, and identified resources must be available according to agreed project schedule to assure an on-time Go-Live. Other resources should be acted as a backup in case identified resources cannot fulfill their duties.
Support and Knowledge Transfer
Vendor should provide necessary technical support and provide knowledge transition in the project phase.

View File

@@ -119,6 +119,26 @@
#### 3.1.4 内容建设与运营
**FR-015**: 系统应支持基于甲方设计稿搭建约 70 个页面,采用可复用的模板和内容模型提高交付效率。
> **FR-015a 页面类型与模板规范**
>
> 系统采用**模板注册表Template Registry**架构,将页面类型与预览/编辑组件解耦:
>
> - **页面类型pageType**:约 70 种,代表每个具体页面(如"车型页"、"专题页"、"活动页"等)。
> - **页面模板PageTemplate**:约 30 种可复用模板每个模板包含独立的预览组件Preview和编辑字段组件Editor
> - **多对一映射**:多个页面类型可共用同一模板。例如"车型页"和"活动页"共用 `car-page` 模板。
>
> 每个模板定义以下内容:
> | 配置项 | 说明 |
> |--------|------|
> | `templateId` | 模板唯一标识(如 `car-page`、`topic-page` |
> | `pageTypes` | 该模板适用的页面类型列表 |
> | `defaultValues` | 新建内容时的默认字段值 |
> | `PreviewComponent` | 手机端预览渲染组件 |
> | `EditorComponent` | 后台编辑字段渲染组件 |
> | `onSourceCarSelected` | 选择来源车型后的数据填充回调 |
>
> **扩展方式**:新增页面类型时,若已有合适模板,只需在注册表中添加一行映射;若需要全新预览布局,则新建模板文件夹并注册,主组件无需修改。
**FR-016**: 供应商应每周人工巡检 JV 官网内容,并根据巡检结果更新小程序中的车型图片和相关内容。
**FR-017**: 内容更新应支持以下操作:

View File

@@ -1,245 +0,0 @@
# 奥迪小红书小程序门户 - 需求规格说明书 (SRS) - Part 1
## 1. 引言 (Introduction)
### 1.1 目的 (Purpose)
本文档旨在详细定义“奥迪中国小红书小程序门户Audi China Red note mini app portal”项目的软件需求规格。本文档将作为技术开发、系统测试、项目验收以及后期运维管理的基准文件确保所有项目干系人对系统功能与非功能边界达成共识。
### 1.2 文档约定 (Document Conventions)
* **格式:** 本文档采用 Markdown 格式编写,业务流程图采用 Mermaid 语法呈现。
* **优先级定义:** 需求分为“必须具备 (Must have)”、“应该具备 (Should have)”和“可以具备 (Could have)”。未特别标注的系统特性均视为“必须具备”。
* **缩写与术语:** 请参见项目立项初期的《核心术语表Glossary》。
### 1.3 预期读者 (Intended Audience)
* **奥迪中国业务团队:** 确认业务流程与合规要求是否准确。
* **项目开发团队 (前端/后端/架构)** 作为系统设计与编码的直接依据。
* **测试团队 (QA)** 依据本说明书中的业务规则与异常流程编写测试用例。
* **IT运维与安全团队** 评估私有云部署方案、数据保护及 SLA 可行性。
### 1.4 项目范围 (Project Scope)
本项目包含两大部分:
1. **2026年系统建设阶段** 搭建基于安全私有云的第三方内容管理门户,开发小红书小程序前端,并实现与小红书后端及 JV 官网的数据打通。
2. **2026-2028年运维阶段** 提供 7x24 小时的系统 Stand-by 支持,并承担每周的内容运营(图片更新)工作。
---
## 2. 总体描述 (Overall Description)
### 2.1 产品视角 (Product Perspective)
本项目是一个独立部署于奥迪私有云环境的“前端展示+后端管理”微型生态系统。
* **上游对接:** 从各合资企业JV官方网站获取最新的车型视觉资产。
* **下游分发:** 通过小红书开放平台 API将车型内容分发至 C 端用户设备。
* **数据回流:** 将在小红书小程序内收集的客户线索Leads安全地沉淀至私有云数据库为奥迪中国后续的营销闭环提供数据支撑。
### 2.2 用户类与特征 (User Classes)
| 用户角色 | 特征描述 | 系统权限 |
| :--- | :--- | :--- |
| **C端访客** | 浏览小红书的潜在购车用户,对操作流畅度和隐私保护极其敏感。 | 仅限小程序前端的浏览与表单提交操作。 |
| **内容运营人员** | 奥迪营销部门或供应商运营专员,负责每周的车型图片更新。 | 门户后台的内容编辑、发布、审核与数据导出。 |
| **系统管理员** | 负责系统健康度、用户权限分配及接口监控的 IT 人员。 | 门户后台的最高权限,包含系统配置与日志查看。 |
### 2.3 运行环境 (Operating Environment)
* **前端环境:** 兼容小红书 App 最新版iOS/Android的小程序运行沙箱环境。
* **后端环境:** 奥迪指定的 Secured Private Cloud私有云操作系统建议为基于 Linux 的企业级发行版,需支持 Docker/K8s 等容器化部署以满足高可用需求。
### 2.4 设计与实现约束 (Constraints)
* **合规约束 (PIPL)** 任何涉及用户 PII个人身份信息的收集、存储与传输必须采用强加密手段如 AES-256且必须在收集前获取用户显式授权。
* **API 速率限制:** 需严格遵守小红书开放平台的接口调用频率限制,避免因频繁同步导致接口被封禁。
* **开放性约束 (Openness)** 后端数据结构设计必须解耦,提供标准 RESTful API 或数据导出机制,以防止未来的供应商锁定 (Vendor Lock-in)。
---
## 3. 系统特性 (System Features)
### 3.1 车型库内容管理与同步展示
**特性描述:** 系统需要支持从 JV 官网获取最新车型图片,并在小红书小程序前端进行动态展示。
#### 3.1.1 业务流程交互图
```mermaid
sequenceDiagram
autonumber
participant Op as 运营人员
participant Portal as 第三方门户(Portal)
participant JV as JV官网系统接口
participant RN as 小红书平台
Op->>Portal: 发起/配置每周同步任务
Portal->>JV: 请求最新车型图片数据 (API)
alt 正常流程: 获取成功
JV-->>Portal: 返回图片数据及元数据
Portal->>Portal: 图片格式检查与压缩处理
Portal->>RN: 推送更新至小红书小程序
RN-->>Portal: 同步成功响应
Portal-->>Op: 提示[同步完成]
else 异常流程: 接口故障或格式错误
JV-->>Portal: 错误响应 / 请求超时
Portal-->>Op: 终止同步,触发异常告警
end
```
#### 3.1.2 业务规则与流程定义
* **正常流程 (Normal Flow)**
1. 运营人员登录 Portal进入“车型库管理”模块。
2. 触发或确认系统每周自动执行的“JV官网同步任务”。
3. Portal 成功获取数据校验图片尺寸需符合小红书UI规范并推送到前端展示。
* **异常流程 (Exception Flow)**
* **异常1网络/接口超时):** 若请求 JV 官网超过 10 秒未响应,重试 3 次;若仍失败,系统记录 Error 日志,并向管理员发送告警邮件,前端继续展示上一版本缓存图片。
* **异常2图片格式不支持** 若抓取的图片非 WebP/PNG/JPG 格式,或单张大小超过 5MB系统拦截该图片入库在 Portal 标记异常条目要求人工介入处理。
---
### 3.2 客户线索收集与流转 (Lead Generation)
**特性描述:** 允许 C 端用户在小程序中填写联系方式以获取更多购车资讯,系统需安全处理这部分 PII 数据。
#### 3.2.1 业务流程交互图
```mermaid
sequenceDiagram
autonumber
participant User as 小红书用户
participant MiniApp as 小程序前端
participant Portal as 私有云Portal后端
User->>MiniApp: 浏览车型详情,点击"联系我们"
MiniApp->>User: 弹出《个人信息保护授权书》(PIPL)
alt 正常流程: 用户同意授权
User->>MiniApp: 点击同意,填写手机号等留资表单
MiniApp->>Portal: [HTTPS] 提交加密表单数据
Portal->>Portal: 数据完整性校验 & 敏感字段落库加密
Portal-->>MiniApp: 返回提交成功状态
MiniApp-->>User: 展示"提交成功,我们将尽快联系您"视图
else 异常流程 A: 拒绝授权
User->>MiniApp: 点击拒绝
MiniApp-->>User: 阻断表单填写,返回上一页
else 异常流程 B: 后端校验失败
Portal-->>MiniApp: 返回校验错误(如手机号格式错误)
MiniApp-->>User: 提示修改错误字段
end
```
#### 3.2.2 业务规则与流程定义
* **正常流程 (Normal Flow)**
1. 用户必须先通过 PIPL 协议的弹窗授权(勾选或点击同意)。
2. 提交包含必填项(如姓名、手机号、意向车型)的表单。
3. Portal 后端接收到数据后,对“手机号”进行加密存储,并将该条留资状态标记为“未处理”。
* **异常流程 (Exception Flow)**
* **异常1拒绝授权** 强阻断。未授权 PIPL 的用户绝对不允许进行任何表单输入操作。
* **异常2数据库写入失败** 若由于私有云数据库故障导致无法落库,前端提示“系统繁忙,请稍后再试”,同时触发 Critical 级别的系统内部告警。
---
### 3.3 第三方门户配置与维护 (3rd Party Portal Admin)
**特性描述:** 提供一个后台管理界面,确保小红书小程序的内容高可用,并支持按需调整。
* **业务规则:**
1. **所见即所得 (WYSIWYG)** 运营人员可以在后台编辑车型介绍文本,并能预览在小程序上的实际展示效果。
2. **API 状态监控:** Portal 仪表盘需实时展示与小红书后端及 JV 官网接口的连通性状态(绿/黄/红灯)。
3. **数据隔离:** 留资数据的查看与导出需具备独立的权限管控,禁止普通运营人员全量导出用户隐私数据,导出操作需记录系统审计日志 (Audit Log)。
---
---
# 奥迪小红书小程序门户 - 需求规格说明书 (SRS) - Part 2
## 4. 外部接口需求 (External Interface Requirements)
### 4.1 用户界面 (User Interfaces)
#### 4.1.1 小红书小程序前端 UI 状态机
前端界面必须严格遵循奥迪中国品牌视觉规范VI以及小红书小程序的设计规范。以下是核心的“留资Lead Generation”交互状态机
```mermaid
stateDiagram-v2
[*] --> 车型列表页
车型列表页 --> 车型详情页 : 点击车型
车型详情页 --> 留资表单页 : 点击[获取底价/预约试驾]
留资表单页 --> PIPL授权弹窗 : 页面加载时或提交前拦截
PIPL授权弹窗 --> 留资表单页 : 用户[同意]
PIPL授权弹窗 --> 车型详情页 : 用户[拒绝] (操作阻断)
留资表单页 --> 数据校验中 : 点击[提交]
数据校验中 --> 提交成功页 : 校验通过 & 后端落库成功
数据校验中 --> 留资表单页 : 校验失败 (提示具体字段错误)
提交成功页 --> [*] : 返回首页/关闭
```
#### 4.1.2 第三方门户后台UI 规范
* **响应式设计:** 必须支持主流桌面浏览器Chrome, Edge, Safari分辨率适配 1920x1080 及以上。
* **数据脱敏展示:** 运营人员在后台查看留资列表时涉及个人隐私如手机号的字段必须默认以脱敏形式Masking展示例如`138****1234`),仅拥有特定高级权限的管理员方可查看或导出明文。
### 4.2 软件接口 (Software Interfaces)
系统需通过标准化的 API 实现内部平台与外部数据源的对接,满足 RFP 对“开放性 (Openness)”的要求。
| 接口名称 | 调用方向 | 协议/架构 | 核心功能描述 | 异常处理机制 |
| :--- | :--- | :--- | :--- | :--- |
| **JV 官网图片同步 API** | Portal -> JV官网 | RESTful GET | 获取合资公司官网最新车型库的图片链接及元数据。 | 超时重试 3 次,失败则触发系统告警。 |
| **小红书内容下发 API** | Portal -> 小红书开放平台 | RESTful POST | 将编辑好的页面结构与内容推送到小程序端。 | 依据小红书 API 返回的 Error Code 进行针对性记录与重发。 |
| **留资数据导出 API** | 外部CRM -> Portal | RESTful GET | 提供标准开放接口,支持奥迪其他业务系统(如 CRM拉取线索数据。 | 强身份鉴权,基于 Token 限制调用频率。 |
### 4.3 通信接口 (Communications Interfaces)
* **传输加密:** 所有内外网通信必须基于 HTTPS 协议,采用 TLS 1.2 或更高版本进行加密。
* **接口鉴权:** * 针对内部后台管理,采用 JWT (JSON Web Token) 进行会话管理。
* 针对外部 API 调用,采用基于 OAuth 2.0 的授权机制或高强度的 API Key + Secret 签名校验。
---
## 5. 非功能性需求 (Nonfunctional Requirements)
### 5.1 性能与服务可用性需求 (Performance & SLA)
根据 RFP 规定项目进入运维阶段后必须满足以下服务水平协议SLA
* **系统可用性 (Availability)** 核心服务可用性需达到 99.9%。
* **服务时间 (Service Hours)**
* **常规支持 (Service Hours):** 周一至周五 08:30 - 17:30。
* **待命支持 (Stand-by Hours):** 周一至周五 17:30 - 次日 08:30周末及法定节假日 00:00 - 24:00。
* **响应与解决指标 (Critical & High Priority)**
* **响应时间 (Response Time)** 任何 Critical/High 级别的系统故障,在接收到告警后,支持团队必须在 **15 分钟** 内响应。
* **解决时间 (Resolution Time)** 必须在 **4 小时** 内提供临时解决方案Workaround或彻底修复并提交事件分析报告。
### 5.2 安全、隐私与合规需求 (Security & Privacy)
针对 RFP 中强调的 **Data Loss** 和合规风险,制定以下策略:
* **数据保留与备份 (Backup & Retention)**
* 数据库需执行**每日增量备份**与**每周全量备份**。
* 根据 RFP 强制要求,源环境备份数据必须保留**至少 1 个月(≥ 1 month**。
* 备份文件必须进行加密处理,并存储在异地或隔离的私有云存储桶中。
* **PIPL 个人信息保护合规:**
* **存储加密:** 数据库中涉及客户 PII个人身份信息的字段如姓名、手机号必须使用 AES-256 对称加密算法存储Data at Rest Encryption
* **审计日志:** 所有涉及敏感数据导出的操作必须记录不可篡改的审计日志包含操作人、时间、IP、导出条数
### 5.3 可维护性与容灾需求 (Maintainability & Contingency)
针对 RFP 中提出的 **Performance Degradation**, **Integration Failure**, 和 **Contingency Planning** 风险:
* **全链路监控体系:** 部署系统级监控(如 CPU、内存、磁盘 I/O与应用级监控接口响应耗时、错误率。当性能指标超过阈值如 API 响应时间 > 3秒自动通过邮件/短信通知运维人员。
* **性能降级预案:** 当小红书并发访问量激增导致私有云后端过载时,系统应具备“服务降级”能力,如暂停非核心的 JV 官网同步任务,优先保障留资接口的吞吐量。
* **回滚机制 (Rollback)** 任何生产环境的发布必须配备详细的回滚 SOP。若发布后发生重大缺陷系统必须能够在 **30 分钟** 内恢复到上一个稳定版本(可通过虚拟机快照或蓝绿部署实现)。
### 5.4 可重用性与开放性 (Reusability & Openness)
* **技术资产重用:** 系统架构应采用前后端分离的微服务设计理念。底层服务组件(如短信通知模块、日志模块)应封装为通用库,以便在奥迪未来的其他 IT 项目中复用。
* **平台防绑定 (No Vendor Lock-in)** 前端采用标准框架后端数据模型需结构化。若未来发生平台迁移系统确保能够一键导出所有的图片资源包与关系型数据CSV/SQL 格式),且开放接口可平滑切换路由至新平台。
---
## 6. 附录 (Appendix)
### 6.1 业务规则列表 (Business Rules)
* **BR-001 [合规阻断]:** 未同意《隐私政策》与 PIPL 授权条款的用户,不得激活留资表单的提交按钮。
* **BR-002 [内容准入]:** 运营人员上传或自动同步的车型图片,单张体积不得超过 5MB且必须通过系统自带的敏感词与违规图像AI初步筛查后方可推送到前端。
* **BR-003 [运维汇报]:** 每月首个工作日必须向奥迪中国提交上月《月度运营报告Monthly Operation Report》。
### 6.2 待确定问题 (TBD List)
*(注以下问题需要在方案设计阶段或项目启动会Kick-off上与奥迪方进一步明确)*
1. **私有云环境边界:** 奥迪中国提供的“Secured private cloud”具体是基于哪种底层架构如 VMWare、特定的厂商云是否允许使用容器化Docker/K8s部署
2. **JV 接口协议:** 合资企业官网目前是否已经具备标准的、开箱即用的 RESTful API 供本项目调用如果不具备是否需要本项目团队进行网页数据抓取Scraping
3. **第三方平台选型:** RFP 中提到的“3rd party platform”客户是否有预期的偏好名单还是完全由供应商进行选型推荐
---

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

View File

Before

Width:  |  Height:  |  Size: 933 KiB

After

Width:  |  Height:  |  Size: 933 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 603 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

View File

@@ -5,6 +5,7 @@ import { CarLibrary } from "@/components/car-library/CarLibrary"
import { MiniAppContentLibrary } from "@/components/content-library/MiniAppContentLibrary"
import { UserAccessManagement } from "@/components/users/UserAccessManagement"
import { SystemSettings } from "@/components/settings/SystemSettings"
import { TemplateManagement } from "@/components/templates/TemplateManagement"
import { Separator } from "@/components/ui/separator"
import { Bell, Search, User } from "lucide-react"
import { Button } from "@/components/ui/button"
@@ -65,6 +66,8 @@ export default function App() {
newContentRequest={null}
/>
)
case "templates":
return <TemplateManagement />
case "user-access":
return <UserAccessManagement />
case "settings":
@@ -99,6 +102,7 @@ export default function App() {
<div className="text-sm font-medium text-muted-foreground">
{activeTab === "car-library" && "官网车型库"}
{activeTab === "mini-content" && "小程序内容库"}
{activeTab === "templates" && "小程序页面模板"}
{activeTab === "user-access" && "用户与权限"}
{activeTab === "settings" && "系统设置"}
</div>

View File

@@ -21,7 +21,7 @@ type SyncStatus = "synced" | "pending" | "error"
type SourceCar = {
id: string
name: string
pageType: "车型页" | "活动页" | "专题页"
pageType: "车型页" | "活动页" | "专题页" | "纯图页"
title: string
subtitle: string
highlights: string
@@ -37,7 +37,7 @@ type SourceCar = {
}
type SourceCarDraft = {
pageType: "车型页" | "活动页" | "专题页"
pageType: "车型页" | "活动页" | "专题页" | "纯图页"
title: string
subtitle: string
highlights: string
@@ -200,10 +200,53 @@ const SOURCE_CARS: SourceCar[] = [
"/images/cars/rs7-2.jpg",
],
},
{
id: "e7x",
name: "E7X",
pageType: "纯图页",
title: "E7X详情",
subtitle: "先锋纯电旗舰",
highlights: "纯电驱动\n先锋设计\n智能座舱",
description: "E7X 纯图平铺模板内容,面向视觉导向场景。",
ctaText: "预约体验",
scheduledPublishAt: "",
officialTagline: "先锋纯电旗舰",
sourceUpdatedAt: "2026-04-16 10:30",
updatedBy: "内容运营专员",
syncStatus: "synced",
imageCount: 3,
imageUrls: [
"/images/cars/ex7-1.jpg",
"/images/cars/ex7-2.jpg",
"/images/cars/ex7-3.jpg",
],
},
{
id: "a7",
name: "A7",
pageType: "专题页",
title: "A7详情",
subtitle: "",
highlights: "RS竞速套件\n宽体低趴 超长轴距\n高性能美学锋芒尽释\n懂你更懂未来",
description: "A7 专题图文模板内容,聚焦视觉叙事与图文模块表达。",
ctaText: "预约试驾",
scheduledPublishAt: "",
officialTagline: "先锋设计",
sourceUpdatedAt: "2026-04-16 10:40",
updatedBy: "内容运营专员",
syncStatus: "synced",
imageCount: 4,
imageUrls: [
"/images/cars/a7-1.jpg",
"/images/cars/a7-2.jpg",
"/images/cars/a7-3.jpg",
"/images/cars/a7-4.jpg",
],
},
]
const INITIAL_LOGS: SyncLog[] = [
{ id: "l1", time: "2026-04-11 09:30", status: "success", message: "同步成功:7 个车型,素材 31 张" },
{ id: "l1", time: "2026-04-11 09:30", status: "success", message: "同步成功:9 个车型,素材 38 张" },
{ id: "l2", time: "2026-04-10 09:15", status: "failed", message: "Audi Q5L 图片拉取失败CDN 超时" },
]
@@ -245,12 +288,12 @@ export function CarLibrary({ roleView, workflowConfig, onAddAuditLog }: CarLibra
const [editingCarId, setEditingCarId] = React.useState<string | null>(null)
const [editorDraft, setEditorDraft] = React.useState<SourceCarDraft | null>(null)
const [newImageUrl, setNewImageUrl] = React.useState("")
const [listPage, setListPage] = React.useState(1)
const initialDraftRef = React.useRef<SourceCarDraft | null>(null)
const listPageSize = 7
const listTotalPages = 10
const listPage = 1
const filtered = sourceCars.filter((car) => car.name.toLowerCase().includes(query.toLowerCase()))
const listTotalPages = Math.max(1, Math.ceil(filtered.length / listPageSize))
const pagedCars = filtered.slice((listPage - 1) * listPageSize, listPage * listPageSize)
const previewCar = sourceCars.find((car) => car.id === previewCarId) ?? null
const previewImages = previewCar?.imageUrls ?? []
@@ -261,6 +304,16 @@ export function CarLibrary({ roleView, workflowConfig, onAddAuditLog }: CarLibra
return JSON.stringify(editorDraft) !== JSON.stringify(initialDraftRef.current)
}, [editorDraft])
React.useEffect(() => {
setListPage(1)
}, [query])
React.useEffect(() => {
if (listPage > listTotalPages) {
setListPage(listTotalPages)
}
}, [listPage, listTotalPages])
React.useEffect(() => {
if (!previewCarId || previewImages.length === 0) return
@@ -300,7 +353,7 @@ export function CarLibrary({ roleView, workflowConfig, onAddAuditLog }: CarLibra
}))
)
setSyncLogs((curr) => [
{ id: String(Date.now()), time, status: "success", message: "本周人工周更完成:7 个车型素材已核对并更新" },
{ id: String(Date.now()), time, status: "success", message: "本周人工周更完成:9 个车型素材已核对并更新" },
...curr,
])
onAddAuditLog("内容运营专员执行 JV 官网人工周更")
@@ -636,21 +689,21 @@ export function CarLibrary({ roleView, workflowConfig, onAddAuditLog }: CarLibra
</Table>
<div className="mt-4 flex items-center justify-between border-t pt-4">
<p className="text-xs text-muted-foreground"> 7 10 </p>
<p className="text-xs text-muted-foreground"> 7 {listTotalPages} </p>
<div className="flex items-center gap-1">
<Button size="sm" variant="outline" disabled></Button>
<Button size="sm" variant="outline" disabled={listPage <= 1} onClick={() => setListPage((prev) => Math.max(1, prev - 1))}></Button>
{Array.from({ length: listTotalPages }, (_, index) => (
<Button
key={`car-page-${index + 1}`}
size="sm"
variant={index + 1 === listPage ? "default" : "outline"}
className={index + 1 === listPage ? "bg-audi-black hover:bg-audi-dark-gray text-white" : ""}
disabled
onClick={() => setListPage(index + 1)}
>
{index + 1}
</Button>
))}
<Button size="sm" variant="outline" disabled></Button>
<Button size="sm" variant="outline" disabled={listPage >= listTotalPages} onClick={() => setListPage((prev) => Math.min(listTotalPages, prev + 1))}></Button>
</div>
</div>
</CardContent>

View File

@@ -25,47 +25,10 @@ import {
TableRow,
} from "@/components/ui/table"
type WorkflowStatus = "draft" | "prepublished" | "published"
type TopicSection = {
id: string
title: string
imageUrl: string
specLeftLabel: string
specRightLabel: string
}
type SourceSnapshot = {
title: string
subtitle: string
highlights: string
description: string
ctaText: string
imageUrls: string[]
topicNavTitle?: string
topicHeroTitle?: string
topicSections?: TopicSection[]
}
type MiniAppContent = {
id: string
sourceCarId: string
sourceCarName: string
pageType: "车型页" | "活动页" | "专题页"
title: string
subtitle: string
highlights: string
description: string
ctaText: string
scheduledPublishAt: string
imageUrls: string[]
topicNavTitle?: string
topicHeroTitle?: string
topicSections?: TopicSection[]
workflowStatus: WorkflowStatus
updatedBy: string
updatedAt: string
}
import type { WorkflowStatus, TopicSection, SourceSnapshot, MiniAppContent } from "./types"
import { getTemplate, getAllTemplates } from "./template-registry"
import "./register-templates"
import { createTopicSectionsFromImages, buildTopicPatch } from "./register-templates"
type MiniAppContentLibraryProps = {
roleView: RoleView
@@ -227,8 +190,83 @@ const CONTENTS: MiniAppContent[] = [
updatedBy: "内容运营专员",
updatedAt: "2026-04-12 10:20",
},
{
id: "c8",
sourceCarId: "e7x",
sourceCarName: "E7X",
pageType: "纯图页",
title: "E7X详情",
subtitle: "",
highlights: "",
description: "",
ctaText: "预约体验",
scheduledPublishAt: "",
imageUrls: [
"/images/cars/ex7-1.jpg",
"/images/cars/ex7-2.jpg",
"/images/cars/ex7-3.jpg",
],
workflowStatus: "draft",
updatedBy: "内容运营专员",
updatedAt: "2026-04-16 10:30",
},
{
id: "c9",
sourceCarId: "a7",
sourceCarName: "A7",
pageType: "专题页",
title: "A7详情",
subtitle: "",
highlights: "RS竞速套件\n宽体低趴 超长轴距\n高性能美学锋芒尽释\n懂你更懂未来",
description: "",
ctaText: "预约试驾",
scheduledPublishAt: "",
imageUrls: [
"/images/cars/a7-1.jpg",
"/images/cars/a7-2.jpg",
"/images/cars/a7-3.jpg",
"/images/cars/a7-4.jpg",
],
topicNavTitle: "A7详情",
topicHeroTitle: "先锋设计",
topicSections: [
{
id: "a7-topic-1",
title: "RS竞速套件",
imageUrl: "/images/cars/a7-1.jpg",
specLeftLabel: "长",
specRightLabel: "宽",
},
{
id: "a7-topic-2",
title: "宽体低趴 超长轴距",
imageUrl: "/images/cars/a7-2.jpg",
specLeftLabel: "长",
specRightLabel: "宽",
},
{
id: "a7-topic-3",
title: "高性能美学,锋芒尽释",
imageUrl: "/images/cars/a7-3.jpg",
specLeftLabel: "长",
specRightLabel: "宽",
},
{
id: "a7-topic-4",
title: "懂你,更懂未来",
imageUrl: "/images/cars/a7-4.jpg",
specLeftLabel: "长",
specRightLabel: "宽",
},
],
workflowStatus: "draft",
updatedBy: "内容运营专员",
updatedAt: "2026-04-16 10:40",
},
]
const INITIAL_CONTENT_IDS = new Set(CONTENTS.map((item) => item.id))
const WORKFLOW_LABEL: Record<WorkflowStatus, string> = {
draft: "草稿",
prepublished: "预发布",
@@ -240,7 +278,7 @@ const SOURCE_SNAPSHOTS: Record<string, SourceSnapshot> = {
title: "Audi A3 Sportback",
subtitle: "进取,不负期待",
highlights: "数字座舱\n城市通勤\n智能互联",
description: "来自官网车型库的车型基线内容。",
description: "适合城市用户的豪华紧凑车型,兼顾效率与智能体验。",
ctaText: "立即预约试驾",
imageUrls: [
"/images/cars/a3-1.jpg",
@@ -251,7 +289,7 @@ const SOURCE_SNAPSHOTS: Record<string, SourceSnapshot> = {
title: "Audi A4L",
subtitle: "做更强大的自己",
highlights: "quattro\n商务舒适\n智能互联",
description: "来自官网车型库的车型基线内容。",
description: "面向商务人群的主推内容版本,突出舒适与操控。",
ctaText: "了解更多",
imageUrls: [
"/images/cars/a4-1.jpg",
@@ -261,7 +299,7 @@ const SOURCE_SNAPSHOTS: Record<string, SourceSnapshot> = {
title: "Audi A6L",
subtitle: "懂你,更懂未来",
highlights: "行政旗舰\n长轴空间\n智能辅助",
description: "来自官网车型库的车型基线内容。",
description: "正在进行文案优化,待业务审核。",
ctaText: "预约顾问回电",
imageUrls: [
"/images/cars/a6-1.jpg",
@@ -272,7 +310,7 @@ const SOURCE_SNAPSHOTS: Record<string, SourceSnapshot> = {
title: "Audi Q3",
subtitle: "活出生命的辽阔",
highlights: "紧凑SUV\n智能互联\n都市通勤",
description: "来自官网车型库的车型基线内容。",
description: "新建草稿,待进一步补充图文和活动权益信息。",
ctaText: "立即预约试驾",
imageUrls: [
"/images/cars/q3-1.jpg",
@@ -283,7 +321,7 @@ const SOURCE_SNAPSHOTS: Record<string, SourceSnapshot> = {
title: "Audi Q5L",
subtitle: "自由,由我定义",
highlights: "四驱性能\n家庭空间\n全场景出行",
description: "来自官网车型库的车型基线内容。",
description: "因权益文案与活动规则不一致,被驳回待修订。",
ctaText: "预约试驾",
imageUrls: [
"/images/cars/q5-1.jpg",
@@ -294,7 +332,7 @@ const SOURCE_SNAPSHOTS: Record<string, SourceSnapshot> = {
title: "Audi A8L",
subtitle: "旗舰格局,沉稳之选",
highlights: "旗舰行政\n豪华座舱\n专属服务",
description: "来自官网车型库的车型基线内容。",
description: "已发布后因活动档期调整撤回,待重新排期。",
ctaText: "预约专属顾问",
imageUrls: [
"/images/cars/a8-1.jpg",
@@ -305,13 +343,70 @@ const SOURCE_SNAPSHOTS: Record<string, SourceSnapshot> = {
title: "Audi RS7 Performance",
subtitle: "高性能美学,锋芒尽释",
highlights: "V8双涡轮\nquattro四驱\nRS专属运动套件",
description: "来自官网车型库的车型基线内容。",
description: "聚焦高性能驾驶体验与豪华运动设计,适配高意向用户内容触达场景。",
ctaText: "预约性能试驾",
imageUrls: [
"/images/cars/rs7-1.jpg",
"/images/cars/rs7-2.jpg",
],
},
e7x: {
title: "E7X详情",
subtitle: "先锋纯电旗舰",
highlights: "纯电驱动\n先锋设计\n智能座舱",
description: "",
ctaText: "预约体验",
imageUrls: [
"/images/cars/ex7-1.jpg",
"/images/cars/ex7-2.jpg",
"/images/cars/ex7-3.jpg",
],
},
a7: {
title: "A7详情",
subtitle: "先锋设计",
highlights: "RS竞速套件\n宽体低趴 超长轴距\n高性能美学锋芒尽释\n懂你更懂未来",
description: "",
ctaText: "预约试驾",
imageUrls: [
"/images/cars/a7-1.jpg",
"/images/cars/a7-2.jpg",
"/images/cars/a7-3.jpg",
"/images/cars/a7-4.jpg",
],
topicNavTitle: "A7详情",
topicHeroTitle: "先锋设计",
topicSections: [
{
id: "a7-topic-1",
title: "RS竞速套件",
imageUrl: "/images/cars/a7-1.jpg",
specLeftLabel: "长",
specRightLabel: "宽",
},
{
id: "a7-topic-2",
title: "宽体低趴 超长轴距",
imageUrl: "/images/cars/a7-2.jpg",
specLeftLabel: "长",
specRightLabel: "宽",
},
{
id: "a7-topic-3",
title: "高性能美学,锋芒尽释",
imageUrl: "/images/cars/a7-3.jpg",
specLeftLabel: "长",
specRightLabel: "宽",
},
{
id: "a7-topic-4",
title: "懂你,更懂未来",
imageUrl: "/images/cars/a7-4.jpg",
specLeftLabel: "长",
specRightLabel: "宽",
},
],
},
}
const SOURCE_CAR_OPTIONS = [
@@ -322,31 +417,10 @@ const SOURCE_CAR_OPTIONS = [
{ id: "q5l", name: "Audi Q5L" },
{ id: "a8l", name: "Audi A8L" },
{ id: "rs7", name: "Audi RS7" },
{ id: "e7x", name: "E7X" },
{ id: "a7", name: "A7" },
] as const
function createTopicSectionsFromImages(images: string[]) {
const picks = images.slice(0, 2)
const fallback = ["/images/cars/a7-1.jpg", "/images/cars/a7-2.jpg"]
const selected = picks.length > 0 ? picks : fallback
return selected.map((imageUrl, index) => ({
id: `topic-section-${index + 1}`,
title: index === 0 ? "RS竞速套件" : "宽体低趴 超长轴距",
imageUrl,
specLeftLabel: "长",
specRightLabel: "宽",
}))
}
function buildTopicPatch(sourceCarName: string, snapshot?: SourceSnapshot): Pick<MiniAppContent, "topicNavTitle" | "topicHeroTitle" | "topicSections"> {
const hero = snapshot?.topicHeroTitle ?? snapshot?.subtitle ?? "先锋设计"
return {
topicNavTitle: snapshot?.topicNavTitle ?? `${sourceCarName || "车型"}详情`,
topicHeroTitle: hero,
topicSections: snapshot?.topicSections ?? createTopicSectionsFromImages(snapshot?.imageUrls ?? []),
}
}
function workflowBadgeClass(status: WorkflowStatus) {
switch (status) {
case "draft":
@@ -369,9 +443,8 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
const [query, setQuery] = React.useState("")
const [newImageUrl, setNewImageUrl] = React.useState("")
const [pendingResetPatch, setPendingResetPatch] = React.useState<Record<string, Partial<MiniAppContent>>>({})
const [listPage, setListPage] = React.useState(1)
const listPageSize = 7
const listTotalPages = 10
const listPage = 1
React.useEffect(() => {
if (!newContentRequest) return
@@ -379,16 +452,18 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
const id = `c${newContentRequest.requestId}`
const existing = contents.find((item) => item.id === id)
if (!existing) {
const defaultPageType = getAllTemplates()[0]?.pageTypes[0] ?? "车型页"
const tmpl = getTemplate(defaultPageType)
const draft: MiniAppContent = {
id,
sourceCarId: newContentRequest.sourceCarId,
sourceCarName: newContentRequest.sourceCarName,
pageType: "车型页",
pageType: defaultPageType,
title: newContentRequest.sourceCarName,
subtitle: "",
highlights: "",
description: "",
ctaText: "立即预约试驾",
ctaText: "",
scheduledPublishAt: "",
imageUrls: [],
topicNavTitle: `${newContentRequest.sourceCarName}详情`,
@@ -397,6 +472,7 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
workflowStatus: "draft",
updatedBy: "内容运营专员",
updatedAt: nowString(),
...tmpl?.defaultValues,
}
setContents((prev) => [draft, ...prev])
}
@@ -412,8 +488,19 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
const key = `${item.sourceCarName} ${item.sourceCarId} ${item.pageType} ${item.title}`.toLowerCase()
return key.includes(query.toLowerCase())
})
const listTotalPages = Math.max(1, Math.ceil(filteredContents.length / listPageSize))
const pagedContents = filteredContents.slice((listPage - 1) * listPageSize, listPage * listPageSize)
React.useEffect(() => {
setListPage(1)
}, [query])
React.useEffect(() => {
if (listPage > listTotalPages) {
setListPage(listTotalPages)
}
}, [listPage, listTotalPages])
const updateContent = (id: string, patch: Partial<MiniAppContent>) => {
setContents((prev) =>
prev.map((item) => (item.id === id ? { ...item, ...patch, updatedAt: nowString() } : item))
@@ -430,7 +517,7 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
return next
})
if (Object.keys(stagedPatch).length > 0) {
onAddAuditLog(`内容运营专员保存并重置 ${item.sourceCarName}官网源数据(回草稿)`)
onAddAuditLog(`内容运营专员保存并重置 ${item.sourceCarName}来源车型同步内容(回草稿)`)
} else {
onAddAuditLog(`内容运营专员保存 ${item.sourceCarName} 内容草稿`)
}
@@ -439,19 +526,16 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
const stageResetFromSource = (item: MiniAppContent) => {
const source = SOURCE_SNAPSHOTS[item.sourceCarId]
if (!source) return
if (!window.confirm("确认重置当前内容吗?此操作将删除当前编辑内容,并重新与所选“来源车型”进行同步。")) {
return
}
const tmpl = getTemplate(item.pageType)
const templatePatch = tmpl?.onSourceCarSelected?.(source, item) ?? {}
setPendingResetPatch((prev) => ({
...prev,
[item.id]: {
title: source.title,
subtitle: source.subtitle,
highlights: source.highlights,
description: source.description,
ctaText: item.pageType === "专题页" ? "预约体验" : source.ctaText,
imageUrls: item.pageType === "专题页" ? [] : source.imageUrls,
...buildTopicPatch(item.sourceCarName, source),
},
[item.id]: templatePatch,
}))
onAddAuditLog(`内容运营专员发起 ${item.sourceCarName} 源数据重置(待保存生效)`)
onAddAuditLog(`内容运营专员发起 ${item.sourceCarName} 内容重置(待保存生效)`)
}
const submitForReview = (item: MiniAppContent) => {
@@ -476,16 +560,18 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
const createNewContent = () => {
const id = `c${Date.now()}`
const defaultPageType = ""
const tmpl = defaultPageType ? getTemplate(defaultPageType) : undefined
const draft: MiniAppContent = {
id,
sourceCarId: "",
sourceCarName: "",
pageType: "车型页",
pageType: defaultPageType,
title: "",
subtitle: "",
highlights: "",
description: "",
ctaText: "立即预约试驾",
ctaText: "",
scheduledPublishAt: "",
imageUrls: [],
topicNavTitle: "车型详情",
@@ -494,6 +580,7 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
workflowStatus: "draft",
updatedBy: "内容运营专员",
updatedAt: nowString(),
...tmpl?.defaultValues,
}
setContents((prev) => [draft, ...prev])
setSelectedId(id)
@@ -527,14 +614,18 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
}
const updateTopicSection = (item: MiniAppContent, sectionId: string, patch: Partial<TopicSection>) => {
const sections = item.topicSections ?? []
const sections = (item.topicSections && item.topicSections.length > 0)
? item.topicSections
: createTopicSectionsFromImages(item.imageUrls)
updateContent(item.id, {
topicSections: sections.map((section) => (section.id === sectionId ? { ...section, ...patch } : section)),
})
}
const addTopicSection = (item: MiniAppContent) => {
const sections = item.topicSections ?? []
const sections = (item.topicSections && item.topicSections.length > 0)
? item.topicSections
: createTopicSectionsFromImages(item.imageUrls)
if (sections.length >= 6) return
updateContent(item.id, {
topicSections: [
@@ -551,7 +642,9 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
}
const removeTopicSection = (item: MiniAppContent, sectionId: string) => {
const sections = item.topicSections ?? []
const sections = (item.topicSections && item.topicSections.length > 0)
? item.topicSections
: createTopicSectionsFromImages(item.imageUrls)
if (sections.length <= 1) return
updateContent(item.id, {
topicSections: sections.filter((section) => section.id !== sectionId),
@@ -607,21 +700,38 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
if (viewMode === "editor" && selected) {
const isNewContent = !selected.sourceCarId
const canResetFromSource = Boolean(selected.sourceCarId && SOURCE_SNAPSHOTS[selected.sourceCarId])
const isTopicPage = selected.pageType === "专题页"
const currentTemplate = getTemplate(selected.pageType)
const selectedTemplateId = currentTemplate?.templateId ?? ""
const topicSections = (selected.topicSections && selected.topicSections.length > 0)
? selected.topicSections
: createTopicSectionsFromImages(selected.imageUrls)
const previewHeroImage = selected.imageUrls[0]
const highlights = selected.highlights
.split("\n")
.map((s) => s.trim())
.filter(Boolean)
const PreviewComp = currentTemplate?.PreviewComponent
const EditorComp = currentTemplate?.EditorComponent
const handleBackToList = () => {
setPendingResetPatch((prev) => {
if (!prev[selected.id]) return prev
const next = { ...prev }
delete next[selected.id]
return next
})
if (!INITIAL_CONTENT_IDS.has(selected.id)) {
setContents((prev) => {
const next = prev.filter((item) => item.id !== selected.id)
if (next.length > 0) {
setSelectedId(next[0].id)
}
return next
})
}
setViewMode("list")
}
return (
<div className="flex flex-col gap-6 p-8">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" onClick={() => setViewMode("list")}>
<Button variant="outline" size="sm" onClick={handleBackToList}>
<ArrowLeft className="mr-1 h-3.5 w-3.5" />
</Button>
<div>
@@ -642,31 +752,32 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
{isNewContent && canEdit ? (
<select
className="h-10 rounded-lg border border-input bg-background px-3 text-sm"
value={selected.pageType}
value={selectedTemplateId}
onChange={(e) => {
const nextPageType = e.target.value as MiniAppContent["pageType"]
const nextTemplateId = e.target.value
const nextTemplate = getAllTemplates().find((tmpl) => tmpl.templateId === nextTemplateId)
const nextPageType = nextTemplate?.pageTypes[0] ?? ""
const snapshot = SOURCE_SNAPSHOTS[selected.sourceCarId]
const sourceName = selected.sourceCarName || "车型"
const templateDefaults = nextTemplate?.defaultValues ?? {}
const templatePatch = nextTemplate?.onSourceCarSelected?.(snapshot, { ...selected, pageType: nextPageType }) ?? {}
updateContent(selected.id, {
pageType: nextPageType,
...(nextPageType === "专题页" ? {
...buildTopicPatch(sourceName, snapshot),
imageUrls: [],
ctaText: "预约体验",
} : {}),
...templateDefaults,
...templatePatch,
})
}}
>
<option value="车型页"></option>
<option value="活动页"></option>
<option value="专题页"></option>
<option value=""></option>
{getAllTemplates().map((tmpl) => (
<option key={tmpl.templateId} value={tmpl.templateId}>{tmpl.displayName}</option>
))}
</select>
) : (
<Input value={selected.pageType} disabled />
<Input value={getTemplate(selected.pageType)?.displayName ?? selected.pageType} disabled />
)}
</div>
@@ -681,16 +792,11 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
const option = SOURCE_CAR_OPTIONS.find((item) => item.id === nextId)
const snapshot = SOURCE_SNAPSHOTS[nextId]
const sourceName = option?.name ?? ""
const templatePatch = currentTemplate?.onSourceCarSelected?.(snapshot, { ...selected, sourceCarId: nextId, sourceCarName: sourceName }) ?? {}
updateContent(selected.id, {
sourceCarId: nextId,
sourceCarName: sourceName,
title: snapshot?.title ?? selected.title,
subtitle: snapshot?.subtitle ?? selected.subtitle,
highlights: snapshot?.highlights ?? selected.highlights,
description: snapshot?.description ?? selected.description,
ctaText: selected.pageType === "专题页" ? "预约体验" : (snapshot?.ctaText ?? selected.ctaText),
imageUrls: selected.pageType === "专题页" ? [] : (snapshot?.imageUrls ?? selected.imageUrls),
...buildTopicPatch(sourceName, snapshot),
...templatePatch,
})
if (snapshot) {
onAddAuditLog(`内容运营专员选择来源车型 ${option?.name ?? nextId} 并载入官网基线内容`)
@@ -707,140 +813,28 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
)}
</div>
{isTopicPage ? (
{selected.pageType ? (
<>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input
value={selected.topicNavTitle ?? `${selected.sourceCarName || "车型"}详情`}
disabled={!canEdit}
onChange={(e) => updateContent(selected.id, { topicNavTitle: e.target.value })}
{EditorComp && (
<EditorComp
content={selected}
canEdit={canEdit}
onUpdate={(patch) => updateContent(selected.id, patch)}
workflowConfig={workflowConfig}
newImageUrl={newImageUrl}
onNewImageUrlChange={setNewImageUrl}
onAddImage={() => addImage(selected)}
onRemoveImage={(idx) => removeImage(selected, idx)}
topicSections={topicSections}
onUpdateTopicSection={(sectionId, patch) => updateTopicSection(selected, sectionId, patch)}
onAddTopicSection={() => addTopicSection(selected)}
onRemoveTopicSection={(sectionId) => removeTopicSection(selected, sectionId)}
/>
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input
value={selected.topicHeroTitle ?? "先锋设计"}
disabled={!canEdit}
onChange={(e) => updateContent(selected.id, { topicHeroTitle: e.target.value })}
/>
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input value={selected.ctaText} disabled={!canEdit} onChange={(e) => updateContent(selected.id, { ctaText: e.target.value })} />
</div>
<div className="grid gap-3">
<div className="flex items-center justify-between">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">1-6</label>
<Button size="sm" variant="outline" disabled={!canEdit || topicSections.length >= 6} onClick={() => addTopicSection(selected)}>
</Button>
</div>
<div className="space-y-3">
{topicSections.map((section, index) => (
<div key={section.id} className="rounded-lg border p-3 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-muted-foreground"> {index + 1}</p>
<Button size="sm" variant="ghost" disabled={!canEdit || topicSections.length <= 1} onClick={() => removeTopicSection(selected, section.id)}>
</Button>
</div>
<Input
value={section.title}
disabled={!canEdit}
onChange={(e) => updateTopicSection(selected, section.id, { title: e.target.value })}
placeholder="模块标题"
/>
<Input
value={section.imageUrl}
disabled={!canEdit}
onChange={(e) => updateTopicSection(selected, section.id, { imageUrl: e.target.value })}
placeholder="模块图片 URL"
/>
</div>
))}
</div>
</div>
</>
) : (
<>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input value={selected.title} disabled={!canEdit} onChange={(e) => updateContent(selected.id, { title: e.target.value })} />
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input value={selected.subtitle} disabled={!canEdit} onChange={(e) => updateContent(selected.id, { subtitle: e.target.value })} />
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<textarea
className="min-h-24 rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:border-ring"
value={selected.highlights}
disabled={!canEdit}
onChange={(e) => updateContent(selected.id, { highlights: e.target.value })}
/>
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<textarea
className="min-h-28 rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:border-ring"
value={selected.description}
disabled={!canEdit}
onChange={(e) => updateContent(selected.id, { description: e.target.value })}
/>
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input value={selected.ctaText} disabled={!canEdit} onChange={(e) => updateContent(selected.id, { ctaText: e.target.value })} />
</div>
</>
)}
{!isTopicPage && workflowConfig.publishStrategy === "scheduled" && (
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input
type="datetime-local"
disabled={!canEdit}
value={selected.scheduledPublishAt}
onChange={(e) => updateContent(selected.id, { scheduledPublishAt: e.target.value })}
/>
</div>
)}
{!isTopicPage && (
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<div className="flex gap-2">
<Input value={newImageUrl} disabled={!canEdit} onChange={(e) => setNewImageUrl(e.target.value)} placeholder="粘贴图片 URL" />
<Button variant="outline" disabled={!canEdit} onClick={() => addImage(selected)}></Button>
</div>
<div className="grid grid-cols-2 gap-2">
{selected.imageUrls.map((url, idx) => (
<div key={`${url}-${idx}`} className="rounded-lg border p-2">
<img src={url} alt={`${selected.sourceCarName}-${idx}`} className="aspect-video w-full rounded object-cover" />
<div className="mt-2 flex justify-end">
<Button size="sm" variant="ghost" disabled={!canEdit} onClick={() => removeImage(selected, idx)}>
</Button>
</div>
</div>
))}
</div>
</div>
)}
{pendingResetPatch[selected.id] && (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
稿稿
稿
</div>
)}
@@ -862,6 +856,10 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
</Button>
</div>
</div>
</>
) : (
<div className="text-sm text-muted-foreground py-8 text-center"></div>
)}
</CardContent>
</Card>
@@ -869,150 +867,14 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
<CardHeader>
<CardTitle className="text-lg font-bold"></CardTitle>
<CardDescription>
{isTopicPage
? "专题页预览:按来源车型模板自动同步,可对模块图文做手工编辑。"
: "按钮上方展示预约试驾表单视觉区(仅演示,不提交;真实留资将直接进入企业 CRM。"}
{currentTemplate?.previewDescription ?? "页面预览"}
</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
{isTopicPage ? (
<div className="min-h-screen w-[360px] bg-white text-[#1a1a1a] flex flex-col relative overflow-hidden shadow-2xl rounded-[28px] border border-gray-200">
<div className="px-8 pt-4 pb-2 flex justify-between items-center text-sm font-semibold">
<span>9:41</span>
<div className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rotate-45 border border-current inline-block" />
<span className="h-2.5 w-2.5 rotate-45 border border-current inline-block" />
<div className="w-6 h-3 border border-current rounded-sm relative">
<div className="absolute inset-0.5 bg-current rounded-[2px] w-3" />
</div>
</div>
</div>
<header className="px-4 py-2 flex items-center justify-between sticky top-0 bg-white z-10">
<button className="p-2 rounded-full">
<ArrowLeft className="w-5 h-5" />
</button>
<h1 className="text-[17px] font-medium">{selected.topicNavTitle || `${selected.sourceCarName || "车型"}详情`}</h1>
<div className="flex items-center bg-gray-50 border border-gray-200 rounded-full px-3 py-1.5 gap-3">
<span className="text-xs text-gray-500">···</span>
<div className="w-px h-4 bg-gray-300" />
<span className="text-xs text-gray-500">x</span>
</div>
</header>
<main className="flex-1 overflow-y-auto pb-32">
<section className="pt-8 pb-6 text-center">
<h2 className="text-[32px] font-bold tracking-tight mb-2">{selected.topicHeroTitle || "先锋设计"}</h2>
<div className="w-12 h-[3px] bg-[#d51c2a] mx-auto" />
</section>
<div className="space-y-4 px-0 max-h-[520px] overflow-y-auto">
{topicSections.map((section) => (
<section key={section.id} className="bg-[#f8f9fb] rounded-t-[32px] overflow-hidden">
<div className="aspect-[16/9] relative overflow-hidden">
{section.imageUrl ? (
<img
src={section.imageUrl}
alt={section.title}
className="w-full h-full object-cover mix-blend-multiply"
/>
{selected.pageType && PreviewComp ? (
<PreviewComp content={selected} workflowConfig={workflowConfig} />
) : (
<div className="w-full h-full flex items-center justify-center text-xs text-gray-500 bg-gray-100"></div>
)}
</div>
<div className="px-6 py-2">
<h3 className="text-[22px] font-normal text-gray-700">{section.title}</h3>
</div>
</section>
))}
</div>
</main>
<footer className="absolute bottom-0 left-0 right-0 bg-white/80 backdrop-blur-md px-4 pt-2 pb-8 border-t border-gray-100">
<button className="w-full bg-[#1c1f23] text-white py-4 rounded-md text-[16px] font-medium">
{selected.ctaText || "预约体验"}
</button>
<div className="mt-6 w-32 h-1.5 bg-black/80 mx-auto rounded-full" />
</footer>
</div>
) : (
<div className="w-[360px] rounded-[28px] border border-gray-200 bg-white shadow-2xl overflow-hidden">
<div className="h-9 bg-black text-white px-4 flex items-center text-[10px] tracking-[0.18em] uppercase">Audi</div>
<div className="relative">
{previewHeroImage ? (
<img src={previewHeroImage} alt={selected.title} className="h-48 w-full object-cover" />
) : (
<div className="flex h-48 w-full items-center justify-center bg-gray-100 text-xs text-gray-500"></div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/65 to-transparent" />
<div className="absolute left-4 right-4 bottom-3 text-white">
<p className="text-lg font-bold tracking-tight">{selected.title}</p>
<p className="text-xs opacity-85">{selected.subtitle}</p>
</div>
</div>
<div className="p-4">
<div className="mb-3 grid grid-cols-3 gap-2">
{selected.imageUrls.slice(0, 3).map((url, idx) => (
<img key={`${url}-${idx}`} src={url} alt={`thumb-${idx}`} className="h-16 w-full rounded object-cover" />
))}
</div>
<div className="space-y-2">
{highlights.map((item) => (
<div key={item} className="inline-flex mr-2 mb-1 rounded-full border px-2 py-0.5 text-[10px] text-gray-600">
{item}
</div>
))}
</div>
<p className="mt-3 text-sm leading-6 text-gray-600">{selected.description}</p>
{workflowConfig.publishStrategy === "scheduled" && selected.scheduledPublishAt && (
<div className="mt-3 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700">
{selected.scheduledPublishAt.replace("T", " ")}
</div>
)}
<div className="mt-4 space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
<div className="grid gap-1">
<label className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold"></label>
<Input placeholder="请输入您的姓名" disabled />
</div>
<div className="grid gap-1">
<label className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold"></label>
<Input placeholder="请输入手机号" disabled />
</div>
<div className="grid gap-1">
<label className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold"></label>
<div className="flex gap-2">
<select className="h-8 flex-1 rounded-lg border border-input bg-white px-2 text-sm text-gray-500" disabled>
<option></option>
</select>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => alert("获取位置功能为原型占位提示")}
>
</Button>
</div>
</div>
<label className="flex items-start gap-2 text-[11px] leading-5 text-gray-600">
<input type="checkbox" className="mt-0.5" />
<span>
CRM
</span>
</label>
</div>
<Button className="mt-4 w-full bg-audi-black hover:bg-audi-dark-gray text-white" disabled>
{selected.ctaText || "立即预约试驾"}
</Button>
</div>
</div>
<div className="text-sm text-muted-foreground py-8"></div>
)}
</CardContent>
</Card>
@@ -1056,7 +918,7 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
<TableHeader>
<TableRow className="hover:bg-transparent border-b border-gray-100">
<TableHead className="font-bold text-audi-black"></TableHead>
<TableHead className="font-bold text-audi-black"></TableHead>
<TableHead className="font-bold text-audi-black"></TableHead>
<TableHead className="font-bold text-audi-black"></TableHead>
<TableHead className="font-bold text-audi-black"></TableHead>
<TableHead className="font-bold text-audi-black"></TableHead>
@@ -1071,7 +933,7 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
<p className="text-xs text-muted-foreground">{item.sourceCarId}</p>
</TableCell>
<TableCell>
<Badge variant="outline">{item.pageType}</Badge>
<Badge variant="outline">{getTemplate(item.pageType)?.displayName ?? item.pageType}</Badge>
</TableCell>
<TableCell className="font-medium">{item.title}</TableCell>
<TableCell>
@@ -1092,21 +954,21 @@ export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog,
</Table>
<div className="mt-4 flex items-center justify-between border-t pt-4">
<p className="text-xs text-muted-foreground"> 7 10 </p>
<p className="text-xs text-muted-foreground"> 7 {listTotalPages} </p>
<div className="flex items-center gap-1">
<Button size="sm" variant="outline" disabled></Button>
<Button size="sm" variant="outline" disabled={listPage <= 1} onClick={() => setListPage((prev) => Math.max(1, prev - 1))}></Button>
{Array.from({ length: listTotalPages }, (_, index) => (
<Button
key={`mini-page-${index + 1}`}
size="sm"
variant={index + 1 === listPage ? "default" : "outline"}
className={index + 1 === listPage ? "bg-audi-black hover:bg-audi-dark-gray text-white" : ""}
disabled
onClick={() => setListPage(index + 1)}
>
{index + 1}
</Button>
))}
<Button size="sm" variant="outline" disabled></Button>
<Button size="sm" variant="outline" disabled={listPage >= listTotalPages} onClick={() => setListPage((prev) => Math.min(listTotalPages, prev + 1))}></Button>
</div>
</div>
</CardContent>

View File

@@ -0,0 +1,95 @@
import { registerTemplate } from "./template-registry"
import type { PageTemplate } from "./types"
import { CarPagePreview } from "./templates/car-page/CarPagePreview"
import { CarPageEditor } from "./templates/car-page/CarPageEditor"
import { PureImagePagePreview } from "./templates/pure-image-page/PureImagePagePreview"
import { PureImagePageEditor } from "./templates/pure-image-page/PureImagePageEditor"
import { TopicPagePreview } from "./templates/topic-page/TopicPagePreview"
import { TopicPageEditor } from "./templates/topic-page/TopicPageEditor"
import { buildTopicPatch, createTopicSectionsFromImages } from "./templates/topic-page/helpers"
const carPageTemplate: PageTemplate = {
templateId: "car-page",
displayName: "车型营销模板",
pageTypes: ["车型页", "活动页"],
defaultValues: {
ctaText: "立即预约试驾",
imageUrls: [],
},
onSourceCarSelected(snapshot, current) {
return {
title: snapshot?.title ?? current.title,
subtitle: snapshot?.subtitle ?? current.subtitle,
highlights: snapshot?.highlights ?? current.highlights,
description: snapshot?.description ?? current.description,
ctaText: snapshot?.ctaText ?? current.ctaText,
imageUrls: snapshot?.imageUrls ?? current.imageUrls,
}
},
PreviewComponent: CarPagePreview,
EditorComponent: CarPageEditor,
previewDescription: "按钮上方展示预约试驾表单视觉区(仅演示,不提交;真实留资将直接进入企业 CRM。",
}
const topicPageTemplate: PageTemplate = {
templateId: "topic-page",
displayName: "专题图文模板",
pageTypes: ["专题页"],
defaultValues: {
ctaText: "预约体验",
imageUrls: [],
topicNavTitle: "车型详情",
topicHeroTitle: "先锋设计",
},
onSourceCarSelected(snapshot, current) {
return {
title: snapshot?.title ?? current.title,
subtitle: snapshot?.subtitle ?? current.subtitle,
highlights: snapshot?.highlights ?? current.highlights,
description: snapshot?.description ?? current.description,
ctaText: "预约体验",
imageUrls: [],
...buildTopicPatch(current.sourceCarName, snapshot),
}
},
PreviewComponent: TopicPagePreview,
EditorComponent: TopicPageEditor,
previewDescription: "专题页预览:按来源车型模板自动同步,可对模块图文做手工编辑。",
}
const pureImagePageTemplate: PageTemplate = {
templateId: "pure-image-page",
displayName: "纯图平铺模板",
pageTypes: ["纯图页"],
defaultValues: {
title: "E7X详情",
ctaText: "预约体验",
subtitle: "",
highlights: "",
description: "",
imageUrls: [
"/images/cars/ex7-1.jpg",
"/images/cars/ex7-2.jpg",
"/images/cars/ex7-3.jpg",
],
},
onSourceCarSelected(snapshot, current) {
return {
title: snapshot?.title ?? current.title,
subtitle: snapshot?.subtitle ?? current.subtitle,
highlights: snapshot?.highlights ?? current.highlights,
description: snapshot?.description ?? current.description,
ctaText: snapshot?.ctaText ?? current.ctaText,
imageUrls: snapshot?.imageUrls ?? current.imageUrls,
}
},
PreviewComponent: PureImagePagePreview,
EditorComponent: PureImagePageEditor,
previewDescription: "页面体仅保留纯图平铺内容,无文案、无圆角、无间距。",
}
registerTemplate(carPageTemplate)
registerTemplate(topicPageTemplate)
registerTemplate(pureImagePageTemplate)
export { createTopicSectionsFromImages, buildTopicPatch }

View File

@@ -0,0 +1,23 @@
import type { PageTemplate } from "./types"
const templates: PageTemplate[] = []
const pageTypeMap: Map<string, PageTemplate> = new Map()
export function registerTemplate(template: PageTemplate) {
templates.push(template)
for (const pt of template.pageTypes) {
pageTypeMap.set(pt, template)
}
}
export function getTemplate(pageType: string): PageTemplate | undefined {
return pageTypeMap.get(pageType)
}
export function getAllPageTypes(): string[] {
return Array.from(pageTypeMap.keys())
}
export function getAllTemplates(): PageTemplate[] {
return [...templates]
}

View File

@@ -0,0 +1,86 @@
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import type { EditorProps } from "../../types"
export function CarPageEditor({
content,
canEdit,
onUpdate,
workflowConfig,
newImageUrl,
onNewImageUrlChange,
onAddImage,
onRemoveImage,
}: EditorProps) {
return (
<>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input value={content.title} disabled={!canEdit} onChange={(e) => onUpdate({ title: e.target.value })} />
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input value={content.subtitle} disabled={!canEdit} onChange={(e) => onUpdate({ subtitle: e.target.value })} />
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<textarea
className="min-h-24 rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:border-ring"
value={content.highlights}
disabled={!canEdit}
onChange={(e) => onUpdate({ highlights: e.target.value })}
/>
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<textarea
className="min-h-28 rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:border-ring"
value={content.description}
disabled={!canEdit}
onChange={(e) => onUpdate({ description: e.target.value })}
/>
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input value={content.ctaText} disabled={!canEdit} onChange={(e) => onUpdate({ ctaText: e.target.value })} />
</div>
{workflowConfig.publishStrategy === "scheduled" && (
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input
type="datetime-local"
disabled={!canEdit}
value={content.scheduledPublishAt}
onChange={(e) => onUpdate({ scheduledPublishAt: e.target.value })}
/>
</div>
)}
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<div className="flex gap-2">
<Input value={newImageUrl ?? ""} disabled={!canEdit} onChange={(e) => onNewImageUrlChange?.(e.target.value)} placeholder="粘贴图片 URL" />
<Button variant="outline" disabled={!canEdit} onClick={() => onAddImage?.()}></Button>
</div>
<div className="grid grid-cols-2 gap-2">
{content.imageUrls.map((url, idx) => (
<div key={`${url}-${idx}`} className="rounded-lg border p-2">
<img src={url} alt={`${content.sourceCarName}-${idx}`} className="aspect-video w-full rounded object-cover" />
<div className="mt-2 flex items-center justify-between gap-2">
<span className="truncate text-[11px] text-muted-foreground">{url}</span>
<Button size="sm" variant="ghost" disabled={!canEdit} onClick={() => onRemoveImage?.(idx)}>
</Button>
</div>
</div>
))}
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,124 @@
import { ChevronLeft, MoreHorizontal, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import type { PreviewProps } from "../../types"
export function CarPagePreview({ content, workflowConfig }: PreviewProps) {
const previewHeroImage = content.imageUrls[0]
const highlights = content.highlights
.split("\n")
.map((s) => s.trim())
.filter(Boolean)
return (
<div className="flex h-[780px] w-[360px] flex-col overflow-hidden rounded-[28px] border border-gray-200 bg-white shadow-2xl">
<div className="flex h-11 items-end justify-between bg-black px-8 pb-2 text-[15px] font-semibold tracking-tight text-white">
<span>9:41</span>
<div className="flex items-center gap-1.5 pb-1">
<svg width="17" height="11" viewBox="0 0 17 11" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M0 8.35L2.83 8.35L2.83 10.5H0V8.35ZM4.25 6.2H7.08V10.5H4.25V6.2ZM8.5 4.05H11.33V10.5H8.5V4.05ZM12.75 1.9H15.58V10.5H12.75V1.9Z" fill="white" />
</svg>
<svg width="16" height="12" viewBox="0 0 16 12" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M8 12L16 3.10931C11.5768 -0.493103 4.42323 -0.493103 0 3.10931L8 12Z" fill="white" />
</svg>
<div className="relative h-[11.33px] w-[24.33px] rounded-[3px] border border-white/35 p-[1px]">
<div className="h-full w-[18px] rounded-[1px] bg-white" />
<div className="absolute -right-[3px] top-[3px] h-[5px] w-[2px] rounded-r-[1px] bg-white/35" />
</div>
</div>
</div>
<header className="flex h-14 items-center justify-between bg-black/85 px-4 backdrop-blur-md">
<button className="p-2 text-white/90" type="button" aria-label="返回">
<ChevronLeft className="h-6 w-6" />
</button>
<div className="h-7" />
<div className="flex h-8 items-center rounded-full border border-white/10 bg-black/40 px-1">
<button className="border-r border-white/10 px-2 text-white/90" type="button" aria-label="更多">
<MoreHorizontal className="h-4 w-4" />
</button>
<button className="px-2 text-white/90" type="button" aria-label="关闭">
<X className="h-4 w-4" />
</button>
</div>
</header>
<main className="min-h-0 flex-1 overflow-y-auto bg-white [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div className="relative">
{previewHeroImage ? (
<img src={previewHeroImage} alt={content.title} className="h-48 w-full object-cover" />
) : (
<div className="flex h-48 w-full items-center justify-center bg-gray-100 text-xs text-gray-500"></div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/65 to-transparent" />
<div className="absolute left-4 right-4 bottom-3 text-white">
<p className="text-lg font-bold tracking-tight">{content.title}</p>
<p className="text-xs opacity-85">{content.subtitle}</p>
</div>
</div>
<div className="p-4">
<div className="mb-3 grid grid-cols-3 gap-2">
{content.imageUrls.slice(0, 3).map((url, idx) => (
<img key={`${url}-${idx}`} src={url} alt={`thumb-${idx}`} className="h-16 w-full rounded object-cover" />
))}
</div>
<div className="space-y-2">
{highlights.map((item) => (
<div key={item} className="inline-flex mr-2 mb-1 rounded-full border px-2 py-0.5 text-[10px] text-gray-600">
{item}
</div>
))}
</div>
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-gray-600">{content.description}</p>
{workflowConfig.publishStrategy === "scheduled" && content.scheduledPublishAt && (
<div className="mt-3 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700">
{content.scheduledPublishAt.replace("T", " ")}
</div>
)}
<div className="mt-4 space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
<div className="grid gap-1">
<label className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold"></label>
<Input placeholder="请输入您的姓名" disabled />
</div>
<div className="grid gap-1">
<label className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold"></label>
<Input placeholder="请输入手机号" disabled />
</div>
<div className="grid gap-1">
<label className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold"></label>
<div className="flex gap-2">
<select className="h-8 flex-1 rounded-lg border border-input bg-white px-2 text-sm text-gray-500" disabled>
<option></option>
</select>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => alert("获取位置功能为原型占位提示")}
>
</Button>
</div>
</div>
<label className="flex items-start gap-2 text-[11px] leading-5 text-gray-600">
<input type="checkbox" className="mt-0.5" />
<span>
CRM
</span>
</label>
</div>
<Button className="mt-4 w-full bg-audi-black hover:bg-audi-dark-gray text-white" disabled>
{content.ctaText || "立即预约试驾"}
</Button>
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1,55 @@
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import type { EditorProps } from "../../types"
export function PureImagePageEditor({
content,
canEdit,
onUpdate,
newImageUrl,
onNewImageUrlChange,
onAddImage,
onRemoveImage,
}: EditorProps) {
return (
<>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input value={content.title} disabled={!canEdit} onChange={(e) => onUpdate({ title: e.target.value })} />
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input value={content.ctaText} disabled={!canEdit} onChange={(e) => onUpdate({ ctaText: e.target.value })} />
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<div className="flex gap-2">
<Input
value={newImageUrl ?? ""}
disabled={!canEdit}
onChange={(e) => onNewImageUrlChange?.(e.target.value)}
placeholder="粘贴图片 URL"
/>
<Button variant="outline" disabled={!canEdit} onClick={() => onAddImage?.()}>
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
{content.imageUrls.map((url, idx) => (
<div key={`${url}-${idx}`} className="rounded-lg border p-2">
<img src={url} alt={`${content.title}-${idx}`} className="aspect-video w-full rounded object-cover" />
<div className="mt-2 flex items-center justify-between gap-2">
<span className="truncate text-[11px] text-muted-foreground">{url}</span>
<Button size="sm" variant="ghost" disabled={!canEdit} onClick={() => onRemoveImage?.(idx)}>
</Button>
</div>
</div>
))}
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,70 @@
import { ChevronLeft, MoreHorizontal, X } from "lucide-react"
import type { PreviewProps } from "../../types"
export function PureImagePagePreview({ content }: PreviewProps) {
const imageUrls = content.imageUrls.filter(Boolean)
return (
<div className="flex h-[780px] w-[360px] flex-col overflow-hidden rounded-[28px] border border-gray-200 bg-black text-white shadow-2xl">
<div className="flex h-11 items-end justify-between px-8 pb-2 text-[15px] font-semibold tracking-tight">
<span>9:41</span>
<div className="flex items-center gap-1.5 pb-1">
<svg width="17" height="11" viewBox="0 0 17 11" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M0 8.35L2.83 8.35L2.83 10.5H0V8.35ZM4.25 6.2H7.08V10.5H4.25V6.2ZM8.5 4.05H11.33V10.5H8.5V4.05ZM12.75 1.9H15.58V10.5H12.75V1.9Z" fill="white" />
</svg>
<svg width="16" height="12" viewBox="0 0 16 12" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M8 12L16 3.10931C11.5768 -0.493103 4.42323 -0.493103 0 3.10931L8 12Z" fill="white" />
</svg>
<div className="relative h-[11.33px] w-[24.33px] rounded-[3px] border border-white/35 p-[1px]">
<div className="h-full w-[18px] rounded-[1px] bg-white" />
<div className="absolute -right-[3px] top-[3px] h-[5px] w-[2px] rounded-r-[1px] bg-white/35" />
</div>
</div>
</div>
<header className="flex h-14 items-center justify-between bg-black/85 px-4 backdrop-blur-md">
<button className="p-2 text-white/90" type="button" aria-label="返回">
<ChevronLeft className="h-6 w-6" />
</button>
<h1 className="text-lg font-medium tracking-wide">{content.title || "E7X详情"}</h1>
<div className="flex h-8 items-center rounded-full border border-white/10 bg-black/40 px-1">
<button className="border-r border-white/10 px-2 text-white/90" type="button" aria-label="更多">
<MoreHorizontal className="h-4 w-4" />
</button>
<button className="px-2 text-white/90" type="button" aria-label="关闭">
<X className="h-4 w-4" />
</button>
</div>
</header>
<main className="min-h-0 flex-1 overflow-y-auto bg-black [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{imageUrls.length > 0 ? (
imageUrls.map((url, idx) => (
<img
key={`${url}-${idx}`}
src={url}
alt={`${content.title || "E7X详情"}-${idx + 1}`}
className="block w-full"
/>
))
) : (
<div className="flex min-h-[480px] items-center justify-center bg-neutral-900 text-sm text-white/60">
</div>
)}
</main>
<footer className="shrink-0 bg-black px-5 py-3.5">
<button
type="button"
className="w-full border border-white bg-transparent py-2.5 text-sm font-medium text-white"
>
{content.ctaText || "预约体验"}
</button>
<div className="mt-3 flex justify-center">
<div className="h-1 w-24 rounded-full bg-white/20" />
</div>
</footer>
</div>
)
}

View File

@@ -0,0 +1,79 @@
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import type { EditorProps } from "../../types"
import { createTopicSectionsFromImages } from "./helpers"
export function TopicPageEditor({
content,
canEdit,
onUpdate,
topicSections: topicSectionsProp,
onUpdateTopicSection,
onAddTopicSection,
onRemoveTopicSection,
}: EditorProps) {
const topicSections = topicSectionsProp
?? ((content.topicSections && content.topicSections.length > 0)
? content.topicSections
: createTopicSectionsFromImages(content.imageUrls))
return (
<>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input
value={content.topicNavTitle ?? `${content.sourceCarName || "车型"}详情`}
disabled={!canEdit}
onChange={(e) => onUpdate({ topicNavTitle: e.target.value })}
/>
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input
value={content.topicHeroTitle ?? "先锋设计"}
disabled={!canEdit}
onChange={(e) => onUpdate({ topicHeroTitle: e.target.value })}
/>
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input value={content.ctaText} disabled={!canEdit} onChange={(e) => onUpdate({ ctaText: e.target.value })} />
</div>
<div className="grid gap-3">
<div className="flex items-center justify-between">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Button size="sm" variant="outline" disabled={!canEdit || topicSections.length >= 6} onClick={() => onAddTopicSection?.()}>
</Button>
</div>
<div className="space-y-3">
{topicSections.map((section, index) => (
<div key={section.id} className="rounded-lg border p-3 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-muted-foreground"> {index + 1}</p>
<Button size="sm" variant="ghost" disabled={!canEdit || topicSections.length <= 1} onClick={() => onRemoveTopicSection?.(section.id)}>
</Button>
</div>
<Input
value={section.title}
disabled={!canEdit}
onChange={(e) => onUpdateTopicSection?.(section.id, { title: e.target.value })}
placeholder="模块标题"
/>
<Input
value={section.imageUrl}
disabled={!canEdit}
onChange={(e) => onUpdateTopicSection?.(section.id, { imageUrl: e.target.value })}
placeholder="模块图片 URL"
/>
</div>
))}
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,79 @@
import { ChevronLeft, MoreHorizontal, X } from "lucide-react"
import type { PreviewProps } from "../../types"
import { createTopicSectionsFromImages } from "./helpers"
export function TopicPagePreview({ content }: PreviewProps) {
const topicSections = (content.topicSections && content.topicSections.length > 0)
? content.topicSections
: createTopicSectionsFromImages(content.imageUrls)
return (
<div className="relative flex h-[780px] w-[360px] flex-col overflow-hidden rounded-[28px] border border-gray-200 bg-white text-[#1a1a1a] shadow-2xl">
<div className="flex h-11 items-end justify-between bg-white px-8 pb-2 text-[15px] font-semibold tracking-tight text-[#1a1a1a]">
<span>9:41</span>
<div className="flex items-center gap-1.5 pb-1">
<svg width="17" height="11" viewBox="0 0 17 11" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M0 8.35L2.83 8.35L2.83 10.5H0V8.35ZM4.25 6.2H7.08V10.5H4.25V6.2ZM8.5 4.05H11.33V10.5H8.5V4.05ZM12.75 1.9H15.58V10.5H12.75V1.9Z" fill="#1a1a1a" />
</svg>
<svg width="16" height="12" viewBox="0 0 16 12" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M8 12L16 3.10931C11.5768 -0.493103 4.42323 -0.493103 0 3.10931L8 12Z" fill="#1a1a1a" />
</svg>
<div className="relative h-[11.33px] w-[24.33px] rounded-[3px] border border-black/35 p-[1px]">
<div className="h-full w-[18px] rounded-[1px] bg-[#1a1a1a]" />
<div className="absolute -right-[3px] top-[3px] h-[5px] w-[2px] rounded-r-[1px] bg-black/35" />
</div>
</div>
</div>
<header className="flex h-14 items-center justify-between bg-white px-4">
<button className="p-2 text-[#1a1a1a]" type="button" aria-label="返回">
<ChevronLeft className="h-6 w-6" />
</button>
<h1 className="text-lg font-medium tracking-wide">{content.topicNavTitle || `${content.sourceCarName || "车型"}详情`}</h1>
<div className="flex h-8 items-center rounded-full border border-gray-200 bg-gray-50 px-1">
<button className="border-r border-gray-200 px-2 text-gray-500" type="button" aria-label="更多">
<MoreHorizontal className="h-4 w-4" />
</button>
<button className="px-2 text-gray-500" type="button" aria-label="关闭">
<X className="h-4 w-4" />
</button>
</div>
</header>
<main className="min-h-0 flex-1 overflow-y-auto pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<section className="pt-4 pb-3 text-center">
<h2 className="mb-1 text-[28px] font-bold tracking-tight">{content.topicHeroTitle || "先锋设计"}</h2>
<div className="w-12 h-[3px] bg-[#d51c2a] mx-auto" />
</section>
<div className="space-y-2 px-0">
{topicSections.map((section) => (
<section key={section.id} className="bg-[#f8f9fb] rounded-t-[32px] overflow-hidden">
<div className="aspect-[16/9] relative overflow-hidden">
{section.imageUrl ? (
<img
src={section.imageUrl}
alt={section.title}
className="w-full h-full object-cover mix-blend-multiply"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-xs text-gray-500 bg-gray-100"></div>
)}
</div>
<div className="px-5 py-1.5">
<h3 className="text-[19px] font-normal text-gray-700">{section.title}</h3>
</div>
</section>
))}
</div>
</main>
<footer className="shrink-0 border-t border-gray-100 bg-white/80 px-4 pt-2 pb-4 backdrop-blur-md">
<button className="w-full rounded-md bg-[#1c1f23] py-3 text-[15px] font-medium text-white">
{content.ctaText || "预约体验"}
</button>
<div className="mx-auto mt-3 h-1 w-24 rounded-full bg-black/80" />
</footer>
</div>
)
}

View File

@@ -0,0 +1,24 @@
import type { MiniAppContent, SourceSnapshot, TopicSection } from "../../types"
export function createTopicSectionsFromImages(images: string[]): TopicSection[] {
const picks = images.slice(0, 2)
const fallback = ["/images/cars/a7-1.jpg", "/images/cars/a7-2.jpg"]
const selected = picks.length > 0 ? picks : fallback
return selected.map((imageUrl, index) => ({
id: `topic-section-${index + 1}`,
title: index === 0 ? "RS竞速套件" : "宽体低趴 超长轴距",
imageUrl,
specLeftLabel: "长",
specRightLabel: "宽",
}))
}
export function buildTopicPatch(sourceCarName: string, snapshot?: SourceSnapshot): Pick<MiniAppContent, "topicNavTitle" | "topicHeroTitle" | "topicSections"> {
const hero = snapshot?.topicHeroTitle ?? snapshot?.subtitle ?? "先锋设计"
return {
topicNavTitle: snapshot?.topicNavTitle ?? `${sourceCarName || "车型"}详情`,
topicHeroTitle: hero,
topicSections: snapshot?.topicSections ?? createTopicSectionsFromImages(snapshot?.imageUrls ?? []),
}
}

View File

@@ -0,0 +1,85 @@
import type * as React from "react"
import type { WorkflowConfig } from "@/App"
export type WorkflowStatus = "draft" | "prepublished" | "published"
export type TopicSection = {
id: string
title: string
imageUrl: string
specLeftLabel: string
specRightLabel: string
}
export type SourceSnapshot = {
title: string
subtitle: string
highlights: string
description: string
ctaText: string
imageUrls: string[]
topicNavTitle?: string
topicHeroTitle?: string
topicSections?: TopicSection[]
}
export type MiniAppContent = {
id: string
sourceCarId: string
sourceCarName: string
pageType: string
title: string
subtitle: string
highlights: string
description: string
ctaText: string
scheduledPublishAt: string
imageUrls: string[]
topicNavTitle?: string
topicHeroTitle?: string
topicSections?: TopicSection[]
workflowStatus: WorkflowStatus
updatedBy: string
updatedAt: string
}
export type PreviewProps = {
content: MiniAppContent
workflowConfig: WorkflowConfig
}
export type EditorProps = {
content: MiniAppContent
canEdit: boolean
onUpdate: (patch: Partial<MiniAppContent>) => void
workflowConfig: WorkflowConfig
/** For car-page image management */
newImageUrl?: string
onNewImageUrlChange?: (url: string) => void
onAddImage?: () => void
onRemoveImage?: (idx: number) => void
/** For topic-page section management */
onUpdateTopicSection?: (sectionId: string, patch: Partial<TopicSection>) => void
onAddTopicSection?: () => void
onRemoveTopicSection?: (sectionId: string) => void
topicSections?: TopicSection[]
}
export type PageTemplate = {
/** Unique template identifier (kebab-case) */
templateId: string
/** Human-friendly template name for UI display */
displayName: string
/** Page type labels that map to this template */
pageTypes: string[]
/** Default values when creating content with this template */
defaultValues: Partial<MiniAppContent>
/** Called when a source car is selected */
onSourceCarSelected?: (snapshot: SourceSnapshot | undefined, current: MiniAppContent) => Partial<MiniAppContent>
/** Preview component for the phone mockup */
PreviewComponent: React.ComponentType<PreviewProps>
/** Editor fields component */
EditorComponent: React.ComponentType<EditorProps>
/** Description shown below the preview card */
previewDescription?: string
}

View File

@@ -2,6 +2,7 @@ import * as React from "react"
import {
Car,
NotebookPen,
LayoutTemplate,
UserCog,
Settings,
LogOut,
@@ -34,6 +35,11 @@ const navItems = [
icon: NotebookPen,
id: "mini-content",
},
{
title: "小程序页面模板",
icon: LayoutTemplate,
id: "templates",
},
{
title: "用户与权限",
icon: UserCog,

View File

@@ -0,0 +1,158 @@
import * as React from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { X } from "lucide-react"
import { getAllTemplates } from "@/components/content-library/template-registry"
import "@/components/content-library/register-templates"
import type { MiniAppContent } from "@/components/content-library/types"
import type { WorkflowConfig } from "@/App"
const defaultWorkflowConfig: WorkflowConfig = {
enablePrePublish: true,
allowRecall: true,
publishStrategy: "manual",
defaultPublishTime: "10:00",
syncFrequency: "weekly",
syncExecutionTime: "02:00",
retryCount: 2,
manualConfirmSync: true,
}
function makeSampleContent(overrides: Partial<MiniAppContent> = {}): MiniAppContent {
return {
id: "sample",
sourceCarId: "AUDI_A7SPB",
sourceCarName: "Audi A7 Sportback",
pageType: "车型页",
title: "A7 Sportback",
subtitle: "优雅轿跑",
highlights: "340 马力 · quattro 四驱 · 5.3s 破百",
description: "以优雅之姿,释放澎湃动力。",
ctaText: "立即预约试驾",
scheduledPublishAt: "",
imageUrls: [
"/images/cars/a7-1.jpg",
"/images/cars/a7-2.jpg",
"/images/cars/a7-3.jpg",
],
workflowStatus: "draft",
updatedBy: "系统",
updatedAt: "2026-04-16",
...overrides,
}
}
function getTemplateSampleContent(templateId: string, defaults: Partial<MiniAppContent>): MiniAppContent {
if (templateId === "car-page") {
return makeSampleContent({
sourceCarId: "a3",
sourceCarName: "Audi A3 Sportback",
pageType: "车型页",
title: "Audi A3 Sportback",
subtitle: "进取,不负期待",
highlights: "数字座舱\n城市通勤\n智能互联",
description: "适合城市用户的豪华紧凑车型,兼顾效率与智能体验。",
ctaText: "立即预约试驾",
imageUrls: [
"/images/cars/a3-1.jpg",
"/images/cars/a3-2.jpg",
],
})
}
return makeSampleContent({
pageType: defaults.pageType as string,
title: (defaults.title as string) ?? "A7 Sportback",
ctaText: (defaults.ctaText as string) ?? "了解更多",
imageUrls: (defaults.imageUrls as string[]) ?? [
"/images/cars/a7-1.jpg",
"/images/cars/a7-2.jpg",
"/images/cars/a7-3.jpg",
],
topicNavTitle: defaults.topicNavTitle as string | undefined,
topicHeroTitle: defaults.topicHeroTitle as string | undefined,
topicSections: defaults.topicSections as MiniAppContent["topicSections"],
})
}
export function TemplateManagement() {
const templates = getAllTemplates()
const [expandedTemplateId, setExpandedTemplateId] = React.useState<string | null>(null)
const expandedTemplate = expandedTemplateId
? templates.find((t) => t.templateId === expandedTemplateId)
: null
return (
<div className="p-6 space-y-6">
<div>
<h2 className="text-lg font-bold"></h2>
<p className="text-sm text-muted-foreground mt-1">
</p>
</div>
{/* Preview cards */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{templates.map((t) => {
const Preview = t.PreviewComponent
const sampleContent = getTemplateSampleContent(t.templateId, {
...t.defaultValues,
pageType: t.pageTypes[0],
})
return (
<Card key={t.templateId} className="overflow-hidden">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t.displayName}</CardTitle>
{t.previewDescription && (
<p className="text-xs text-muted-foreground mt-1">{t.previewDescription}</p>
)}
</CardHeader>
<CardContent className="flex justify-center">
<button
type="button"
className="cursor-pointer rounded-xl border border-gray-200 overflow-hidden hover:shadow-lg transition-shadow w-[200px] h-[350px]"
onClick={() => setExpandedTemplateId(t.templateId)}
title="点击放大预览"
>
<div className="origin-top-left scale-[0.555] w-[360px] pointer-events-none">
<Preview content={sampleContent} workflowConfig={defaultWorkflowConfig} />
</div>
</button>
</CardContent>
</Card>
)
})}
</div>
{/* Fullscreen modal */}
{expandedTemplate && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
onClick={() => setExpandedTemplateId(null)}
>
<div
className="relative bg-white rounded-2xl shadow-2xl p-6 max-h-[90vh] overflow-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<span className="font-semibold">{expandedTemplate.displayName}</span>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setExpandedTemplateId(null)}>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex justify-center">
<expandedTemplate.PreviewComponent
content={getTemplateSampleContent(expandedTemplate.templateId, {
...expandedTemplate.defaultValues,
pageType: expandedTemplate.pageTypes[0],
})}
workflowConfig={defaultWorkflowConfig}
/>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,9 @@
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"

8
audi-red-note-mini-app3/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example

View File

@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/8100d767-eabc-4053-8910-92a8a5c2aba8
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Google AI Studio App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,6 @@
{
"name": "E7X Details Page",
"description": "High-fidelity mobile landing page clone for the E7X car model.",
"requestFramePermissions": [],
"majorCapabilities": []
}

View File

@@ -0,0 +1,34 @@
{
"name": "react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist",
"lint": "tsc --noEmit"
},
"dependencies": {
"@google/genai": "^1.29.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"lucide-react": "^0.546.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"vite": "^6.2.0",
"express": "^4.21.2",
"dotenv": "^17.2.3",
"motion": "^12.23.24"
},
"devDependencies": {
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.0",
"@types/express": "^4.17.21"
}
}

View File

@@ -0,0 +1,146 @@
import { ChevronLeft, MoreHorizontal, X } from "lucide-react";
import { motion } from "motion/react";
export default function App() {
return (
<div className="flex flex-col min-h-screen bg-black font-sans scroll-smooth">
{/* Status Bar Mockup */}
<div className="fixed top-0 left-0 right-0 h-11 flex justify-between items-end px-8 z-50 pointer-events-none">
<span className="text-[15px] font-semibold text-white tracking-tight">9:41</span>
<div className="flex items-center gap-1.5 pb-1">
<svg width="17" height="11" viewBox="0 0 17 11" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 8.35L2.83 8.35L2.83 10.5H0V8.35ZM4.25 6.2H7.08V10.5H4.25V6.2ZM8.5 4.05H11.33V10.5H8.5V4.05ZM12.75 1.9H15.58V10.5H12.75V1.9Z" fill="white"/>
</svg>
<svg width="16" height="12" viewBox="0 0 16 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 12L16 3.10931C11.5768 -0.493103 4.42323 -0.493103 0 3.10931L8 12Z" fill="white"/>
</svg>
<div className="w-[24.33px] h-[11.33px] border border-white/35 rounded-[3px] p-[1px] relative">
<div className="bg-white h-full w-[18px] rounded-[1px]" />
<div className="absolute top-[3px] -right-[3px] w-[2px] h-[5px] bg-white/35 rounded-r-[1px]" />
</div>
</div>
</div>
{/* Main Header */}
<header className="fixed top-0 left-0 right-0 h-24 pt-11 flex items-center justify-between px-4 bg-black/60 backdrop-blur-md z-40">
<button className="p-2 transition-opacity active:opacity-50">
<ChevronLeft className="w-6 h-6" />
</button>
<h1 className="text-lg font-medium tracking-wide">E7X详情</h1>
<div className="flex items-center bg-black/40 border border-white/10 rounded-full h-8 px-1">
<button className="px-2 border-r border-white/10">
<MoreHorizontal className="w-4 h-4" />
</button>
<button className="px-2">
<X className="w-4 h-4" />
</button>
</div>
</header>
{/* Content */}
<main className="flex-1 mt-24 mb-24 overflow-y-auto">
{/* Section 1: Side view & Metrics */}
<section className="relative overflow-hidden">
<motion.img
initial={{ opacity: 0, scale: 1.05 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.8 }}
src="https://picsum.photos/seed/e7x-side/1200/900"
alt="E7X Side Profile"
className="w-full object-cover aspect-[4/3]"
referrerPolicy="no-referrer"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black via-transparent to-transparent opacity-60" />
{/* Overlaid Metrics */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4, duration: 0.6 }}
className="absolute bottom-6 left-0 right-0 grid grid-cols-4 px-4 text-center"
>
{[
{ val: "5,049", unit: "mm", label: "车长" },
{ val: "1,997", unit: "mm", label: "车宽" },
{ val: "1,710", unit: "mm", label: "车高" },
{ val: "3,060", unit: "mm", label: "轴距" },
].map((metric, i) => (
<div key={i} className="flex flex-col gap-0.5">
<div className="flex items-baseline justify-center">
<span className="text-lg font-bold leading-none">{metric.val}</span>
<span className="text-[10px] ml-0.5 leading-none font-medium opacity-80">{metric.unit}</span>
</div>
<span className="text-[10px] text-gray-400 font-normal">{metric.label}</span>
</div>
))}
</motion.div>
</section>
{/* Section 2: Front view */}
<section className="bg-black">
<motion.img
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.8 }}
src="https://picsum.photos/seed/e7x-front/1200/900"
alt="E7X Front Design"
className="w-full object-cover aspect-[4/3]"
referrerPolicy="no-referrer"
/>
</section>
{/* Section 3: Text content & Detailed view */}
<motion.section
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-100px" }}
transition={{ duration: 0.8 }}
className="relative pt-16 pb-24 bg-black flex flex-col items-center"
>
<h2 className="text-3xl font-medium tracking-tight mb-8"></h2>
<div className="flex flex-col items-center gap-3 text-gray-300 text-[15px] font-light text-center">
<p className="tracking-[0.15em]"></p>
<p className="tracking-[0.15em]"></p>
</div>
<img
src="https://picsum.photos/seed/e7x-detail/1200/600"
alt="Detail"
className="w-full mt-16 object-cover"
referrerPolicy="no-referrer"
/>
</motion.section>
{/* Section 4: Extra Detail Image */}
<section className="-mt-12 bg-black">
<motion.img
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.8 }}
src="https://picsum.photos/seed/e7x-rear/1200/900"
alt="E7X Rear"
className="w-full object-cover"
referrerPolicy="no-referrer"
/>
</section>
</main>
{/* Fixed Footer */}
<footer className="fixed bottom-0 left-0 right-0 px-6 py-6 bg-black z-50">
<motion.button
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.98 }}
className="w-full py-4 bg-transparent border border-white text-white text-lg font-medium transition-colors hover:bg-white/5 active:bg-white/10"
>
</motion.button>
{/* iPhone indicator bar mockup */}
<div className="flex justify-center mt-6">
<div className="w-32 h-1.5 bg-white/20 rounded-full" />
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,12 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
}
@layer base {
body {
@apply bg-black text-white antialiased overflow-x-hidden;
}
}

View File

@@ -0,0 +1,10 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

View File

@@ -0,0 +1,24 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig, loadEnv} from 'vite';
export default defineConfig(({mode}) => {
const env = loadEnv(mode, '.', '');
return {
plugins: [react(), tailwindcss()],
define: {
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
},
};
});