Initial commit

This commit is contained in:
2026-04-13 20:28:09 +08:00
commit e2a2eb4666
62 changed files with 17589 additions and 0 deletions

59
Plan & Risks.md Normal file
View File

@@ -0,0 +1,59 @@
收到。作为《需求规格说明书SRS》的自然延伸我为您输出基于该项目的**项目排期表(基于 WBS 的 Gantt 图以及风险应对登记册Risk Register**。
这两份交付物将直接帮助您在编写投标方案时展示专业的项目管理能力,并对 RFP 中明确指出的风险点Data Loss, Performance Degradation, Integration Failure, Contingency Planning进行完美闭环。
-----
### 一、 项目排期表与 WBS 分解 (Project Schedule & Gantt Chart)
基于前期评估的 170-190 人天工作量,建议将 2026 年首期建设阶段规划为 \*\*12 周(约 3 个月)\*\*的交付周期。为了敏捷响应变更,开发阶段可拆分为 2-3 个 Sprint 迭代。
以下是使用 Mermaid 生成的里程碑甘特图(以 2026 年 5 月启动为例,具体日期可依实际调整):
```mermaid
gantt
title 奥迪小红书小程序门户 - 2026 项目建设排期表 (12周)
dateFormat YYYY-MM-DD
axisFormat %m-%d
section 1. 需求与设计 (2周)
需求调研与业务流程确认 :a1, 2026-05-01, 7d
UI/UX设计与小红书审核 :a2, after a1, 7d
系统架构设计与私有云评审 :a3, 2026-05-08, 7d
section 2. 环境部署 (2周)
私有云资源申领与网络规划 :b1, after a3, 5d
第三方平台(CMS)私有化部署 :b2, after b1, 7d
安全基线加固与证书配置 :b3, after b2, 3d
section 3. 开发与集成 (4周)
Sprint 1: 小程序前端页面开发 :c1, after a2, 14d
Sprint 1: 留资表单与加密落库 :c2, after b2, 14d
Sprint 2: JV官网车型自动同步集成 :c3, after c1, 14d
Sprint 2: 小红书开放平台API对接 :c4, after c2, 14d
section 4. 测试与安全 (2周)
SIT (系统集成与压力测试) :d1, after c4, 7d
漏洞扫描与渗透测试(Pen-Test) :d2, after d1, 5d
UAT (用户验收测试) :d3, after d2, 5d
section 5. 上线与移交 (2周)
生产环境割接与试运行 :e1, after d3, 5d
全量数据备份验证与SOP移交 :e2, after e1, 5d
项目正式Go-Live (转入SLA运维) :milestone, m1, after e2, 0d
```
-----
### 二、 风险应对登记册 (Risk Register)
本登记册严格对标 RFP 第 6.3.1 节中提出的四大运维与执行风险,并补充了合规与资源配置方面的关键管理风险。在方案中呈现此表,能极大提升客户对供应商交付质量的信任度。
| 风险 ID | 风险类别 | 风险描述 (Risk Description) | 概率 | 影响 | 应对策略 | 缓解措施 & 应急预案 (Mitigation & Contingency Plan) |
| :--- | :--- | :--- | :---: | :---: | :--- | :--- |
| **R-01** | **数据安全**<br>*(RFP强项)* | **Data Loss (数据丢失):**<br>由于私有云存储故障或人为误操作导致留资数据或车型素材丢失。 | 低 | **极高** | **减轻**<br>(Mitigate) | **缓解措施:** 实施每日增量、每周全量备份,**源环境备份数据保留 ≥1 个月**;开启数据库防误删机制 (Soft Delete)。<br>**应急预案:** 启动 RTO=2小时RPO=15分钟的数据恢复 SOP异地存储桶冷备激活。 |
| **R-02** | **系统可用性**<br>*(RFP强项)* | **Performance Degradation (性能降级):**<br>营销活动期间并发流量激增,导致私有云网关或小程序响应缓慢。 | 中 | 高 | **减轻**<br>(Mitigate) | **缓解措施:** 部署 APM (应用性能管理) 实时监控吞吐量;配置网关限流与图片 CDN 缓存。<br>**应急预案:** 触发自动弹性扩容 (Auto-scaling) 规则;实施“服务降级”,暂停后台非核心的图片抓取任务,优先保障前端留资表单的可用性。 |
| **R-03** | **技术集成**<br>*(RFP强项)* | **Integration Failure (集成失效):**<br>小红书开放平台 API 变更或 JV 官网结构改版,导致数据同步与推送失败。 | 中 | 高 | **规避**<br>(Avoid) | **缓解措施:** 建立独立的自动化接口测试脚本,每日定时巡检 API 健康度;对 JV 接口返回值做严格的数据类型校验。<br>**应急预案:** 接口失效时触发告警并阻断脏数据入库;启用前端页面缓存机制 (Fallback to Cache);研发团队 4 小时内介入修复。 |
| **R-04** | **法律合规** | **PIPL 隐私合规违规:**<br>未按照国家法规要求处理 C 端用户个人信息,面临罚款或小程序下架。 | 低 | **极高** | **规避**<br>(Avoid) | **缓解措施:** 前端强制弹窗获取授权;后端数据库敏感字段(手机号)强制执行 AES-256 加密落库;后台采用基于角色的访问控制 (RBAC) 与数据脱敏展示。 |
| **R-05** | **资源与排期** | **核心人员流失/不可用:**<br>关键开发或运维人员在项目关键路径中因故缺席,导致无法按时 Go-Live。 | 中 | 中 | **减轻**<br>(Mitigate) | **缓解措施:** *响应RFP要求*,建立“主角色+影子角色 (Backup)”的人力资源池;所有技术架构和操作手册 (SOP) 必须沉淀在知识库中。<br>**应急预案:** 影子池人员在 24 小时内完成工作交接并全职顶替,确保进度不被阻断。 |
| **R-06** | **变更管理**<br>*(RFP强项)* | **Contingency Rollback (回滚失败):**<br>系统升级或补丁发布导致生产环境大面积不可用,且无法快速恢复。 | 低 | 高 | **减轻**<br>(Mitigate) | **缓解措施:** 采用蓝绿部署或金丝雀发布策略;任何生产变更前必须在 UAT 环境完成演练。<br>**应急预案:** 执行标准化回滚 SOP (Contingency Planning),通过私有云虚拟机快照在 30 分钟内无损回滚至上一稳定版本。 |

165
Proposal.md Normal file
View File

@@ -0,0 +1,165 @@
***
# Proposal: Audi China Red Note Mini App Portal
**Section: Understanding & Solution**
## Slide 1: Executive Summary
**Visual Suggestion:** A sleek, high-impact split-screen graphic. On one side, a vibrant Audi marketing visual on a mobile device (representing the Red Note app); on the other, a secure, locked server rack (representing the Private Cloud and Compliance).
**Core Content:**
* **The Vision:** Building a seamless bridge between Audi's premium brand experience and highly secure enterprise data management.
* **The "Impossible Triangle" Solved:** This project demands agility for marketing, absolute security for data, and rock-solid stability for operations. Our solution guarantees all three.
* **Our Commitment:** Delivering a fully compliant, high-performing Red Note Mini App portal with automated content pipelines, strictly adhering to PIPL, and backed by enterprise-grade SLA operations from 2026 to 2028.
---
## Slide 2: The Infinity Loop: User Journey & Business Flow
**Visual Suggestion:** A horizontal Infinity Loop (`∞`) diagram. The left loop represents the "B2C Front-end" and the right loop represents the "B2B Back-end".
**Core Content:**
* **Left Loop (Discovery & Engagement):** * Target audience discovers Audi models via Red Note.
* Seamless browsing of vehicle details.
* Strict PIPL consent gate triggered before any data entry.
* Submission of Lead Generation Form.
* **Right Loop (Operation & Conversion):**
* Encrypted transmission to the secured Private Cloud Portal.
* Leads processed and securely prepared for CRM integration.
* Automated sync engine pulls the latest model images from JV websites.
* Portal pushes fresh content back to the Mini App.
```mermaid
graph TD
A[市场专员登录CMS] --> B{操作类型}
B -->|新增内容| C[创建新页面/模块]
B -->|编辑内容| D[修改现有内容]
B -->|删除内容| E[标记内容为删除]
C --> F[上传图片/输入文案]
D --> F
F --> G[预览内容]
G --> H{审核通过?}
H -->|是| I[发布到生产环境]
H -->|否| J[返回编辑]
I --> K[同步到小红书小程序]
```
```mermaid
graph TD
A[用户访问小红书笔记] --> B[点击外链进入小程序]
B --> C[浏览车型信息]
C --> D{感兴趣?}
D -->|是| E[填写联系信息表单]
D -->|否| F[离开页面]
E --> G[提交表单]
G --> H{验证通过?}
H -->|是| I[显示感谢页面]
H -->|否| J[显示错误信息]
I --> K[数据存储到数据库]
```
---
## Slide 3: Core Challenges & Strategic Mitigation
**Visual Suggestion:** A three-column grid with warning icons transforming into green checkmarks.
**Core Content:**
* **Challenge 1: Strict PIPL & Data Privacy**
* *Strategy:* Implement AES-256 encryption for Data at Rest. Mandatory front-end consent gating. Role-Based Access Control (RBAC) with masked data views for operators.
* **Challenge 2: Integration Volatility (JV APIs & Red Note)**
* *Strategy:* Build resilient API middleware with automated retry mechanisms. Implement "Fallback to Cache" so the Mini App remains functional even if upstream JV APIs timeout.
* **Challenge 3: Rigorous Data Retention & Contingency**
* *Strategy:* Automated daily incremental and weekly full backups. Guaranteed $\ge$ 1-month retention of the source environment. 30-minute snapshot rollback capabilities.
---
## Slide 4: Solution Architecture Blueprint
**Visual Suggestion:** A layered architecture diagram showing the flow from the Client Interface (Mobile) through the Security Gateway into the Secured Private Cloud and external APIs.
**Core Content:**
* **Presentation Layer:** Red Note Mini App native framework (UI/UX compliant with Audi VI).
* **Gateway & Security Layer:** HTTPS/TLS 1.2+, WAF, and API throttling to prevent overload.
* **Application Layer (3rd Party Portal):** Headless CMS engine, Lead Management Service, and Data Sync Scheduler.
* **Data & Persistence Layer:** Encrypted Relational Database (PII Data) and Object Storage (Images).
* **Openness & Extensibility:** Decoupled design with RESTful APIs, preventing vendor lock-in and ensuring easy data migration.
---
## Slide 5: Automated Content & Integration Pipeline
**Visual Suggestion:** A flowchart showing a robotic arm or gear mechanism moving images from a "JV Website" bucket to an "Audi Mini App" display.
**Core Content:**
* **Eliminating Manual Overhead:** Transitioning from error-prone weekly manual uploads to a highly automated sync pipeline.
* **The Workflow:**
* Scheduled cron jobs pull vehicle image metadata from JV official websites.
* System automatically validates file size, format, and dimensions.
* Content is staged in the Portal for one-click approval or auto-published to Red Note.
* **Business Value:** Significantly reduces operational workload, ensures brand consistency across platforms, and guarantees zero latency in marketing campaigns.
---
## Slide 6: Agile Delivery & Milestone Plan
**Visual Suggestion:** A highly structured 12-week Gantt chart highlighting key sprints and testing phases.
**Core Content:**
* **Phase 1: Discovery & Architecture (Weeks 1-2):** UI/UX design, PIPL legal review, and Private Cloud resource provisioning.
* **Phase 2: Core Development - 2 Sprints (Weeks 3-8):** Front-end scaffolding, 3rd party CMS deployment, API integration, and encryption logic.
* **Phase 3: Rigorous QA & Security (Weeks 9-10):** System Integration Testing (SIT), Vulnerability Scanning, and Penetration Testing.
* **Phase 4: UAT & Go-Live (Weeks 11-12):** User Acceptance Testing, Knowledge Transfer, and production cutover.
```mermaid
gantt
title 奥迪小红书小程序门户 - 2026 项目建设排期表 (12周)
dateFormat YYYY-MM-DD
axisFormat %m-%d
section 1. 需求与设计 (2周)
需求调研与业务流程确认 :a1, 2026-05-01, 7d
UI/UX设计与小红书审核 :a2, after a1, 7d
系统架构设计与私有云评审 :a3, 2026-05-08, 7d
section 2. 环境部署 (2周)
私有云资源申领与网络规划 :b1, after a3, 5d
第三方平台(CMS)私有化部署 :b2, after b1, 7d
安全基线加固与证书配置 :b3, after b2, 3d
section 3. 开发与集成 (4周)
Sprint 1: 小程序前端页面开发 :c1, after a2, 14d
Sprint 1: 留资表单与加密落库 :c2, after b2, 14d
Sprint 2: JV官网车型自动同步集成 :c3, after c1, 14d
Sprint 2: 小红书开放平台API对接 :c4, after c2, 14d
section 4. 测试与安全 (2周)
SIT (系统集成与压力测试) :d1, after c4, 7d
漏洞扫描与渗透测试(Pen-Test) :d2, after d1, 5d
UAT (用户验收测试) :d3, after d2, 5d
section 5. 上线与移交 (2周)
生产环境割接与试运行 :e1, after d3, 5d
全量数据备份验证与SOP移交 :e2, after e1, 5d
项目正式Go-Live (转入SLA运维) :milestone, m1, after e2, 0d
```
---
## Slide 7: Expert Team & Automotive IT DNA
**Visual Suggestion:** An organizational chart or team profile cards highlighting key roles and industry badges (PMP, Agile, Automotive).
**Core Content:**
* **Senior Leadership:** Led by a PMP-certified Project Manager with 18 years of enterprise application development and IT delivery experience.
* **Agile Mastery:** Deep expertise in Agile methodologies (Scrum, SAFe) ensuring transparent, iterative, and high-quality deliveries.
* **Premium Automotive Experience:** A team with proven DNA in the automotive sector, boasting hands-on experience in managing complex connected car ecosystems and seamlessly taking over critical IT operations (e.g., MIB3 services).
* **Scalable Resource Pool:** A structured "Core + Shadow" team model ensuring zero disruption and fulfilling the RFP's exact backup resource requirements.
---
## Slide 8: Smart Ops & SLA Guarantee (2026-2028)
**Visual Suggestion:** A dashboard graphic showing 99.9% uptime, accompanied by a 7x24 timeline.
**Core Content:**
* **Next-Generation Maintenance:** Moving beyond reactive support by implementing Site Reliability Engineering (SRE) and Smart Ops practices.
* **Proven Centralized Management:** Extensive experience in executing centralized software maintenance and operations across diverse, multi-client portfolios.
* **SLA Commitments:**
* **7x24 Stand-by Readiness:** Coverage across all non-working hours and public holidays.
* **Rapid Incident Response:** 15-minute acknowledgment and 4-hour resolution (workaround/fix) for all Critical/High priority tickets.
* Comprehensive application health checks, continuous Grafana/Prometheus monitoring, and formalized monthly reporting.
---
## Slide 9: Why Us / Conclusion
**Visual Suggestion:** A three-pillar graphic (Security, Automotive Expertise, Operational Excellence) supporting the Audi logo.
**Core Content:**
* **Uncompromising Compliance:** PIPL and data security are built into the source code, not added as an afterthought.
* **Automotive IT Veterans:** We speak your language. Our rich background in managing premium auto-brand systems ensures a zero-learning-curve collaboration.
* **Flawless Execution:** From a robust 12-week Agile rollout to intelligent, centralized SRE operations, we guarantee an on-time Go-Live and years of frictionless maintenance.
* **Your Trusted Digital Partner:** We deliver more than code; we deliver brand trust and operational peace of mind.

60
RFP.txt Normal file
View File

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

@@ -0,0 +1,289 @@
# 奥迪小红书小程序门户需求规格说明书
## 1. 引言
### 1.1 目的
本文档旨在定义奥迪中国小红书小程序门户系统的功能性和非功能性需求,为系统设计、开发、测试和验收提供基准。本系统将支持奥迪中国在小红书平台展示汽车产品信息并收集潜在客户联系信息。
### 1.2 范围
本系统范围包括:
- 小红书小程序门户前端应用基于H5技术
- 内容管理系统CMS平台
- 私有云环境部署
- 三年期运维服务2026-2028年
系统将允许奥迪市场部门管理展示内容,用户可浏览汽车型号信息并提交联系信息表单。
### 1.3 定义、首字母缩写词和缩略语
### 1.4 参考资料
- RFP文档奥迪小红书小程序门户项目
- IEEE 830-1998 软件需求规格说明标准
- 小红书开放平台技术规范
### 1.5 概述
本文档其余部分详细描述了系统的总体特性、具体功能需求、非功能需求、外部接口需求以及相关业务流程。
## 2. 总体描述
### 2.1 产品愿景
建立一个安全、可靠、易于维护的小红书小程序门户,使奥迪中国能够在小红书平台有效展示产品信息,收集潜在客户数据,并与官方渠道保持内容一致性。
### 2.2 产品功能
- **内容管理**:通过第三方平台管理小红书小程序显示内容
- **用户交互**:用户可浏览汽车型号信息并提交联系信息
- **API集成**:连接小红书小程序后端
- **运维监控**:确保系统高可用性和性能
- **定期更新**:每周同步官方渠道的车型图片
### 2.3 用户特征
| 用户角色 | 描述 | 技术水平 |
|---------|------|---------|
| 奥迪业务/市场负责人 | 负责内容方向决策、审核与月度运营结果确认 | 业务管理与内容审核能力 |
| 内容运营专员(供应商/联合团队) | 负责内容制作发布与每周素材同步 | 基础内容运营与系统操作能力 |
| 供应商项目与运维负责人 | 负责项目推进、风险管理、监控与故障响应 | 专业项目管理与运维技术背景 |
| 终端用户(小红书用户) | 浏览车型信息并提交联系表单 | 普通智能手机用户 |
### 2.4 约束
- 必须部署在安全的私有云环境中
- 必须符合小红书平台技术规范
- 必须支持与奥迪官方渠道的内容同步
- 必须满足个人信息保护法规要求
### 2.5 假设和依赖关系
- 奥迪中国已获得小红书企业专业号认证和外链权限
- 小红书平台API保持稳定
- 奥迪官方渠道提供标准化的车型数据接口
## 3. 具体需求
### 3.1 功能需求
#### 3.1.1 平台准备
**FR-001**: 系统应提供一个第三方内容管理平台,允许授权用户编辑小红书小程序显示内容。
**FR-002**: 内容管理平台应支持以下内容类型:
- 文本内容编辑
- 图片上传和管理
- 表单字段配置
- 页面布局调整
**FR-003**: 系统应提供API接口将内容管理平台与小红书小程序前端连接。
#### 3.1.2 环境准备
**FR-004**: 系统必须部署在安全的私有云环境中。
**FR-005**: 云环境应包含以下组件:
- Web服务器
- 应用服务器
- 数据库服务器
- 监控和日志系统
**FR-006**: 系统应实施网络安全措施,包括防火墙、入侵检测和数据加密。
#### 3.1.3 门户维护
**FR-007**: 系统应保证99.5%的正常运行时间。
**FR-008**: 系统应实现自动备份机制保留源环境至少1个月。
**FR-009**: 系统应具备性能监控和优化能力,防止性能下降。
**FR-010**: 系统应建立集成测试和故障排除机制。
**FR-011**: 系统应准备回滚和事件响应程序。
#### 3.1.4 内容运营
**FR-012**: 系统应支持每周定期更新内容,与奥迪官方渠道的车型图片保持同步。
**FR-013**: 内容更新应支持以下操作:
- 批量图片替换
- 文案更新
- 新车型添加
- 旧车型下架
**FR-014**: 系统应记录所有内容变更历史,支持版本回溯。
#### 3.1.5 项目管理
**FR-015**: 系统供应商应提供日常账户服务、时间线管理、风险管理、质量控制和会议材料准备。
**FR-016**: 供应商应每周举行进度更新会议,解决出现的问题。
**FR-017**: 供应商应每月提供运营报告,并与奥迪中国进行审查。
**FR-018**: 对于关键/高优先级事件,供应商应提供专门的事件报告。
#### 3.1.6 运维支持与值班
**FR-019**: 系统应提供关键/高优先级事件支持机制:工作日服务时间 08:30-17:30非服务时间 17:30-08:30 待命支持。
**FR-020**: 系统应在周末及法定节假日提供关键/高优先级事件 7x24 待命支持。
### 3.2 非功能需求
#### 3.2.1 性能需求
**NFR-001**: 系统响应时间不应超过3秒在正常网络条件下
**NFR-002**: 系统应支持同时处理1000个并发用户。
#### 3.2.2 安全需求
**NFR-003**: 系统必须实施用户身份验证和授权机制。
**NFR-004**: 所有敏感数据(如用户联系信息)必须加密存储。
**NFR-005**: 系统必须符合《个人信息保护法》要求,明确告知用户数据用途并获得同意。
#### 3.2.3 可用性需求
**NFR-006**: 内容管理界面应直观易用新用户可在30分钟内掌握基本操作。
**NFR-007**: 系统应提供详细的帮助文档和操作指南。
#### 3.2.4 可维护性需求
**NFR-008**: 系统应采用模块化设计,便于功能扩展和维护。
**NFR-009**: 代码应有充分注释,遵循编码规范。
#### 3.2.5 可移植性需求
**NFR-010**: 系统应使用标准开放平台接口,支持与其他系统进行数据交换。
**NFR-011**: 内容应能轻松迁移到其他平台,避免供应商锁定。
#### 3.2.6 供应商能力与交接需求
**NFR-012**: 供应商团队应具备小红书小程序集成、应用、云、安全与数据相关实践经验。
**NFR-013**: 项目管理核心角色应具备 PMP 或同等级项目管理资质。
**NFR-014**: 供应商应说明对现有技术平台、基础设施和 IT 能力的复用方案,降低重复建设成本。
**NFR-015**: 供应商应保证项目资源充足,关键岗位需设置备份人选以确保按期上线。
**NFR-016**: 供应商应在项目期提供技术支持与知识转移,至少包含系统架构、运维操作、常见故障处理和交接文档。
### 3.3 外部接口需求
#### 3.3.1 用户接口
**UI-001**: 小红书小程序前端应采用响应式设计,适配各种移动设备屏幕。
**UI-002**: 内容管理后台应提供清晰的导航和操作反馈。
#### 3.3.2 硬件接口
无特殊硬件接口需求。
#### 3.3.3 软件接口
**SI-001**: 系统应提供RESTful API接口用于内容同步。
**SI-002**: 系统应支持与奥迪官方渠道的数据接口对接。
#### 3.3.4 通信接口
**CI-001**: 所有外部通信必须使用HTTPS协议。
**CI-002**: API调用应实施速率限制和认证机制。
## 4. 业务流程
### 4.1 内容管理流程
```mermaid
graph TD
A[内容运营专员登录CMS] --> B{操作类型}
B -->|新增内容| C[创建新页面/模块]
B -->|编辑内容| D[修改现有内容]
B -->|删除内容| E[标记内容为删除]
C --> F[上传图片/输入文案]
D --> F
F --> G[预览内容并提交审核]
G --> H[奥迪业务/市场负责人审核]
H --> I{审核通过?}
I -->|是| J[发布到生产环境]
I -->|否| K[返回内容运营专员修订]
J --> L[同步到小红书小程序]
```
### 4.2 用户交互流程
```mermaid
graph TD
A[用户访问小红书笔记] --> B[点击外链进入小程序]
B --> C[浏览车型信息]
C --> D{感兴趣?}
D -->|是| E[填写联系信息表单]
D -->|否| F[离开页面]
E --> G[提交表单]
G --> H{验证通过?}
H -->|是| I[显示感谢页面]
H -->|否| J[显示错误信息]
I --> K[数据存储到数据库]
```
### 4.3 运维管理流程
```mermaid
graph TD
A[系统监控] --> B{异常检测?}
B -->|是| C[触发告警]
C --> D[运维团队响应]
D --> E{问题级别}
E -->|关键/高| F[立即处理 + 生成事件报告]
E -->|中/低| G[记录并安排处理]
B -->|否| H[正常运行]
H --> I[定期备份]
I --> J[月度报告生成]
```
## 5. 术语表
| 中文术语 | 英文术语 | 业务定义 |
|---------|---------|---------|
| 小红书小程序 | Red Note Mini App | 在小红书平台内运行的轻量级应用,用于展示产品信息和收集用户数据 |
| 内容管理系统 | Content Management System (CMS) | 用于创建、管理和修改数字内容的软件应用程序 |
| 私有云 | Private Cloud | 专为单一组织构建的云计算环境,提供更高的安全性和控制 |
| 外链 | External Link | 从小红书笔记指向外部网站的链接 |
| 运维 | Operations and Maintenance | 系统上线后的日常监控、维护和支持活动 |
| 表单 | Form | 用于收集用户输入信息的网页元素集合 |
| API集成 | API Integration | 不同软件系统之间通过应用程序编程接口进行数据交换和功能调用 |
| 内容同步 | Content Synchronization | 保持多个系统或平台之间内容一致性的过程 |
## 附录A: 异常流程
### A.1 内容更新异常流程
1. **异常情况**: 官方渠道图片无法获取
- **处理流程**:
- 系统记录错误日志
- 发送告警通知给运维团队
- 使用上次成功同步的图片作为临时替代
- 运维团队手动介入解决
2. **异常情况**: 内容发布失败
- **处理流程**:
- 系统自动回滚到上一版本
- 通知内容管理员
- 提供详细的错误信息
- 记录失败原因用于后续分析
### A.2 用户表单提交异常流程
1. **异常情况**: 表单验证失败
- **处理流程**:
- 高亮显示错误字段
- 提供具体的错误提示
- 保留用户已输入的正确信息
- 允许用户修正后重新提交
2. **异常情况**: 数据库写入失败
- **处理流程**:
- 显示友好的错误页面
- 自动重试机制最多3次
- 如果仍失败,保存数据到临时存储
- 后台任务定期重试写入
### A.3 系统故障异常流程
1. **异常情况**: 服务器宕机
- **处理流程**:
- 自动切换到备用服务器
- 发送紧急告警给运维团队
- 启动故障排查程序
- 如30分钟内无法恢复执行回滚计划
2. **异常情况**: 安全漏洞检测
- **处理流程**:
- 立即隔离受影响组件
- 通知安全团队
- 实施临时防护措施
- 进行全面安全审计

View File

@@ -0,0 +1,245 @@
# 奥迪小红书小程序门户 - 需求规格说明书 (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”客户是否有预期的偏好名单还是完全由供应商进行选型推荐
---

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-content-portal/.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/446d51dc-3773-4299-8003-801daf14fe26
## 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,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

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 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,5 @@
{
"name": "Audi Content & Operation Portal",
"description": "A professional, corporate 'Smart Ops' portal for Audi content management and lead operations.",
"requestFramePermissions": []
}

8324
audi-content-portal/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
{
"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": {
"@base-ui/react": "^1.3.0",
"@fontsource-variable/geist": "^5.2.8",
"@google/genai": "^1.29.0",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^3.8.1",
"shadcn": "^4.2.0",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"vite": "^6.2.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@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"
}
}

View File

@@ -0,0 +1,154 @@
import * as React from "react"
import { SidebarProvider, SidebarInset, SidebarTrigger } from "@/components/ui/sidebar"
import { AppSidebar } from "@/components/layout/AppSidebar"
import { Dashboard } from "@/components/dashboard/Dashboard"
import { CarLibrary } from "@/components/car-library/CarLibrary"
import { MiniAppContentLibrary } from "@/components/content-library/MiniAppContentLibrary"
import { LeadManagement } from "@/components/leads/LeadManagement"
import { UserAccessManagement } from "@/components/users/UserAccessManagement"
import { SystemSettings } from "@/components/settings/SystemSettings"
import { Separator } from "@/components/ui/separator"
import { Bell, Search, User } from "lucide-react"
import { Button } from "@/components/ui/button"
type NewContentRequest = {
sourceCarId: string
sourceCarName: string
requestId: string
}
export type RoleView = "biz-market" | "content-ops" | "pm-ops"
export type WorkflowConfig = {
enablePrePublish: boolean
allowRecall: boolean
publishStrategy: "manual" | "scheduled"
defaultPublishTime: string
syncFrequency: "daily" | "weekly"
syncExecutionTime: string
retryCount: number
manualConfirmSync: boolean
}
export default function App() {
const [activeTab, setActiveTab] = React.useState("dashboard")
const [roleView, setRoleView] = React.useState<RoleView>("content-ops")
const [workflowConfig, setWorkflowConfig] = React.useState<WorkflowConfig>({
enablePrePublish: true,
allowRecall: true,
publishStrategy: "manual",
defaultPublishTime: "10:00",
syncFrequency: "weekly",
syncExecutionTime: "02:00",
retryCount: 2,
manualConfirmSync: true,
})
const [auditLogs, setAuditLogs] = React.useState<string[]>([
"内容运营专员提交 Audi A6L 审核2026-04-11 09:20",
"业务/市场负责人批准 Audi Q5L 进入预发布2026-04-10 15:45",
"内容运营专员发布 Audi A3 Sportback2026-04-09 11:30",
])
const [pendingNewContent, setPendingNewContent] = React.useState<NewContentRequest | null>(null)
const appendLog = (message: string) => {
const now = new Date().toLocaleString("zh-CN", { hour12: false })
setAuditLogs((prev) => [`${message}${now}`, ...prev].slice(0, 12))
}
const handleCreateContentFromCar = (sourceCarId: string, sourceCarName: string) => {
setPendingNewContent({
sourceCarId,
sourceCarName,
requestId: String(Date.now()),
})
appendLog(`内容运营专员基于官网 ${sourceCarName} 新建内容草稿`)
setActiveTab("mini-content")
}
const renderContent = () => {
switch (activeTab) {
case "dashboard":
return <Dashboard />
case "car-library":
return (
<CarLibrary
roleView={roleView}
workflowConfig={workflowConfig}
onAddAuditLog={appendLog}
onCreateContent={handleCreateContentFromCar}
/>
)
case "mini-content":
return (
<MiniAppContentLibrary
roleView={roleView}
workflowConfig={workflowConfig}
onAddAuditLog={appendLog}
newContentRequest={pendingNewContent}
/>
)
case "leads":
return <LeadManagement />
case "user-access":
return <UserAccessManagement />
case "settings":
return (
<SystemSettings
roleView={roleView}
onRoleViewChange={setRoleView}
workflowConfig={workflowConfig}
onWorkflowConfigChange={setWorkflowConfig}
auditLogs={auditLogs}
/>
)
default:
return <Dashboard />
}
}
return (
<SidebarProvider>
<AppSidebar activeTab={activeTab} setActiveTab={setActiveTab} />
<SidebarInset className="bg-gray-50/50">
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b bg-white px-4 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<div className="flex flex-1 items-center justify-between">
<div className="text-sm font-medium text-muted-foreground">
{activeTab === "dashboard" && "仪表盘"}
{activeTab === "car-library" && "车型库"}
{activeTab === "mini-content" && "小程序内容库"}
{activeTab === "leads" && "潜客管理"}
{activeTab === "user-access" && "用户与权限"}
{activeTab === "settings" && "系统设置"}
</div>
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" className="h-9 w-9 text-muted-foreground">
<Search className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-9 w-9 text-muted-foreground">
<Bell className="h-4 w-4" />
</Button>
<div className="flex items-center gap-2 pl-2 border-l">
<div className="flex flex-col items-end">
<span className="text-xs font-bold">Admin User</span>
<span className="text-[10px] text-muted-foreground">
{roleView === "biz-market" && "业务/市场负责人视角"}
{roleView === "content-ops" && "内容运营专员视角"}
{roleView === "pm-ops" && "项目与运维负责人视角"}
</span>
</div>
<div className="h-8 w-8 rounded-full bg-audi-black flex items-center justify-center text-white">
<User size={16} />
</div>
</div>
</div>
</div>
</header>
<main className="flex-1 overflow-auto">
{renderContent()}
</main>
</SidebarInset>
</SidebarProvider>
)
}

View File

@@ -0,0 +1,283 @@
import * as React from "react"
import { AlertCircle, CheckCircle2, Clock, Filter, Plus, RefreshCw, Search } from "lucide-react"
import type { RoleView, WorkflowConfig } from "@/App"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Progress } from "@/components/ui/progress"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
type SyncStatus = "synced" | "pending" | "error"
type SourceCar = {
id: string
name: string
officialTagline: string
sourceUpdatedAt: string
syncStatus: SyncStatus
imageCount: number
}
type SyncLog = {
id: string
time: string
status: "success" | "failed"
message: string
}
type CarLibraryProps = {
roleView: RoleView
workflowConfig: WorkflowConfig
onAddAuditLog: (message: string) => void
onCreateContent?: (sourceCarId: string, sourceCarName: string) => void
}
const SOURCE_CARS: SourceCar[] = [
{
id: "a3",
name: "Audi A3 Sportback",
officialTagline: "进取,不负期待",
sourceUpdatedAt: "2026-04-11 09:30",
syncStatus: "synced",
imageCount: 8,
},
{
id: "a4l",
name: "Audi A4L",
officialTagline: "做更强大的自己",
sourceUpdatedAt: "2026-04-11 09:30",
syncStatus: "synced",
imageCount: 6,
},
{
id: "a6l",
name: "Audi A6L",
officialTagline: "懂你,更懂未来",
sourceUpdatedAt: "2026-04-10 14:20",
syncStatus: "pending",
imageCount: 5,
},
{
id: "q5l",
name: "Audi Q5L",
officialTagline: "自由,由我定义",
sourceUpdatedAt: "2026-04-10 09:15",
syncStatus: "error",
imageCount: 0,
},
]
const INITIAL_LOGS: SyncLog[] = [
{ id: "l1", time: "2026-04-11 09:30", status: "success", message: "同步成功4 个车型,素材 19 张" },
{ id: "l2", time: "2026-04-10 09:15", status: "failed", message: "Audi Q5L 图片拉取失败CDN 超时" },
]
function syncBadge(status: SyncStatus) {
if (status === "synced") {
return (
<Badge variant="outline" className="bg-emerald-50 text-emerald-700 border-emerald-200 gap-1">
<CheckCircle2 className="h-3 w-3" />
</Badge>
)
}
if (status === "pending") {
return (
<Badge variant="outline" className="bg-amber-50 text-amber-700 border-amber-200 gap-1">
<Clock className="h-3 w-3" />
</Badge>
)
}
return (
<Badge variant="outline" className="bg-rose-50 text-rose-700 border-rose-200 gap-1">
<AlertCircle className="h-3 w-3" />
</Badge>
)
}
function nowString() {
return new Date().toLocaleString("zh-CN", { hour12: false })
}
export function CarLibrary({ onAddAuditLog, onCreateContent }: CarLibraryProps) {
const [sourceCars, setSourceCars] = React.useState<SourceCar[]>(SOURCE_CARS)
const [query, setQuery] = React.useState("")
const [isSyncing, setIsSyncing] = React.useState(false)
const [progress, setProgress] = React.useState(0)
const [syncLogs, setSyncLogs] = React.useState<SyncLog[]>(INITIAL_LOGS)
const filtered = sourceCars.filter((car) => car.name.toLowerCase().includes(query.toLowerCase()))
const handleSync = () => {
setIsSyncing(true)
setProgress(0)
const interval = setInterval(() => {
setProgress((prev) => {
if (prev >= 100) {
clearInterval(interval)
const time = nowString()
setSourceCars((curr) =>
curr.map((car) => ({
...car,
sourceUpdatedAt: time,
syncStatus: "synced",
imageCount: car.imageCount || 4,
}))
)
setSyncLogs((curr) => [
{ id: String(Date.now()), time, status: "success", message: "手动同步完成4 个车型已更新" },
...curr,
])
onAddAuditLog("内容运营专员执行官网车型同步")
setTimeout(() => setIsSyncing(false), 300)
return 100
}
return prev + 4
})
}, 45)
}
const retryFailedSync = (logId: string) => {
const time = nowString()
setSyncLogs((curr) =>
curr.map((log) =>
log.id === logId ? { ...log, status: "success", time, message: `${log.message} -> 已重试成功` } : log
)
)
setSourceCars((curr) =>
curr.map((car) => (car.syncStatus === "error" ? { ...car, syncStatus: "synced", sourceUpdatedAt: time, imageCount: 4 } : car))
)
onAddAuditLog("项目与运维负责人重试官网同步失败任务")
}
return (
<div className="flex flex-col gap-6 p-8">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground"></p>
</div>
<Button onClick={handleSync} disabled={isSyncing} className="bg-audi-black hover:bg-audi-dark-gray text-white px-6">
{isSyncing ? <RefreshCw className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
</Button>
</div>
{isSyncing && (
<Card className="border-audi-red/20 bg-audi-red/5">
<CardContent className="pt-6">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-audi-red">...</span>
<span className="text-muted-foreground">{Math.round(progress)}%</span>
</div>
<Progress value={progress} className="h-2 bg-audi-red/10" />
</div>
</CardContent>
</Card>
)}
<Card className="border-none shadow-sm bg-white">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg font-bold"></CardTitle>
<Badge variant="outline" className="text-xs">Source Entity: SourceCar</Badge>
</div>
<div className="flex items-center gap-2 w-full max-w-sm">
<Search className="h-4 w-4 text-muted-foreground absolute ml-3" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索官网车型名称..."
className="pl-9 bg-gray-50/50 border-none"
/>
</div>
</CardHeader>
<CardContent>
<Table>
<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="text-right font-bold text-audi-black"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((car) => (
<TableRow key={car.id} className="hover:bg-gray-50/50 border-b border-gray-50 transition-colors">
<TableCell className="font-medium">{car.name}</TableCell>
<TableCell className="text-muted-foreground">{car.officialTagline}</TableCell>
<TableCell>{car.imageCount} </TableCell>
<TableCell>{syncBadge(car.syncStatus)}</TableCell>
<TableCell className="text-muted-foreground text-xs">{car.sourceUpdatedAt}</TableCell>
<TableCell className="text-right">
{car.syncStatus === "synced" ? (
<Button
size="sm"
variant="outline"
onClick={() => {
onCreateContent?.(car.id, car.name)
onAddAuditLog(`内容运营专员基于官网 ${car.name} 新建内容草稿`)
}}
>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
) : (
<Button
size="sm"
variant="outline"
disabled
title={car.syncStatus === "pending" ? "车型待同步,完成同步后可新建内容" : "车型同步失败,请先重试同步"}
>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Card className="border-none shadow-sm bg-white">
<CardHeader>
<CardTitle className="text-lg font-bold"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{syncLogs.map((log) => (
<div key={log.id} className="flex items-center gap-3 rounded-lg border p-3 text-sm">
{log.status === "success" ? (
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
) : (
<AlertCircle className="h-4 w-4 text-rose-600" />
)}
<div className="flex-1">
<p className="font-medium">{log.message}</p>
<p className="text-xs text-muted-foreground">{log.time}</p>
</div>
{log.status === "failed" && (
<Button size="sm" variant="outline" onClick={() => retryFailedSync(log.id)}>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
</Button>
)}
</div>
))}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,621 @@
import * as React from "react"
import {
ArrowLeft,
Eye,
Pencil,
Send,
ShieldCheck,
Undo2,
Upload,
XCircle,
} from "lucide-react"
import type { RoleView, WorkflowConfig } from "@/App"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
type WorkflowStatus = "draft" | "pending" | "prepublished" | "published" | "rejected" | "recalled"
type MiniAppContent = {
id: string
sourceCarId: string
sourceCarName: string
title: string
subtitle: string
highlights: string
description: string
ctaText: string
scheduledPublishAt: string
imageUrls: string[]
workflowStatus: WorkflowStatus
updatedBy: string
updatedAt: string
}
type MiniAppContentLibraryProps = {
roleView: RoleView
workflowConfig: WorkflowConfig
onAddAuditLog: (message: string) => void
newContentRequest?: {
sourceCarId: string
sourceCarName: string
requestId: string
} | null
}
const CONTENTS: MiniAppContent[] = [
{
id: "c1",
sourceCarId: "a3",
sourceCarName: "Audi A3 Sportback",
title: "Audi A3 Sportback",
subtitle: "进取,不负期待",
highlights: "数字座舱\n城市通勤\n智能互联",
description: "适合城市用户的豪华紧凑车型,兼顾效率与智能体验。",
ctaText: "立即预约试驾",
scheduledPublishAt: "",
imageUrls: [
"https://images.unsplash.com/photo-1606152421802-db97b9c7a11b?auto=format&fit=crop&q=80&w=1200",
"https://picsum.photos/seed/a3-content-1/900/600",
"https://picsum.photos/seed/a3-content-2/900/600",
],
workflowStatus: "published",
updatedBy: "内容运营专员",
updatedAt: "2026-04-11 10:02",
},
{
id: "c2",
sourceCarId: "a4l",
sourceCarName: "Audi A4L",
title: "Audi A4L 四月限时礼遇",
subtitle: "豪华与性能平衡",
highlights: "quattro\n商务舒适\n限时礼遇",
description: "面向商务人群的主推内容版本,突出舒适与操控。",
ctaText: "获取活动详情",
scheduledPublishAt: "2026-04-14T10:00",
imageUrls: [
"https://images.unsplash.com/photo-1614162692292-7ac56d7f7f1e?auto=format&fit=crop&q=80&w=1200",
"https://picsum.photos/seed/a4-content-1/900/600",
],
workflowStatus: "prepublished",
updatedBy: "业务/市场负责人",
updatedAt: "2026-04-11 09:48",
},
{
id: "c3",
sourceCarId: "a6l",
sourceCarName: "Audi A6L",
title: "Audi A6L",
subtitle: "懂你,更懂未来",
highlights: "行政旗舰\n长轴空间\n智能辅助",
description: "正在进行文案优化,待业务审核。",
ctaText: "预约顾问回电",
scheduledPublishAt: "",
imageUrls: [
"https://images.unsplash.com/photo-1541348263662-e0c86433610a?auto=format&fit=crop&q=80&w=1200",
],
workflowStatus: "pending",
updatedBy: "内容运营专员",
updatedAt: "2026-04-11 09:30",
},
{
id: "c4",
sourceCarId: "q3",
sourceCarName: "Audi Q3",
title: "Audi Q3 城市灵动版",
subtitle: "年轻进阶,灵动出行",
highlights: "紧凑SUV\n智能互联\n都市通勤",
description: "新建草稿,待进一步补充图文和活动权益信息。",
ctaText: "预约试驾",
scheduledPublishAt: "",
imageUrls: [
"https://picsum.photos/seed/q3-content-1/900/600",
],
workflowStatus: "draft",
updatedBy: "内容运营专员",
updatedAt: "2026-04-11 08:40",
},
{
id: "c5",
sourceCarId: "q5l",
sourceCarName: "Audi Q5L",
title: "Audi Q5L 周末试驾礼遇",
subtitle: "自由,由我定义",
highlights: "四驱性能\n家庭空间\n周末活动",
description: "因权益文案与活动规则不一致,被驳回待修订。",
ctaText: "了解礼遇",
scheduledPublishAt: "",
imageUrls: [
"https://picsum.photos/seed/q5-content-1/900/600",
],
workflowStatus: "rejected",
updatedBy: "业务/市场负责人",
updatedAt: "2026-04-11 08:10",
},
{
id: "c6",
sourceCarId: "a8l",
sourceCarName: "Audi A8L",
title: "Audi A8L 尊享礼宾版",
subtitle: "旗舰格局,沉稳之选",
highlights: "旗舰行政\n豪华座舱\n专属服务",
description: "已发布后因活动档期调整撤回,待重新排期。",
ctaText: "预约专属顾问",
scheduledPublishAt: "2026-04-15T09:30",
imageUrls: [
"https://picsum.photos/seed/a8-content-1/900/600",
],
workflowStatus: "recalled",
updatedBy: "业务/市场负责人",
updatedAt: "2026-04-11 07:55",
},
]
const WORKFLOW_LABEL: Record<WorkflowStatus, string> = {
draft: "草稿",
pending: "待审核",
prepublished: "预发布",
published: "已发布",
rejected: "已驳回",
recalled: "已撤回",
}
function workflowBadgeClass(status: WorkflowStatus) {
switch (status) {
case "draft":
return "bg-gray-100 text-gray-700 border-gray-200"
case "pending":
return "bg-amber-50 text-amber-700 border-amber-200"
case "prepublished":
return "bg-blue-50 text-blue-700 border-blue-200"
case "published":
return "bg-emerald-50 text-emerald-700 border-emerald-200"
case "rejected":
return "bg-rose-50 text-rose-700 border-rose-200"
case "recalled":
return "bg-zinc-100 text-zinc-700 border-zinc-200"
}
}
function nowString() {
return new Date().toLocaleString("zh-CN", { hour12: false })
}
export function MiniAppContentLibrary({ roleView, workflowConfig, onAddAuditLog, newContentRequest }: MiniAppContentLibraryProps) {
const [contents, setContents] = React.useState<MiniAppContent[]>(CONTENTS)
const [viewMode, setViewMode] = React.useState<"list" | "editor">("list")
const [selectedId, setSelectedId] = React.useState<string>(CONTENTS[0].id)
const [newImageUrl, setNewImageUrl] = React.useState("")
React.useEffect(() => {
if (!newContentRequest) return
const id = `c${newContentRequest.requestId}`
const existing = contents.find((item) => item.id === id)
if (!existing) {
const draft: MiniAppContent = {
id,
sourceCarId: newContentRequest.sourceCarId,
sourceCarName: newContentRequest.sourceCarName,
title: newContentRequest.sourceCarName,
subtitle: "",
highlights: "",
description: "",
ctaText: "立即预约试驾",
scheduledPublishAt: "",
imageUrls: [],
workflowStatus: "draft",
updatedBy: "内容运营专员",
updatedAt: nowString(),
}
setContents((prev) => [draft, ...prev])
}
setSelectedId(id)
setViewMode("editor")
}, [newContentRequest?.requestId])
const canEdit = roleView === "content-ops"
const canApprove = roleView === "biz-market"
const selected = contents.find((item) => item.id === selectedId) ?? null
const updateContent = (id: string, patch: Partial<MiniAppContent>) => {
setContents((prev) =>
prev.map((item) => (item.id === id ? { ...item, ...patch, updatedAt: nowString() } : item))
)
}
const saveDraft = (item: MiniAppContent) => {
updateContent(item.id, { workflowStatus: "draft", updatedBy: "内容运营专员" })
onAddAuditLog(`内容运营专员保存 ${item.sourceCarName} 内容草稿`)
}
const submitForReview = (item: MiniAppContent) => {
updateContent(item.id, { workflowStatus: "pending", updatedBy: "内容运营专员" })
onAddAuditLog(`内容运营专员提交 ${item.sourceCarName} 到审核队列`)
}
const withdrawReview = (item: MiniAppContent) => {
updateContent(item.id, { workflowStatus: "draft", updatedBy: "内容运营专员" })
onAddAuditLog(`内容运营专员撤回 ${item.sourceCarName} 的审核申请`)
}
const approve = (item: MiniAppContent) => {
const nextStatus: WorkflowStatus = workflowConfig.enablePrePublish ? "prepublished" : "published"
updateContent(item.id, { workflowStatus: nextStatus, updatedBy: "业务/市场负责人" })
onAddAuditLog(`业务/市场负责人审批通过 ${item.sourceCarName}`)
}
const reject = (item: MiniAppContent) => {
updateContent(item.id, { workflowStatus: "rejected", updatedBy: "业务/市场负责人" })
onAddAuditLog(`业务/市场负责人驳回 ${item.sourceCarName} 内容版本`)
}
const publish = (item: MiniAppContent) => {
const actor = roleView === "biz-market" ? "业务/市场负责人" : "内容运营专员"
updateContent(item.id, { workflowStatus: "published", updatedBy: actor })
onAddAuditLog(`${actor}发布 ${item.sourceCarName} 到小程序`)
}
const recall = (item: MiniAppContent) => {
updateContent(item.id, { workflowStatus: "recalled", updatedBy: "业务/市场负责人" })
onAddAuditLog(`业务/市场负责人撤回 ${item.sourceCarName} 已发布内容`)
}
const revive = (item: MiniAppContent) => {
const nextStatus: WorkflowStatus = workflowConfig.enablePrePublish ? "prepublished" : "published"
updateContent(item.id, { workflowStatus: nextStatus, updatedBy: "业务/市场负责人" })
onAddAuditLog(`业务/市场负责人恢复 ${item.sourceCarName} 的发布流程`)
}
const createNewVersion = (item: MiniAppContent) => {
updateContent(item.id, { workflowStatus: "draft", updatedBy: "内容运营专员" })
onAddAuditLog(`内容运营专员基于 ${item.sourceCarName} 发布版本创建新稿`)
}
const addImage = (item: MiniAppContent) => {
const next = newImageUrl.trim()
if (!next) return
updateContent(item.id, { imageUrls: [...item.imageUrls, next] })
setNewImageUrl("")
}
const removeImage = (item: MiniAppContent, idx: number) => {
updateContent(item.id, { imageUrls: item.imageUrls.filter((_, i) => i !== idx) })
}
const renderActions = (item: MiniAppContent) => {
const actions: React.ReactNode[] = []
if (canEdit) {
if (item.workflowStatus === "draft" || item.workflowStatus === "rejected" || item.workflowStatus === "recalled") {
actions.push(
<Button key="submit" size="sm" variant="outline" onClick={() => submitForReview(item)}>
<Send className="mr-1 h-3.5 w-3.5" />{item.workflowStatus === "draft" ? "提交审核" : "重新提审"}
</Button>
)
}
if (item.workflowStatus === "pending") {
actions.push(
<Button key="withdraw" size="sm" variant="outline" onClick={() => withdrawReview(item)}>
<Undo2 className="mr-1 h-3.5 w-3.5" />
</Button>
)
}
if (item.workflowStatus === "prepublished") {
actions.push(
<Button key="publish" size="sm" variant="outline" onClick={() => publish(item)}>
<Upload className="mr-1 h-3.5 w-3.5" />
</Button>
)
}
if (item.workflowStatus === "published") {
actions.push(
<Button key="new-version" size="sm" variant="outline" onClick={() => createNewVersion(item)}>
<Pencil className="mr-1 h-3.5 w-3.5" />
</Button>
)
}
}
if (canApprove) {
if (item.workflowStatus === "pending") {
actions.push(
<Button key="approve" size="sm" variant="outline" onClick={() => approve(item)}>
<ShieldCheck className="mr-1 h-3.5 w-3.5" />
</Button>
)
actions.push(
<Button key="reject" size="sm" variant="outline" onClick={() => reject(item)}>
<XCircle className="mr-1 h-3.5 w-3.5" />
</Button>
)
}
if (workflowConfig.allowRecall && item.workflowStatus === "published") {
actions.push(
<Button key="recall" size="sm" variant="outline" onClick={() => recall(item)}>
<Undo2 className="mr-1 h-3.5 w-3.5" />
</Button>
)
}
if (item.workflowStatus === "recalled") {
actions.push(
<Button key="revive" size="sm" variant="outline" onClick={() => revive(item)}>
<Upload className="mr-1 h-3.5 w-3.5" />
</Button>
)
}
}
actions.push(
<Button key="detail" size="sm" variant="ghost" onClick={() => { setSelectedId(item.id); setViewMode("editor") }}>
<Eye className="mr-1 h-3.5 w-3.5" />
</Button>
)
return actions
}
if (viewMode === "editor" && selected) {
const highlights = selected.highlights
.split("\n")
.map((s) => s.trim())
.filter(Boolean)
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")}>
<ArrowLeft className="mr-1 h-3.5 w-3.5" />
</Button>
<div>
<h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground"></p>
</div>
</div>
<Badge variant="outline" className={workflowBadgeClass(selected.workflowStatus)}>
{WORKFLOW_LABEL[selected.workflowStatus]}
</Badge>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<Card className="border-none shadow-sm bg-white">
<CardHeader>
<CardTitle className="text-lg font-bold"></CardTitle>
<CardDescription>MiniAppContent</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2">
<label className="text-xs font-semibold tracking-wide text-muted-foreground uppercase"></label>
<Input value={selected.sourceCarName} disabled />
</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>
{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>
)}
<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>
<div className="flex gap-2 pt-2">
<Button variant="outline" disabled={!canEdit} onClick={() => saveDraft(selected)}>
<Pencil className="mr-1 h-3.5 w-3.5" />稿
</Button>
<Button disabled={!canEdit} onClick={() => submitForReview(selected)}>
<Send className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
<Card className="border-none shadow-sm bg-white">
<CardHeader>
<CardTitle className="text-lg font-bold"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
<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">
<img src={selected.imageUrls[0]} alt={selected.title} className="h-48 w-full object-cover" />
<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>
</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>
</CardContent>
</Card>
</div>
</div>
)
}
return (
<div className="flex flex-col gap-6 p-8">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground"></p>
</div>
</div>
<Card className="border-none shadow-sm bg-white">
<CardHeader>
<CardTitle className="text-lg font-bold"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Table>
<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="text-right font-bold text-audi-black"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{contents.map((item) => (
<TableRow key={item.id} className="hover:bg-gray-50/50 border-b border-gray-50 transition-colors">
<TableCell>
<p className="font-medium">{item.sourceCarName}</p>
<p className="text-xs text-muted-foreground">{item.sourceCarId}</p>
</TableCell>
<TableCell className="font-medium">{item.title}</TableCell>
<TableCell>
<Badge variant="outline" className={workflowBadgeClass(item.workflowStatus)}>
{WORKFLOW_LABEL[item.workflowStatus]}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
<p>{item.updatedAt}</p>
<p>{item.updatedBy}</p>
</TableCell>
<TableCell className="text-right">
<div className="flex flex-wrap justify-end gap-2">{renderActions(item)}</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,193 @@
import * as React from "react"
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
AreaChart,
Area
} from 'recharts';
import {
Activity,
Globe,
TrendingUp,
Users,
ArrowUpRight,
ArrowDownRight,
CheckCircle2,
AlertCircle
} from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
const data = [
{ name: '04-03', leads: 45 },
{ name: '04-04', leads: 52 },
{ name: '04-05', leads: 48 },
{ name: '04-06', leads: 61 },
{ name: '04-07', leads: 55 },
{ name: '04-08', leads: 67 },
{ name: '04-09', leads: 72 },
];
export function Dashboard() {
return (
<div className="flex flex-col gap-6 p-8">
<div className="flex flex-col gap-1">
<h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground"></p>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<Card className="border-none shadow-sm bg-white">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Red Note </CardTitle>
<Activity className="h-4 w-4 text-emerald-500" />
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
<div className="text-2xl font-bold"></div>
</div>
<p className="text-xs text-muted-foreground mt-1">
延迟: 24ms
</p>
</CardContent>
</Card>
<Card className="border-none shadow-sm bg-white">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Globe className="h-4 w-4 text-emerald-500" />
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
<div className="text-2xl font-bold"></div>
</div>
<p className="text-xs text-muted-foreground mt-1">
上次同步: 10分钟前
</p>
</CardContent>
</Card>
<Card className="border-none shadow-sm bg-white">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">+72</div>
<div className="flex items-center gap-1 text-xs text-emerald-500 mt-1">
<ArrowUpRight className="h-3 w-3" />
<span> 12%</span>
</div>
</CardContent>
</Card>
<Card className="border-none shadow-sm bg-white">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<CheckCircle2 className="h-4 w-4 text-audi-red" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">99.9%</div>
<p className="text-xs text-muted-foreground mt-1">
运行时间: 14天 2
</p>
</CardContent>
</Card>
</div>
<div className="grid gap-6 md:grid-cols-1 lg:grid-cols-7">
<Card className="lg:col-span-4 border-none shadow-sm bg-white">
<CardHeader>
<CardTitle className="text-lg font-bold"></CardTitle>
<CardDescription>7</CardDescription>
</CardHeader>
<CardContent className="pl-2">
<div className="h-[300px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data}>
<defs>
<linearGradient id="colorLeads" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#BB0A30" stopOpacity={0.1}/>
<stop offset="95%" stopColor="#BB0A30" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f0f0f0" />
<XAxis
dataKey="name"
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#888' }}
dy={10}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#888' }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#fff',
border: 'none',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)'
}}
/>
<Area
type="monotone"
dataKey="leads"
stroke="#BB0A30"
strokeWidth={2}
fillOpacity={1}
fill="url(#colorLeads)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card className="lg:col-span-3 border-none shadow-sm bg-white">
<CardHeader>
<CardTitle className="text-lg font-bold"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{[
{ title: "车型库自动同步完成", time: "2小时前", type: "success" },
{ title: "Red Note API 接口升级", time: "5小时前", type: "info" },
{ title: "发现 3 条重复潜客记录", time: "昨日", type: "warning" },
{ title: "合资公司官网连接重置", time: "2天前", type: "info" },
].map((item, i) => (
<div key={i} className="flex items-start gap-4">
<div className={`mt-1 h-2 w-2 rounded-full ${
item.type === 'success' ? 'bg-emerald-500' :
item.type === 'warning' ? 'bg-amber-500' : 'bg-blue-500'
}`} />
<div className="flex flex-col gap-1">
<p className="text-sm font-medium leading-none">{item.title}</p>
<p className="text-xs text-muted-foreground">{item.time}</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,118 @@
import * as React from "react"
import {
LayoutDashboard,
Car,
NotebookPen,
Users,
UserCog,
Settings,
LogOut,
ChevronRight
} from "lucide-react"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
} from "@/components/ui/sidebar"
const navItems = [
{
title: "仪表盘",
icon: LayoutDashboard,
id: "dashboard",
},
{
title: "车型库",
icon: Car,
id: "car-library",
},
{
title: "小程序内容库",
icon: NotebookPen,
id: "mini-content",
},
{
title: "潜客管理",
icon: Users,
id: "leads",
},
{
title: "用户与权限",
icon: UserCog,
id: "user-access",
},
{
title: "系统设置",
icon: Settings,
id: "settings",
},
]
export function AppSidebar({
activeTab,
setActiveTab
}: {
activeTab: string;
setActiveTab: (id: string) => void
}) {
return (
<Sidebar collapsible="icon">
<SidebarHeader className="border-b border-sidebar-border/50 py-6">
<div className="flex items-center gap-3 px-2">
<div className="flex h-8 w-8 items-center justify-center rounded-sm bg-audi-red text-white">
<span className="text-xs font-bold tracking-tighter">A</span>
</div>
<div className="flex flex-col overflow-hidden group-data-[collapsible=icon]:hidden">
<span className="text-sm font-bold tracking-[0.15em] uppercase truncate">Audi Portal</span>
<span className="text-[10px] text-muted-foreground tracking-widest uppercase truncate">Smart Operations</span>
</div>
</div>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden"></SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{navItems.map((item) => (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
tooltip={item.title}
isActive={activeTab === item.id}
onClick={() => setActiveTab(item.id)}
className="data-active:bg-audi-red/5 data-active:text-audi-red"
>
<item.icon />
<span>{item.title}</span>
{activeTab === item.id && (
<ChevronRight className="ml-auto size-3 opacity-50 group-data-[collapsible=icon]:hidden" />
)}
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="border-t border-sidebar-border/50 p-4">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton className="text-muted-foreground hover:text-foreground">
<LogOut />
<span>退</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
<SidebarRail />
</Sidebar>
)
}

View File

@@ -0,0 +1,206 @@
import * as React from "react"
import {
Download,
Eye,
EyeOff,
Search,
Filter,
MoreHorizontal,
User,
Phone,
MapPin,
Calendar
} from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
const LEADS = [
{ id: "1", name: "张先生", phone: "13812345678", city: "北京", model: "Audi A6L", date: "2024-04-09 14:20" },
{ id: "2", name: "李女士", phone: "15987654321", city: "上海", model: "Audi Q5L", date: "2024-04-09 13:15" },
{ id: "3", name: "王先生", phone: "13600001111", city: "广州", model: "Audi e-tron GT", date: "2024-04-09 11:45" },
{ id: "4", name: "赵先生", phone: "18855556666", city: "深圳", model: "Audi A4L", date: "2024-04-09 10:30" },
{ id: "5", name: "陈女士", phone: "13799998888", city: "杭州", model: "Audi Q3", date: "2024-04-08 17:20" },
{ id: "6", name: "孙先生", phone: "13511112222", city: "成都", model: "Audi A3 Sportback", date: "2024-04-08 16:10" },
{ id: "7", name: "周女士", phone: "13944445555", city: "南京", model: "Audi Q4 e-tron", date: "2024-04-08 14:50" },
{ id: "8", name: "吴先生", phone: "13166667777", city: "武汉", model: "Audi A8L", date: "2024-04-08 12:30" },
];
export function LeadManagement() {
const [showPhone, setShowPhone] = React.useState<Record<string, boolean>>({});
const [openActionLeadId, setOpenActionLeadId] = React.useState<string | null>(null);
const togglePhone = (id: string) => {
setShowPhone(prev => ({ ...prev, [id]: !prev[id] }));
};
const maskPhone = (phone: string) => {
return phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2");
};
const handleExport = () => {
// Simple alert for demo
alert("正在导出 8 条潜客数据为 CSV 文件...");
};
const handleLeadAction = (leadName: string, action: string) => {
alert(`${leadName} - ${action}`);
setOpenActionLeadId(null);
};
return (
<div className="flex flex-col gap-6 p-8">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground"></p>
</div>
<Button
onClick={handleExport}
variant="outline"
className="border-audi-black text-audi-black hover:bg-audi-black hover:text-white px-6"
>
<Download className="mr-2 h-4 w-4" />
CSV
</Button>
</div>
<Card className="border-none shadow-sm bg-white">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 w-full max-w-sm">
<Search className="h-4 w-4 text-muted-foreground absolute ml-3" />
<Input placeholder="搜索客户姓名或车型..." className="pl-9 bg-gray-50/50 border-none" />
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-9">
<Filter className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<Table>
<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="text-right font-bold text-audi-black"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{LEADS.map((lead) => (
<TableRow key={lead.id} className="hover:bg-gray-50/50 border-b border-gray-50 transition-colors">
<TableCell>
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-full bg-gray-100 flex items-center justify-center text-xs font-bold text-gray-600">
{lead.name[0]}
</div>
<span className="font-medium">{lead.name}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<span className="font-mono text-sm">
{showPhone[lead.id] ? lead.phone : maskPhone(lead.phone)}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-audi-red"
onClick={() => togglePhone(lead.id)}
>
{showPhone[lead.id] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</Button>
</div>
</TableCell>
<TableCell>
<Badge variant="secondary" className="bg-gray-100 text-gray-700 hover:bg-gray-200 border-none">
{lead.model}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
<div className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
{lead.city}
</div>
</TableCell>
<TableCell className="text-muted-foreground text-xs">
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{lead.date}
</div>
</TableCell>
<TableCell className="text-right relative">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setOpenActionLeadId((prev) => (prev === lead.id ? null : lead.id))}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
{openActionLeadId === lead.id && (
<div className="absolute right-2 top-9 z-20 w-44 rounded-md border bg-white p-1 shadow-lg whitespace-normal">
<button
className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-gray-100"
onClick={() => handleLeadAction(lead.name, "查看详情")}
>
</button>
<button
className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-gray-100"
onClick={() => handleLeadAction(lead.name, "更新跟进状态")}
>
</button>
<button
className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-gray-100"
onClick={() => handleLeadAction(lead.name, "分配销售顾问")}
>
</button>
<button
className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-gray-100"
onClick={() => handleLeadAction(lead.name, "添加备注")}
>
</button>
<button
className="block w-full rounded px-3 py-2 text-left text-sm text-rose-600 hover:bg-rose-50"
onClick={() => handleLeadAction(lead.name, "标记无效")}
>
</button>
</div>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,239 @@
import { Settings2 } from "lucide-react"
import type { RoleView, WorkflowConfig } from "@/App"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
type SystemSettingsProps = {
roleView: RoleView
onRoleViewChange: (role: RoleView) => void
workflowConfig: WorkflowConfig
onWorkflowConfigChange: (next: WorkflowConfig) => void
auditLogs: string[]
}
export function SystemSettings({
roleView,
onRoleViewChange,
workflowConfig,
onWorkflowConfigChange,
auditLogs,
}: SystemSettingsProps) {
return (
<div className="flex flex-col gap-6 p-8">
<div className="flex flex-col gap-1">
<h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground"></p>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<Card className="border-none shadow-sm bg-white">
<CardHeader>
<CardTitle className="text-lg font-bold"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<Button
variant={roleView === "content-ops" ? "default" : "outline"}
onClick={() => onRoleViewChange("content-ops")}
>
</Button>
<Button
variant={roleView === "biz-market" ? "default" : "outline"}
onClick={() => onRoleViewChange("biz-market")}
>
/
</Button>
<Button
variant={roleView === "pm-ops" ? "default" : "outline"}
onClick={() => onRoleViewChange("pm-ops")}
>
</Button>
</CardContent>
</Card>
<Card className="border-none shadow-sm bg-white">
<CardHeader>
<CardTitle className="text-lg font-bold"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex items-center justify-between rounded-lg border p-3">
<span></span>
<Button
size="sm"
variant={workflowConfig.enablePrePublish ? "default" : "outline"}
onClick={() =>
onWorkflowConfigChange({
...workflowConfig,
enablePrePublish: !workflowConfig.enablePrePublish,
})
}
>
{workflowConfig.enablePrePublish ? "已开启" : "已关闭"}
</Button>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<span></span>
<Button
size="sm"
variant={workflowConfig.allowRecall ? "default" : "outline"}
onClick={() =>
onWorkflowConfigChange({
...workflowConfig,
allowRecall: !workflowConfig.allowRecall,
})
}
>
{workflowConfig.allowRecall ? "已开启" : "已关闭"}
</Button>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<span></span>
<div className="flex gap-2">
<Button
size="sm"
variant={workflowConfig.publishStrategy === "manual" ? "default" : "outline"}
onClick={() =>
onWorkflowConfigChange({ ...workflowConfig, publishStrategy: "manual" })
}
>
</Button>
<Button
size="sm"
variant={workflowConfig.publishStrategy === "scheduled" ? "default" : "outline"}
onClick={() =>
onWorkflowConfigChange({ ...workflowConfig, publishStrategy: "scheduled" })
}
>
</Button>
</div>
</div>
{workflowConfig.publishStrategy === "scheduled" && (
<div className="grid gap-2 rounded-lg border p-3">
<span className="text-muted-foreground"></span>
<Input
type="time"
value={workflowConfig.defaultPublishTime}
onChange={(e) =>
onWorkflowConfigChange({
...workflowConfig,
defaultPublishTime: e.target.value,
})
}
/>
</div>
)}
</CardContent>
</Card>
<Card className="border-none shadow-sm bg-white">
<CardHeader>
<CardTitle className="text-lg font-bold"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="grid gap-2">
<span className="text-muted-foreground"></span>
<div className="flex gap-2">
<Button
size="sm"
variant={workflowConfig.syncFrequency === "daily" ? "default" : "outline"}
onClick={() =>
onWorkflowConfigChange({ ...workflowConfig, syncFrequency: "daily" })
}
>
</Button>
<Button
size="sm"
variant={workflowConfig.syncFrequency === "weekly" ? "default" : "outline"}
onClick={() =>
onWorkflowConfigChange({ ...workflowConfig, syncFrequency: "weekly" })
}
>
</Button>
</div>
</div>
<div className="grid gap-2">
<span className="text-muted-foreground"></span>
<Input
type="time"
value={workflowConfig.syncExecutionTime}
onChange={(e) =>
onWorkflowConfigChange({
...workflowConfig,
syncExecutionTime: e.target.value,
})
}
/>
</div>
<div className="grid gap-2">
<span className="text-muted-foreground"></span>
<Input
type="number"
min={0}
max={5}
value={workflowConfig.retryCount}
onChange={(e) =>
onWorkflowConfigChange({
...workflowConfig,
retryCount: Number(e.target.value || 0),
})
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<span></span>
<Button
size="sm"
variant={workflowConfig.manualConfirmSync ? "default" : "outline"}
onClick={() =>
onWorkflowConfigChange({
...workflowConfig,
manualConfirmSync: !workflowConfig.manualConfirmSync,
})
}
>
{workflowConfig.manualConfirmSync ? "需要确认" : "自动通过"}
</Button>
</div>
<p className="text-xs text-muted-foreground">
</p>
</CardContent>
</Card>
<Card className="border-none shadow-sm bg-white">
<CardHeader>
<CardTitle className="text-lg font-bold"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-audi-red" />
<span className="text-sm font-medium"> 12 </span>
<Badge variant="outline" className="ml-auto">
{auditLogs.length}
</Badge>
</div>
<div className="space-y-2">
{auditLogs.map((item, idx) => (
<div key={`${item}-${idx}`} className="rounded-lg border px-3 py-2 text-sm text-muted-foreground">
{item}
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,27 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({ className, variant, ...props }: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return <span data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn("bg-card text-card-foreground rounded-xl border shadow-sm", className)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"h3">) {
return (
<h3
data-slot="card-title"
className={cn("leading-none font-semibold tracking-tight", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("p-6 pt-0", className)} {...props} />
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
)
}
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,31 @@
import * as React from "react"
import { cn } from "@/lib/utils"
type ProgressProps = React.ComponentProps<"div"> & {
value?: number
}
function Progress({ className, value = 0, ...props }: ProgressProps) {
const clamped = Math.max(0, Math.min(100, value))
return (
<div
data-slot="progress"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(clamped)}
className={cn("bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", className)}
{...props}
>
<div
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-transform"
style={{ transform: `translateX(-${100 - clamped}%)` }}
/>
</div>
)
}
export { Progress }

View File

@@ -0,0 +1,23 @@
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,136 @@
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,723 @@
"use client"
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { PanelLeftIcon } from "lucide-react"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
render,
...props
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-label",
sidebar: "group-label",
},
})
}
function SidebarGroupAction({
className,
render,
...props
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-action",
sidebar: "group-action",
},
})
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
render,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar()
const comp = useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
},
props
),
render: !tooltip ? render : <TooltipTrigger render={render} />,
state: {
slot: "sidebar-menu-button",
sidebar: "menu-button",
size,
active: isActive,
},
})
if (!tooltip) {
return comp
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
{comp}
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
render,
showOnHover = false,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
showOnHover?: boolean
}) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-menu-action",
sidebar: "menu-action",
},
})
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
})
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
render,
size = "md",
isActive = false,
className,
...props
}: useRender.ComponentProps<"a"> &
React.ComponentProps<"a"> & {
size?: "sm" | "md"
isActive?: boolean
}) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className
),
},
props
),
render,
state: {
slot: "sidebar-menu-sub-button",
sidebar: "menu-sub-button",
size,
active: isActive,
},
})
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -0,0 +1,15 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,105 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div className="relative w-full overflow-x-auto">
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,66 @@
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,288 @@
import * as React from "react"
import { ShieldCheck, UserCog, Users } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
type UserStatus = "enabled" | "disabled"
type UserRecord = {
id: string
name: string
account: string
role: "业务/市场负责人" | "内容运营专员" | "项目与运维负责人"
status: UserStatus
lastLogin: string
}
type PermissionKey =
| "dashboard.view"
| "source.sync"
| "content.edit"
| "content.approve"
| "content.publish"
| "leads.manage"
| "settings.manage"
| "users.manage"
type RolePermission = {
roleName: "业务/市场负责人" | "内容运营专员" | "项目与运维负责人"
permissions: PermissionKey[]
}
const INITIAL_USERS: UserRecord[] = [
{
id: "u1",
name: "陈经理",
account: "market.chen",
role: "业务/市场负责人",
status: "enabled",
lastLogin: "2026-04-12 09:18",
},
{
id: "u2",
name: "刘运营",
account: "content.liu",
role: "内容运营专员",
status: "enabled",
lastLogin: "2026-04-12 08:42",
},
{
id: "u3",
name: "王运维",
account: "ops.wang",
role: "项目与运维负责人",
status: "enabled",
lastLogin: "2026-04-11 20:05",
},
{
id: "u4",
name: "赵测试",
account: "qa.zhao",
role: "内容运营专员",
status: "disabled",
lastLogin: "2026-04-01 11:20",
},
]
const PERMISSION_LABEL: Record<PermissionKey, string> = {
"dashboard.view": "查看仪表盘",
"source.sync": "车型同步",
"content.edit": "内容编辑",
"content.approve": "内容审核",
"content.publish": "内容发布",
"leads.manage": "潜客管理",
"settings.manage": "系统设置",
"users.manage": "用户与权限管理",
}
const INITIAL_ROLE_PERMISSIONS: RolePermission[] = [
{
roleName: "业务/市场负责人",
permissions: [
"dashboard.view",
"content.approve",
"content.publish",
"leads.manage",
],
},
{
roleName: "内容运营专员",
permissions: [
"dashboard.view",
"source.sync",
"content.edit",
"content.publish",
"leads.manage",
],
},
{
roleName: "项目与运维负责人",
permissions: [
"dashboard.view",
"source.sync",
"settings.manage",
"users.manage",
],
},
]
const PERMISSION_COLUMNS: PermissionKey[] = [
"dashboard.view",
"source.sync",
"content.edit",
"content.approve",
"content.publish",
"leads.manage",
"settings.manage",
"users.manage",
]
export function UserAccessManagement() {
const [query, setQuery] = React.useState("")
const [users, setUsers] = React.useState<UserRecord[]>(INITIAL_USERS)
const [rolePermissions, setRolePermissions] = React.useState<RolePermission[]>(INITIAL_ROLE_PERMISSIONS)
const filteredUsers = users.filter((user) => {
const key = `${user.name} ${user.account} ${user.role}`.toLowerCase()
return key.includes(query.toLowerCase())
})
const toggleUserStatus = (id: string) => {
setUsers((prev) =>
prev.map((user) =>
user.id === id
? { ...user, status: user.status === "enabled" ? "disabled" : "enabled" }
: user
)
)
}
const togglePermission = (roleName: RolePermission["roleName"], permission: PermissionKey) => {
setRolePermissions((prev) =>
prev.map((role) => {
if (role.roleName !== roleName) return role
const has = role.permissions.includes(permission)
return {
...role,
permissions: has
? role.permissions.filter((item) => item !== permission)
: [...role.permissions, permission],
}
})
)
}
return (
<div className="flex flex-col gap-6 p-8">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground"></p>
</div>
<Button className="bg-audi-black hover:bg-audi-dark-gray text-white px-6"></Button>
</div>
<Card className="border-none shadow-sm bg-white">
<CardHeader>
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="text-lg font-bold"></CardTitle>
<CardDescription></CardDescription>
</div>
<div className="w-full max-w-xs">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索姓名、账号或角色..."
/>
</div>
</div>
</CardHeader>
<CardContent>
<Table>
<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="text-right font-bold text-audi-black"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredUsers.map((user) => (
<TableRow key={user.id} className="hover:bg-gray-50/50 border-b border-gray-50 transition-colors">
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell className="text-muted-foreground">{user.account}</TableCell>
<TableCell>
<Badge variant="outline">{user.role}</Badge>
</TableCell>
<TableCell>
<Badge
variant="outline"
className={
user.status === "enabled"
? "bg-emerald-50 text-emerald-700 border-emerald-200"
: "bg-zinc-100 text-zinc-700 border-zinc-200"
}
>
{user.status === "enabled" ? "启用" : "停用"}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{user.lastLogin}</TableCell>
<TableCell className="text-right">
<div className="inline-flex gap-2">
<Button size="sm" variant="outline">
</Button>
<Button size="sm" variant="outline" onClick={() => toggleUserStatus(user.id)}>
{user.status === "enabled" ? "停用" : "启用"}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Card className="border-none shadow-sm bg-white">
<CardHeader>
<CardTitle className="text-lg font-bold"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="mb-3 flex items-center gap-2 text-sm text-muted-foreground">
<Users className="h-4 w-4" />
<UserCog className="h-4 w-4 ml-4" />
<ShieldCheck className="h-4 w-4 ml-4 text-audi-red" />
</div>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent border-b border-gray-100">
<TableHead className="font-bold text-audi-black"></TableHead>
{PERMISSION_COLUMNS.map((permission) => (
<TableHead key={permission} className="font-bold text-audi-black text-xs">
{PERMISSION_LABEL[permission]}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rolePermissions.map((role) => (
<TableRow key={role.roleName} className="hover:bg-gray-50/50 border-b border-gray-50 transition-colors">
<TableCell className="font-medium">{role.roleName}</TableCell>
{PERMISSION_COLUMNS.map((permission) => {
const checked = role.permissions.includes(permission)
return (
<TableCell key={`${role.roleName}-${permission}`}>
<input
type="checkbox"
checked={checked}
onChange={() => togglePermission(role.roleName, permission)}
/>
</TableCell>
)
})}
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View File

@@ -0,0 +1,154 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--color-audi-red: #BB0A30;
--color-audi-black: #000000;
--color-audi-silver: #F0F0F0;
--color-audi-dark-gray: #333333;
}
@layer base {
body {
@apply font-sans antialiased bg-background text-foreground;
}
* {
@apply border-border outline-ring/50;
}
html {
@apply font-sans;
}
}
.audi-button {
@apply px-6 py-3 bg-audi-black text-white font-medium uppercase tracking-wider transition-all duration-300 hover:bg-audi-dark-gray active:scale-95 disabled:opacity-50 disabled:pointer-events-none;
}
.audi-button-outline {
@apply px-6 py-3 border border-audi-black text-audi-black font-medium uppercase tracking-wider transition-all duration-300 hover:bg-audi-black hover:text-white active:scale-95;
}
.audi-input {
@apply w-full px-4 py-3 border-b border-gray-300 focus:border-audi-black outline-none transition-colors duration-300 bg-transparent;
}
.audi-card {
@apply bg-white border border-gray-100 overflow-hidden transition-all duration-500 hover:shadow-2xl hover:-translate-y-1;
}
@theme inline {
--font-heading: var(--font-sans);
--font-sans: 'Geist Variable', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,13 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
import { TooltipProvider } from "@/components/ui/tooltip";
createRoot(document.getElementById('root')!).render(
<StrictMode>
<TooltipProvider>
<App />
</TooltipProvider>
</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": {
"@/*": [
"./src/*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

View File

@@ -0,0 +1,29 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { defineConfig, loadEnv } from 'vite';
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
// 手动定义 __dirname 以确保在 Windows 下的兼容性
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
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: {
// 使用 resolve 确保路径在 Windows/Linux 下都能正确解析
'@': resolve(__dirname, './src'),
},
},
server: {
port: 3000,
host: '0.0.0.0',
},
};
});

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-app/.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/546f4d9a-c8ab-40ae-a5f0-bdd95abfca93
## 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,5 @@
{
"name": "Audi Red Note Mini App",
"description": "A high-fidelity Audi brand experience for car exploration and lead generation.",
"requestFramePermissions": []
}

3992
audi-red-note-mini-app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

View File

@@ -0,0 +1,463 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, FormEvent } from "react";
import { motion, AnimatePresence } from "motion/react";
import {
ChevronRight,
X,
Check,
Phone,
User,
MapPin,
ShieldCheck,
ArrowLeft,
Loader2
} from "lucide-react";
// --- Types ---
interface CarModel {
id: string;
name: string;
price: string;
image: string;
tagline: string;
}
const CAR_MODELS: CarModel[] = [
{
id: "a3",
name: "Audi A3 Sportback",
price: "203,100",
image: "https://images.unsplash.com/photo-1606152421802-db97b9c7a11b?auto=format&fit=crop&q=80&w=800",
tagline: "进取,不负期待"
},
{
id: "a4l",
name: "Audi A4L",
price: "321,800",
image: "https://images.unsplash.com/photo-1614162692292-7ac56d7f7f1e?auto=format&fit=crop&q=80&w=800",
tagline: "做更强大的自己"
},
{
id: "a6l",
name: "Audi A6L",
price: "427,900",
image: "https://images.unsplash.com/photo-1541348263662-e0c86433610a?auto=format&fit=crop&q=80&w=800",
tagline: "懂你,更懂未来"
},
{
id: "q3",
name: "Audi Q3",
price: "279,800",
image: "https://images.unsplash.com/photo-1533473359331-0135ef1b58bf?auto=format&fit=crop&q=80&w=800",
tagline: "活出生命的辽阔"
},
{
id: "q5l",
name: "Audi Q5L",
price: "396,800",
image: "https://images.unsplash.com/photo-1566473065146-d215f068671b?auto=format&fit=crop&q=80&w=800",
tagline: "自由,由我定义"
},
{
id: "etron-gt",
name: "Audi e-tron GT",
price: "999,800",
image: "https://images.unsplash.com/photo-1617469767053-d3b523a0b982?auto=format&fit=crop&q=80&w=800",
tagline: "静谧,亦能澎湃"
}
];
const CITIES = ["北京", "上海", "广州", "深圳", "杭州", "成都", "南京", "武汉"];
// --- Components ---
const Header = ({ onBack, showBack }: { onBack?: () => void; showBack?: boolean }) => (
<header className="sticky top-0 z-40 bg-white/80 backdrop-blur-md border-b border-gray-100 px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
{showBack && (
<button onClick={onBack} className="p-1 -ml-2 hover:bg-gray-100 rounded-full transition-colors">
<ArrowLeft size={24} />
</button>
)}
<div className="flex flex-col">
<span className="text-xl font-bold tracking-[0.2em] uppercase">Audi</span>
<span className="text-[10px] text-gray-400 tracking-widest uppercase -mt-1">Vorsprung durch Technik</span>
</div>
</div>
<div className="flex gap-4">
<div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
<User size={16} className="text-gray-600" />
</div>
</div>
</header>
);
const PIPLPopup = ({ onAccept }: { onAccept: () => void }) => (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-6 bg-black/60 backdrop-blur-sm"
>
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
className="bg-white w-full max-w-md p-8 shadow-2xl"
>
<div className="flex items-center gap-3 mb-6">
<ShieldCheck className="text-audi-red" size={32} />
<h2 className="text-2xl font-bold tracking-tight"></h2>
</div>
<div className="space-y-4 text-gray-600 text-sm leading-relaxed mb-8">
<p>
访PIPL
</p>
<p>
<b></b>
</p>
<p>
</p>
</div>
<button
onClick={onAccept}
className="audi-button w-full flex items-center justify-center gap-2"
>
<ChevronRight size={18} />
</button>
<p className="text-center text-[10px] text-gray-400 mt-4 uppercase tracking-widest">
Audi Privacy Compliance
</p>
</motion.div>
</motion.div>
);
export default function App() {
const [currentPage, setCurrentPage] = useState<"gallery" | "form">("gallery");
const [selectedCar, setSelectedCar] = useState<CarModel | null>(null);
const [showPIPL, setShowPIPL] = useState(true);
const [piplAccepted, setPiplAccepted] = useState(false);
// Form State
const [formData, setFormData] = useState({
name: "",
phone: "",
city: "",
agreed: false
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const handleLearnMore = (car: CarModel) => {
setSelectedCar(car);
setCurrentPage("form");
window.scrollTo({ top: 0, behavior: "smooth" });
};
const handleBack = () => {
setCurrentPage("gallery");
setIsSuccess(false);
};
const validateForm = () => {
const newErrors: Record<string, string> = {};
if (!formData.name.trim()) newErrors.name = "请输入姓名";
if (!/^1[3-9]\d{9}$/.test(formData.phone)) newErrors.phone = "请输入有效的手机号";
if (!formData.city) newErrors.city = "请选择城市";
if (!formData.agreed) newErrors.agreed = "请阅读并同意隐私政策";
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
setIsSubmitting(true);
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 2000));
setIsSubmitting(false);
setIsSuccess(true);
};
return (
<div className="min-h-screen bg-white selection:bg-audi-red selection:text-white">
<AnimatePresence>
{showPIPL && <PIPLPopup onAccept={() => { setShowPIPL(false); setPiplAccepted(true); }} />}
</AnimatePresence>
<Header
showBack={currentPage === "form"}
onBack={handleBack}
/>
<main className="max-w-screen-xl mx-auto">
<AnimatePresence mode="wait">
{currentPage === "gallery" ? (
<motion.section
key="gallery"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="px-6 py-12"
>
<div className="mb-12">
<h1 className="text-4xl md:text-6xl font-black tracking-tighter uppercase mb-4">
</h1>
<p className="text-gray-500 max-w-2xl text-lg">
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{CAR_MODELS.map((car) => (
<motion.div
key={car.id}
layoutId={car.id}
className="audi-card group"
>
<div className="relative aspect-[16/10] overflow-hidden">
<img
src={car.image}
alt={car.name}
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
referrerPolicy="no-referrer"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
</div>
<div className="p-6">
<div className="flex justify-between items-start mb-2">
<h3 className="text-xl font-bold tracking-tight">{car.name}</h3>
<span className="text-audi-red font-mono text-sm">CNY {car.price} </span>
</div>
<p className="text-gray-400 text-sm mb-6">{car.tagline}</p>
<button
onClick={() => handleLearnMore(car)}
className="audi-button-outline w-full flex items-center justify-center gap-2 group/btn"
>
<ChevronRight size={18} className="transition-transform group-hover/btn:translate-x-1" />
</button>
</div>
</motion.div>
))}
</div>
</motion.section>
) : (
<motion.section
key="form"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="px-6 py-12 lg:flex lg:gap-16 lg:items-start"
>
{/* Hero Image Section */}
<div className="lg:w-1/2 mb-12 lg:mb-0 lg:sticky lg:top-32">
<div className="relative rounded-lg overflow-hidden shadow-2xl">
<img
src={selectedCar?.image}
alt={selectedCar?.name}
className="w-full aspect-video object-cover"
referrerPolicy="no-referrer"
/>
<div className="absolute bottom-0 left-0 right-0 p-8 bg-gradient-to-t from-black/80 to-transparent text-white">
<h2 className="text-3xl font-bold mb-2">{selectedCar?.name}</h2>
<p className="text-gray-300 opacity-80">{selectedCar?.tagline}</p>
</div>
</div>
<div className="mt-8 grid grid-cols-3 gap-4">
{[1, 2, 3].map(i => (
<div key={i} className="aspect-square bg-gray-100 rounded overflow-hidden">
<img
src={`https://picsum.photos/seed/audi-${selectedCar?.id}-${i}/400/400`}
alt="Detail"
className="w-full h-full object-cover opacity-60 hover:opacity-100 transition-opacity"
referrerPolicy="no-referrer"
/>
</div>
))}
</div>
</div>
{/* Form Section */}
<div className="lg:w-1/2 max-w-md">
{isSuccess ? (
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="text-center py-12"
>
<div className="w-20 h-20 bg-green-50 text-green-500 rounded-full flex items-center justify-center mx-auto mb-6">
<Check size={40} />
</div>
<h2 className="text-3xl font-bold mb-4"></h2>
<p className="text-gray-500 mb-8">
24
</p>
<button onClick={handleBack} className="audi-button w-full">
</button>
</motion.div>
) : (
<>
<div className="mb-10">
<h2 className="text-3xl font-bold tracking-tight mb-2"></h2>
<p className="text-gray-500"></p>
</div>
<form onSubmit={handleSubmit} className="space-y-8">
<div className="space-y-1">
<label className="text-[10px] uppercase tracking-widest text-gray-400 font-bold"></label>
<div className="relative">
<User className="absolute left-0 top-1/2 -translate-y-1/2 text-gray-300" size={18} />
<input
type="text"
placeholder="请输入您的真实姓名"
className={`audi-input pl-8 ${errors.name ? 'border-audi-red' : ''}`}
value={formData.name}
onChange={(e) => setFormData({...formData, name: e.target.value})}
/>
</div>
{errors.name && <p className="text-audi-red text-xs mt-1">{errors.name}</p>}
</div>
<div className="space-y-1">
<label className="text-[10px] uppercase tracking-widest text-gray-400 font-bold"></label>
<div className="relative">
<Phone className="absolute left-0 top-1/2 -translate-y-1/2 text-gray-300" size={18} />
<input
type="tel"
placeholder="1xx xxxx xxxx"
className={`audi-input pl-8 ${errors.phone ? 'border-audi-red' : ''}`}
value={formData.phone}
onChange={(e) => setFormData({...formData, phone: e.target.value})}
/>
</div>
{errors.phone && <p className="text-audi-red text-xs mt-1">{errors.phone}</p>}
</div>
<div className="space-y-1">
<label className="text-[10px] uppercase tracking-widest text-gray-400 font-bold"></label>
<div className="relative">
<MapPin className="absolute left-0 top-1/2 -translate-y-1/2 text-gray-300" size={18} />
<select
className={`audi-input pl-8 appearance-none ${errors.city ? 'border-audi-red' : ''}`}
value={formData.city}
onChange={(e) => setFormData({...formData, city: e.target.value})}
>
<option value=""></option>
{CITIES.map(city => <option key={city} value={city}>{city}</option>)}
</select>
</div>
{errors.city && <p className="text-audi-red text-xs mt-1">{errors.city}</p>}
</div>
<div className="flex items-start gap-3">
<div className="relative flex items-center h-5">
<input
id="pipl"
type="checkbox"
className="w-4 h-4 text-audi-black border-gray-300 rounded focus:ring-audi-black"
checked={formData.agreed}
onChange={(e) => setFormData({...formData, agreed: e.target.checked})}
/>
</div>
<div className="text-xs text-gray-500 leading-relaxed">
<label htmlFor="pipl">
<span className="text-audi-black underline cursor-pointer"></span> <span className="text-audi-black underline cursor-pointer"></span>
</label>
{errors.agreed && <p className="text-audi-red text-xs mt-1">{errors.agreed}</p>}
</div>
</div>
<button
type="submit"
disabled={isSubmitting}
className="audi-button w-full flex items-center justify-center gap-3 h-14"
>
{isSubmitting ? (
<>
<Loader2 className="animate-spin" size={20} />
...
</>
) : (
<>
<ChevronRight size={20} />
</>
)}
</button>
</form>
</>
)}
</div>
</motion.section>
)}
</AnimatePresence>
</main>
<footer className="bg-audi-black text-white py-16 px-6 mt-20">
<div className="max-w-screen-xl mx-auto flex flex-col md:flex-row justify-between items-start gap-12">
<div className="space-y-6">
<div className="flex flex-col">
<span className="text-3xl font-bold tracking-[0.3em] uppercase">Audi</span>
<span className="text-xs text-gray-500 tracking-[0.5em] uppercase">Vorsprung durch Technik</span>
</div>
<p className="text-gray-500 text-sm max-w-xs">
</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-12">
<div className="space-y-4">
<h4 className="text-xs font-bold uppercase tracking-widest"></h4>
<ul className="text-gray-500 text-sm space-y-2">
<li>轿</li>
<li>SUV</li>
<li>e-tron </li>
<li>Audi Sport</li>
</ul>
</div>
<div className="space-y-4">
<h4 className="text-xs font-bold uppercase tracking-widest"></h4>
<ul className="text-gray-500 text-sm space-y-2">
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
</div>
<div className="space-y-4">
<h4 className="text-xs font-bold uppercase tracking-widest"></h4>
<ul className="text-gray-500 text-sm space-y-2">
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
</div>
</div>
</div>
<div className="max-w-screen-xl mx-auto mt-20 pt-8 border-t border-white/10 flex flex-col md:flex-row justify-between gap-6 text-[10px] text-gray-600 uppercase tracking-widest">
<p>© 2026 </p>
<div className="flex gap-6">
<span className="hover:text-white cursor-pointer transition-colors"></span>
<span className="hover:text-white cursor-pointer transition-colors"></span>
<span className="hover:text-white cursor-pointer transition-colors">ICP备00000000号</span>
</div>
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,31 @@
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--color-audi-red: #BB0A30;
--color-audi-black: #000000;
--color-audi-silver: #F0F0F0;
--color-audi-dark-gray: #333333;
}
@layer base {
body {
@apply bg-white text-audi-black font-sans antialiased;
}
}
.audi-button {
@apply px-6 py-3 bg-audi-black text-white font-medium uppercase tracking-wider transition-all duration-300 hover:bg-audi-dark-gray active:scale-95 disabled:opacity-50 disabled:pointer-events-none;
}
.audi-button-outline {
@apply px-6 py-3 border border-audi-black text-audi-black font-medium uppercase tracking-wider transition-all duration-300 hover:bg-audi-black hover:text-white active:scale-95;
}
.audi-input {
@apply w-full px-4 py-3 border-b border-gray-300 focus:border-audi-black outline-none transition-colors duration-300 bg-transparent;
}
.audi-card {
@apply bg-white border border-gray-100 overflow-hidden transition-all duration-500 hover:shadow-2xl hover:-translate-y-1;
}

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',
},
};
});

View File

@@ -0,0 +1,52 @@
# 奥迪小红书小程序门户:角色职责与 User Journey
## 1. 角色与职责
| 角色 | 主要职责 | 关键系统触点 | 关键输出 |
|---|---|---|---|
| 奥迪业务/市场负责人 | 制定内容方向与优先级、审核关键内容与合规性、确认月度运营结果与改进项 | 审核流程、月度报告评审、问题升级通道 | 审核结论、优先级决策、改进要求 |
| 内容运营专员(供应商/联合团队) | 按周同步 JV 官网站点车型图片、执行内容上架/下架 | 内容同步任务、素材管理、发布窗口 | 同步任务结果、素材更新记录 |
| 供应商项目与运维负责人 | 统筹项目计划、风险管理、周会/月报、跨团队沟通,保障门户可用性并负责监控告警、备份恢复与故障排查 | 项目状态看板、风险台账、会议机制、监控平台、日志系统、备份与回滚机制 | 周会纪要、月报、事件报告、故障处理记录、备份结果 |
| 终端用户(小红书用户) | 浏览车型信息、填写并提交联系表单 | 小程序门户页面、表单提交页、感谢页 | 留资数据、用户行为数据 |
## 2. User Journey按角色
### 2.1 终端用户(潜客)
1. 在小红书浏览笔记并点击外链进入小程序门户。
2. 浏览车型信息、图片与关键信息。
3. 对感兴趣车型填写联系信息表单并提交。
4. 系统执行前端与后端校验。
5. 校验成功:显示感谢页并写入数据库;校验失败:提示错误并允许修正后重提。
### 2.2 奥迪业务/市场负责人
1. 接收待审核内容或关键变更通知。
2. 从品牌一致性、合规性、业务目标角度完成审核。
3. 对异常或高优先级事项进行决策与升级。
4. 在月度评审中确认 KPI、问题闭环与下阶段计划。
### 2.3 内容运营专员
1. 按周执行与 JV 官网车型图片同步任务。
2. 校验素材完整性、清晰度和品牌规范一致性。
3. 对同步失败项进行重试与人工修复。
4. 记录每次同步结果并提交运营周报输入。
### 2.4 供应商项目与运维负责人
1. 制定项目里程碑与周计划,维护风险与问题清单。
2. 组织周会,跟踪需求、进度、质量和依赖项。
3. 持续监控应用、接口、数据库与云资源状态。
4. 在告警触发后按优先级组织并执行响应流程。
5. 工作日 08:30-17:30 提供服务支持,非服务时段待命;周末及法定节假日对 Critical/High 提供 7x24 待命。
6. 定期执行备份校验,确保源环境保留不少于 1 个月,并输出月报与事件报告。
## 3. 关键跨角色协作链路
1. 内容运营专员发起内容变更 -> 业务/市场负责人审核 -> 发布上线 -> 终端用户浏览与留资。
2. 内容运营专员执行周更同步 -> 业务/市场负责人确认品牌一致性。
3. 项目与运维负责人监控告警并组织响应 -> 业务/市场负责人获知关键事件 -> 输出事件报告与改进项。
## 4. 建议的验收观察点
1. 每个角色是否有明确可执行动作与系统入口。
2. User Journey 是否覆盖主路径与错误路径(表单校验失败、同步失败、故障响应)。
3. 周会、月报、事件报告、知识转移是否有对应责任人和产出物。
4. 值班与待命机制是否可被审计(时间、响应记录、处理结果)。