From d8cbf39ea0d196c6033f4ec51185bdeba85ef7b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B4=94=E5=A4=A7=E6=9D=83?= Date: Sun, 27 Apr 2025 20:48:58 +0800 Subject: [PATCH 1/2] init --- move202503/cuidaquan/README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 move202503/cuidaquan/README.md diff --git a/move202503/cuidaquan/README.md b/move202503/cuidaquan/README.md new file mode 100644 index 00000000..59f48fb4 --- /dev/null +++ b/move202503/cuidaquan/README.md @@ -0,0 +1,11 @@ +## project +- 项目名称:NFT Billboard +> 描述: NFT Billboard是一个革命性的区块链广告解决方案,将虚拟世界中的广告位转化为可交易的NFT资产。我们利用Sui区块链和Walrus存储网络,实现广告内容的动态更新,适配链游、元宇宙等多种虚拟场景。 + + +## Member +- 崔大权 github: https://github.com/cuidaquan +> 自我介绍&技术栈: 十余年web2开发经验,对Move特别感兴趣,想通过Move入门区块链。技术栈:java, 前端, nodejs, sui move + + + From 1178a8b2fd46db8ae7dd7906180bfa6cd837ddfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B4=94=E5=A4=A7=E6=9D=83?= Date: Mon, 5 May 2025 17:07:28 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E6=AD=A3=E5=BC=8F=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- move202503/cuidaquan/nft-billboard/.gitignore | 1 + .../nft-billboard/NFT_Billboard_Pitchdeck.md | 82 + move202503/cuidaquan/nft-billboard/README.md | 229 ++ .../nft-billboard/nft_billboard/.gitignore | 2 + .../nft-billboard/nft_billboard/Move.toml | 11 + .../nft-billboard/nft_billboard/README.md | 221 ++ .../nft_billboard/sources/ad_space.move | 228 ++ .../nft_billboard/sources/factory.move | 318 +++ .../nft_billboard/sources/nft.move | 236 ++ .../nft_billboard/sources/nft_billboard.move | 366 ++++ .../tests/nft_billboard_tests.move | 875 ++++++++ .../nft_billboard_web/.env.development | 16 + .../nft_billboard_web/.env.production | 17 + .../nft_billboard_web/.gitignore | 19 + .../nft-billboard/nft_billboard_web/.npmrc | 3 + .../nft-billboard/nft_billboard_web/README.md | 172 ++ .../nft_billboard_web/babel.config.js | 8 + .../nft_billboard_web/config-overrides.js | 37 + .../nft_billboard_web/package.json | 80 + .../nft_billboard_web/public/favicon.ico | Bin 0 -> 3870 bytes .../nft_billboard_web/public/index.html | 44 + .../nft_billboard_web/public/logo.svg | 76 + .../nft_billboard_web/public/logo192.png | Bin 0 -> 5347 bytes .../nft_billboard_web/public/logo512.png | Bin 0 -> 9664 bytes .../nft_billboard_web/public/manifest.json | 30 + .../nft_billboard_web/public/robots.txt | 3 + .../nft_billboard_web/src/App.css | 38 + .../nft_billboard_web/src/App.scss | 274 +++ .../nft_billboard_web/src/App.tsx | 81 + .../nft_billboard_web/src/assets/logo.svg | 76 + .../src/components/adSpace/AdSpaceCard.scss | 291 +++ .../src/components/adSpace/AdSpaceCard.tsx | 283 +++ .../src/components/adSpace/AdSpaceForm.scss | 264 +++ .../src/components/adSpace/AdSpaceForm.tsx | 502 +++++ .../src/components/adSpace/AdSpaceItem.scss | 183 ++ .../src/components/adSpace/AdSpaceItem.tsx | 211 ++ .../src/components/common/ConnectWallet.scss | 46 + .../src/components/common/ConnectWallet.tsx | 169 ++ .../components/common/LanguageSwitcher.scss | 28 + .../components/common/LanguageSwitcher.tsx | 49 + .../src/components/layout/Footer.scss | 42 + .../src/components/layout/Footer.tsx | 25 + .../src/components/layout/Header.scss | 176 ++ .../src/components/layout/Header.tsx | 98 + .../src/components/layout/MainLayout.scss | 124 ++ .../src/components/layout/MainLayout.tsx | 27 + .../src/components/nft/MediaContent.scss | 57 + .../src/components/nft/MediaContent.tsx | 203 ++ .../src/components/nft/NFTCard.scss | 216 ++ .../src/components/nft/NFTCard.tsx | 189 ++ .../src/components/nft/UpdateAdContent.scss | 56 + .../src/components/nft/UpdateAdContent.tsx | 587 +++++ .../src/components/walrus/WalrusUpload.scss | 137 ++ .../src/components/walrus/WalrusUpload.tsx | 806 +++++++ .../nft_billboard_web/src/config/config.ts | 91 + .../nft_billboard_web/src/config/constants.ts | 46 + .../src/config/walrusConfig.ts | 91 + .../src/hooks/useTransaction.ts | 147 ++ .../src/hooks/useWalletTransaction.ts | 118 + .../nft_billboard_web/src/i18n/i18n.ts | 40 + .../src/i18n/locales/en.json | 596 ++++++ .../src/i18n/locales/zh.json | 596 ++++++ .../nft_billboard_web/src/index.css | 13 + .../nft_billboard_web/src/index.tsx | 27 + .../nft_billboard_web/src/logo.svg | 1 + .../src/pages/AdSpaceDetail.scss | 826 +++++++ .../src/pages/AdSpaceDetail.tsx | 580 +++++ .../nft_billboard_web/src/pages/AdSpaces.scss | 343 +++ .../nft_billboard_web/src/pages/AdSpaces.tsx | 201 ++ .../nft_billboard_web/src/pages/Home.scss | 646 ++++++ .../nft_billboard_web/src/pages/Home.tsx | 206 ++ .../nft_billboard_web/src/pages/Manage.scss | 761 +++++++ .../nft_billboard_web/src/pages/Manage.tsx | 1467 +++++++++++++ .../nft_billboard_web/src/pages/MyNFTs.scss | 302 +++ .../nft_billboard_web/src/pages/MyNFTs.tsx | 262 +++ .../src/pages/NFTDetail.scss | 325 +++ .../nft_billboard_web/src/pages/NFTDetail.tsx | 886 ++++++++ .../nft_billboard_web/src/pages/NotFound.tsx | 20 + .../src/pages/PurchaseAdSpace.scss | 217 ++ .../src/pages/PurchaseAdSpace.tsx | 343 +++ .../nft_billboard_web/src/react-app-env.d.ts | 1 + .../nft_billboard_web/src/reportWebVitals.ts | 15 + .../src/styles/AdSpaceDetailFix.css | 119 + .../src/styles/NFTDetailFix.css | 87 + .../src/styles/textBrightness.css | 33 + .../nft_billboard_web/src/types/index.ts | 131 ++ .../nft_billboard_web/src/utils/auth.ts | 182 ++ .../nft_billboard_web/src/utils/contract.ts | 1906 +++++++++++++++++ .../nft_billboard_web/src/utils/env.ts | 154 ++ .../nft_billboard_web/src/utils/format.ts | 133 ++ .../nft_billboard_web/src/utils/formatter.ts | 58 + .../src/utils/transaction-helper.ts | 111 + .../src/utils/transaction-utils.ts | 184 ++ .../nft_billboard_web/src/utils/walrus.ts | 692 ++++++ .../nft_billboard_web/tsconfig.json | 26 + 95 files changed, 21295 insertions(+) create mode 100644 move202503/cuidaquan/nft-billboard/.gitignore create mode 100644 move202503/cuidaquan/nft-billboard/NFT_Billboard_Pitchdeck.md create mode 100644 move202503/cuidaquan/nft-billboard/README.md create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard/.gitignore create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard/Move.toml create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard/README.md create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard/sources/ad_space.move create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard/sources/factory.move create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard/sources/nft.move create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard/sources/nft_billboard.move create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard/tests/nft_billboard_tests.move create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/.env.development create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/.env.production create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/.gitignore create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/.npmrc create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/README.md create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/babel.config.js create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/config-overrides.js create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/package.json create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/public/favicon.ico create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/public/index.html create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/public/logo.svg create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/public/logo192.png create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/public/logo512.png create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/public/manifest.json create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/public/robots.txt create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/App.css create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/App.scss create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/App.tsx create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/assets/logo.svg create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/adSpace/AdSpaceCard.scss create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/adSpace/AdSpaceCard.tsx create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/adSpace/AdSpaceForm.scss create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/adSpace/AdSpaceForm.tsx create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/adSpace/AdSpaceItem.scss create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/adSpace/AdSpaceItem.tsx create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/common/ConnectWallet.scss create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/common/ConnectWallet.tsx create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/common/LanguageSwitcher.scss create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/common/LanguageSwitcher.tsx create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/layout/Footer.scss create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/layout/Footer.tsx create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/layout/Header.scss create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/layout/Header.tsx create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/layout/MainLayout.scss create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/layout/MainLayout.tsx create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/nft/MediaContent.scss create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/nft/MediaContent.tsx create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/nft/NFTCard.scss create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/nft/NFTCard.tsx create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/nft/UpdateAdContent.scss create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/nft/UpdateAdContent.tsx create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/walrus/WalrusUpload.scss create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/walrus/WalrusUpload.tsx create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/config/config.ts create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/config/constants.ts create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/config/walrusConfig.ts create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/hooks/useTransaction.ts create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/hooks/useWalletTransaction.ts create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/i18n/i18n.ts create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/i18n/locales/en.json create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/i18n/locales/zh.json create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/index.css create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/index.tsx create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/logo.svg create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/pages/AdSpaceDetail.scss create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/pages/AdSpaceDetail.tsx create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/pages/AdSpaces.scss create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/pages/AdSpaces.tsx create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/pages/Home.scss create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/pages/Home.tsx create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/pages/Manage.scss create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/pages/Manage.tsx create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/pages/MyNFTs.scss create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/pages/MyNFTs.tsx create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/pages/NFTDetail.scss create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/pages/NFTDetail.tsx create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/pages/NotFound.tsx create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/pages/PurchaseAdSpace.scss create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/pages/PurchaseAdSpace.tsx create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/react-app-env.d.ts create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/reportWebVitals.ts create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/styles/AdSpaceDetailFix.css create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/styles/NFTDetailFix.css create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/styles/textBrightness.css create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/types/index.ts create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/utils/auth.ts create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/utils/contract.ts create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/utils/env.ts create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/utils/format.ts create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/utils/formatter.ts create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/utils/transaction-helper.ts create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/utils/transaction-utils.ts create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/src/utils/walrus.ts create mode 100644 move202503/cuidaquan/nft-billboard/nft_billboard_web/tsconfig.json diff --git a/move202503/cuidaquan/nft-billboard/.gitignore b/move202503/cuidaquan/nft-billboard/.gitignore new file mode 100644 index 00000000..7bc7bf27 --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/.gitignore @@ -0,0 +1 @@ +nft_billboard_web/package-lock.json diff --git a/move202503/cuidaquan/nft-billboard/NFT_Billboard_Pitchdeck.md b/move202503/cuidaquan/nft-billboard/NFT_Billboard_Pitchdeck.md new file mode 100644 index 00000000..e0d527d0 --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/NFT_Billboard_Pitchdeck.md @@ -0,0 +1,82 @@ +# NFT Billboards - 重新定义区块链广告的未来 +## Sui Overflow 2025 黑客松 Programmable Storage 赛道项目 + +## 项目概述 + +NFT Billboards是一个革命性的区块链广告解决方案,将虚拟世界中的广告位转化为可交易的NFT资产。我们利用Sui区块链和Walrus存储网络,实现广告内容的动态更新,适配链游、元宇宙等多种虚拟场景。 + +## 路线图 + +### 已完成 +- ✅ 核心智能合约开发 +- ✅ 前端应用MVP版本 +- ✅ Walrus存储集成 + +### 未来计划 +- 📅 广告效果分析工具 +- 📅 多种广告形式支持 +- 📅 更多虚拟场景适配方案 +- 📅 游戏引擎插件 + +## 创新性 + +### 广告位NFT化 +- 将广告资源转化为区块链上的唯一资产 +- 支持二级市场自由交易 +- 优质广告位具有保值增值潜力 + +### 动态内容更新 +- 链上记录,链下存储 +- 无需重铸即可更新广告内容 +- 所有更新记录在链上可追溯 + +### 智能定价算法 +- 租期越长,单日价格越优惠 +- 基于智能合约的自动化定价 +- 支持1-365天灵活租期 + +### 多级权限系统 +- 平台管理员:注册开发者,设置参数 +- 游戏开发者:创建广告位 +- NFT持有者:管理广告内容 + +## 技术实现 + +### 系统架构 +- 智能合约层:Sui Move语言 +- 前端应用层:React + TypeScript +- 存储层:Walrus去中心化网络 + +### Walrus存储集成 +- 广告内容存储在Walrus网络 +- 内容指针记录在NFT对象中 +- 存储时长与NFT租期自动匹配 + + +## 商业潜力 + +### 市场与收入 +- 全球数字广告市场规模超4500亿美元 +- 平台分成:交易抽取5-10%服务费 +- 续租收益和高级功能增值服务 + +### 目标客户 +- 游戏开发者:获得额外收入 +- 品牌广告主:进入Web3领域 +- NFT投资者:功能性NFT投资 +- 元宇宙项目:广告解决方案 + +## 生态契合 + +### 与Sui生态协同 +- 利用Sui对象模型和高性能交易 +- 低Gas费用降低交易成本 +- 为Sui生态带来新应用场景 +- 促进SUI代币流通和使用 + + +## 联系方式 +- GitHub:https://github.com/cuidaquan/nft-billboard +- 项目网站:https://www.nftbillboards.io +- 联系我们:https://x.com/NFTBillboardsio + diff --git a/move202503/cuidaquan/nft-billboard/README.md b/move202503/cuidaquan/nft-billboard/README.md new file mode 100644 index 00000000..36586e19 --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/README.md @@ -0,0 +1,229 @@ +# NFT Billboards - 链上动态NFT广告牌系统 + +## 项目推介文档 + +[NFT Billboards - 重新定义区块链广告的未来](https://docs.google.com/presentation/d/1p4-J6uv0tInVwv_JEsSo3fC_VnMLQBheGqZY2nNObx0/edit?usp=sharing) + +## 项目概述 + +NFT Billboards是一个革命性的区块链广告解决方案,将虚拟世界中的广告位转化为可交易的NFT资产。我们利用Sui区块链的对象模型,结合Walrus去中心化存储网络,实现了广告内容的动态更新和安全存储。此广告牌系统适配包括链游、元宇宙、Web3应用等多种虚拟世界场景。 + +### 核心价值 + +- **广告位NFT化**:将广告资源转化为区块链上的唯一资产 +- **动态内容更新**:无需重新部署即可更新广告内容 +- **透明租赁机制**:基于智能合约的自动化租赁和续租流程 +- **去中心化存储**:利用Walrus网络实现广告内容的安全存储 + +## 系统架构 + +项目采用三层架构设计: + +1. **智能合约层**:基于Sui区块链的Move智能合约,处理核心业务逻辑 +2. **前端应用层**:React + TypeScript构建的用户界面,提供直观的交互体验 +3. **存储层**:Walrus去中心化存储网络,确保广告内容的安全可靠存储 + +## 目录结构 + +``` +nft-billboard/ +├── README.md # 项目说明文档 +├── nft_billboard/ # Move智能合约目录 +│ ├── sources/ # 合约源码 +│ │ ├── ad_space.move # 广告位相关功能 +│ │ ├── nft_billboard.move # 主合约模块 +│ │ ├── factory.move # 工厂合约 +│ │ └── nft.move # NFT相关功能 +│ ├── tests/ # 合约测试 +│ └── build/ # 编译输出 +└── nft_billboard_web/ # 前端项目目录 + ├── src/ # 前端源码 + │ ├── components/ # 组件 + │ ├── pages/ # 页面 + │ ├── hooks/ # 自定义钩子 + │ ├── utils/ # 工具函数 + │ └── assets/ # 静态资源 + └── public/ # 公共资源 +``` + +## 核心功能 + +### 1. 广告位管理 + +- 平台管理员可注册游戏开发者 +- 游戏开发者可创建和管理广告位 +- 广告位包含位置、尺寸、价格等属性 + +### 2. NFT广告牌 + +- 用户可购买广告位获得NFT所有权 +- 支持1-365天的灵活租期 +- 智能定价算法确保长期租赁更具性价比 + +### 3. 内容管理 + +- NFT持有者可动态更新广告内容 +- 内容存储在Walrus去中心化网络 +- 所有更新记录在区块链上可追溯 + +### 4. 权限系统 + +- 多级权限控制确保系统安全 +- 基于地址验证的访问控制 +- 完善的错误处理机制 + +## 技术栈 + +- **区块链**:Sui +- **智能合约**:Move +- **前端框架**:React 18 + TypeScript +- **UI组件**:Ant Design +- **钱包集成**:@mysten/dapp-kit +- **存储方案**:Walrus + +## 快速开始 + +### 智能合约 + +```bash +# 进入合约目录 +cd nft_billboard + +# 编译合约 +sui move build + +# 运行测试 +sui move test + +# 发布合约 +sui client publish --gas-budget 100000000 +``` + +### 前端应用 + +```bash +# 安装依赖 +cd nft_billboard_web +npm install + +# 启动开发服务器 +npm start + +# 构建生产版本 +npm run build +``` + + + +## 核心数据结构 + +### 工厂合约 + +```move +public struct Factory has key { + id: UID, + admin: address, + ad_spaces: vector, // 改为vector,更容易在JSON中显示 + game_devs: vector
, // 游戏开发者地址列表 + platform_ratio: u8 // 平台分成比例,百分比 +} +``` + +### 广告位 + +```move +public struct AdSpace has key, store { + id: UID, + game_id: String, // 游戏ID + location: String, // 位置信息 + size: String, // 广告尺寸 + is_available: bool, // 是否可购买 + creator: address, // 创建者地址 + created_at: u64, // 创建时间 + fixed_price: u64, // 基础固定价格(以SUI为单位,表示一天的租赁价格) +} +``` + +### 广告牌NFT + +```move +public struct AdBoardNFT has key, store { + id: UID, + ad_space_id: ID, // 对应的广告位ID + owner: address, // 当前所有者 + brand_name: String, // 品牌名称 + content_url: String, // 内容URL或指针 + project_url: String, // 项目URL + lease_start: u64, // 租约开始时间 + lease_end: u64, // 租约结束时间 + is_active: bool, // 是否激活 + blob_id: Option, // Walrus中的blob ID + storage_source: String, // 存储来源 ("walrus" 或 "external") +} +``` + +## 智能定价算法 + +系统采用指数衰减模型计算租赁价格,确保长期租赁更具性价比: + +```move +// 价格计算核心逻辑 +let daily_price = ad_space.fixed_price; +let ratio = 977000; // 衰减因子(0.977) +let base = 1000000; // 基数 +let min_daily_factor = 500000; // 最低日因子(0.5) + +// 计算总价 +let total_price = daily_price; // 第一天全价 +let mut factor = base; +let mut i = 1; + +while (i < lease_days) { + factor = factor * ratio / base; + + if (factor < min_daily_factor) { + total_price = total_price + daily_price * min_daily_factor * (lease_days - i) / base; + break + }; + + total_price = total_price + daily_price * factor / base; + i = i + 1; +} +``` + +## 前端页面 + +- **首页**:系统介绍和功能导航 +- **广告位列表**:浏览和筛选可用广告位 +- **广告位详情**:查看广告位详细信息和购买 +- **我的NFT**:管理已购买的广告牌NFT +- **管理页面**:开发者和管理员专用功能 + +## 安全考虑 + +- 基于地址的多级权限验证 +- 租约有效性验证 +- 支付金额验证和多余资金退还 +- 内容哈希验证确保内容完整性 + +## 部署指南 + +### 合约部署 + +1. 确保安装Sui CLI并配置好钱包 +2. 编译并发布合约到测试网或主网 +3. 记录合约包ID和工厂对象ID + +### 前端部署 + +1. 在环境配置文件(.env.production)中设置合约相关参数 +2. 构建前端应用:`npm run build` +3. 部署到静态网站托管服务 + +## 后续规划 + +1. 广告效果分析功能 +2. 更多广告类型支持 +3. 多链部署支持 +4. 移动端优化 +5. 社区治理机制 \ No newline at end of file diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard/.gitignore b/move202503/cuidaquan/nft-billboard/nft_billboard/.gitignore new file mode 100644 index 00000000..47f41b8e --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard/.gitignore @@ -0,0 +1,2 @@ +build/* +Move.lock diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard/Move.toml b/move202503/cuidaquan/nft-billboard/nft_billboard/Move.toml new file mode 100644 index 00000000..c0493d10 --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard/Move.toml @@ -0,0 +1,11 @@ +[package] +name = "nft_billboard" +version = "0.1.0" +edition = "2024.beta" + +[dependencies] + + +[addresses] +nft_billboard = "0x0" + diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard/README.md b/move202503/cuidaquan/nft-billboard/nft_billboard/README.md new file mode 100644 index 00000000..eea7c527 --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard/README.md @@ -0,0 +1,221 @@ +# NFT Billboard 智能合约 + +## 概述 + +这是NFT Billboard系统的智能合约部分,使用Sui区块链的Move语言实现。合约实现了广告位NFT化、租赁管理、内容动态更新等核心功能。 + +## 合约结构 + +项目包含四个主要合约模块: + +1. **nft_billboard.move** - 主合约模块,包含系统初始化和核心功能 +2. **factory.move** - 工厂合约,负责权限管理和广告位创建 +3. **ad_space.move** - 广告位相关功能,包括创建和管理广告位 +4. **nft.move** - NFT相关功能,包括NFT铸造、内容更新和租约管理 + +## 核心数据结构 + +### 工厂合约 + +```move +public struct Factory has key { + id: UID, + admin: address, + ad_spaces: vector, // 改为vector,更容易在JSON中显示 + game_devs: vector
, // 游戏开发者地址列表 + platform_ratio: u8 // 平台分成比例,百分比 +} +``` + +### 广告位 + +```move +public struct AdSpace has key, store { + id: UID, + game_id: String, // 游戏ID + location: String, // 位置信息 + size: String, // 广告尺寸 + is_available: bool, // 是否可购买 + creator: address, // 创建者地址 + created_at: u64, // 创建时间 + fixed_price: u64, // 基础固定价格(以SUI为单位,表示一天的租赁价格) +} +``` + +### 广告牌NFT + +```move +public struct AdBoardNFT has key, store { + id: UID, + ad_space_id: ID, // 对应的广告位ID + owner: address, // 当前所有者 + brand_name: String, // 品牌名称 + content_url: String, // 内容URL或指针 + project_url: String, // 项目URL + lease_start: u64, // 租约开始时间 + lease_end: u64, // 租约结束时间 + is_active: bool, // 是否激活 + blob_id: Option, // Walrus中的blob ID + storage_source: String, // 存储来源 ("walrus" 或 "external") +} +``` + +## 主要功能 + +### 1. 系统初始化 + +```move +fun init(_: NFT_BILLBOARD, ctx: &mut TxContext) +``` + +初始化系统,创建工厂合约,设置平台管理员和系统参数。 + +### 2. 注册游戏开发者 + +```move +public entry fun register_game_dev( + factory: &mut Factory, + game_dev: address, + ctx: &mut TxContext +) +``` + +平台管理员注册游戏开发者,授予其创建广告位的权限。 + +### 3. 创建广告位 + +```move +public entry fun create_ad_space( + factory: &mut Factory, + game_id: String, + location: String, + size: String, + daily_price: u64, + clock: &Clock, + ctx: &mut TxContext +) +``` + +游戏开发者创建新的广告位,设置位置、尺寸和价格等属性。 + +### 4. 购买广告位 + +```move +public entry fun purchase_ad_space( + factory: &mut Factory, + ad_space: &mut AdSpace, + mut payment: Coin, + brand_name: String, + content_url: String, + project_url: String, + lease_days: u64, + clock: &Clock, + start_time: u64, + blob_id: vector, // 添加:blob_id参数(序列化后的Option) + storage_source: String, // 添加:storage_source参数 + ctx: &mut TxContext +) +``` + +用户购买广告位,支付SUI代币,获得对应的NFT所有权。 + +### 5. 更新广告内容 + +```move +public entry fun update_ad_content( + nft: &mut nft::AdBoardNFT, + content_url: String, + blob_id: vector, // 添加:blob_id参数(序列化后的Option) + storage_source: String, // 添加:storage_source参数 + clock: &Clock, + ctx: &mut TxContext +) +``` + +NFT持有者更新广告内容,内容URL和哈希记录在区块链上。 + +### 6. 续租广告位 + +```move +public entry fun renew_lease( + factory: &mut Factory, + ad_space: &mut AdSpace, + nft: &mut nft::AdBoardNFT, + mut payment: Coin, + lease_days: u64, + clock: &Clock, + ctx: &mut TxContext +) +``` + +NFT持有者续租广告位,延长租约期限。 + +## 智能定价算法 + +系统使用指数衰减模型计算租赁价格,确保长期租赁更具性价比: + +```move +// 价格计算核心逻辑 +let daily_price = ad_space.fixed_price; +let ratio = 977000; // 衰减因子(0.977) +let base = 1000000; // 基数 +let min_daily_factor = 500000; // 最低日因子(0.5) + +// 计算总价 +let total_price = daily_price; // 第一天全价 +let mut factor = base; +let mut i = 1; + +while (i < lease_days) { + factor = factor * ratio / base; + + if (factor < min_daily_factor) { + total_price = total_price + daily_price * min_daily_factor * (lease_days - i) / base; + break + }; + + total_price = total_price + daily_price * factor / base; + i = i + 1; +} +``` + +## 权限控制 + +系统实现了多级权限控制: + +1. **管理员权限**:验证调用者地址与Factory中的admin地址匹配 +2. **游戏开发者权限**:验证调用者地址是否在game_devs表中注册 +3. **NFT所有者权限**:验证调用者地址与NFT所有者地址匹配 + +## 错误处理 + +合约定义了多种错误类型,确保操作安全: + +- `ENotAdmin`:非管理员操作错误 +- `ENotGameDev`:非游戏开发者操作错误 +- `ENotAdSpaceCreator`:非广告位创建者操作错误 +- `ENotOwner`:非NFT所有者操作错误 +- `EInsufficientPayment`:支付金额不足错误 +- `ELeaseExpired`:租约已过期错误 + +## 编译与测试 + +```bash +# 编译合约 +sui move build + +# 运行测试 +sui move test + +# 发布合约 +sui client publish --gas-budget 100000000 +``` + +## 部署后配置 + +部署合约后,需要记录以下信息用于前端配置: + +1. 合约包ID +2. 工厂对象ID + +这些信息将在前端环境变量中配置,用于与合约交互。 diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard/sources/ad_space.move b/move202503/cuidaquan/nft-billboard/nft_billboard/sources/ad_space.move new file mode 100644 index 00000000..70bda07a --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard/sources/ad_space.move @@ -0,0 +1,228 @@ +module nft_billboard::ad_space { + use std::string::{Self, String}; + use sui::object::{Self, UID, ID}; + use sui::tx_context::{Self, TxContext}; + use sui::transfer; + use sui::event; + use sui::clock::{Self, Clock}; + + // 错误码 + const ENotCreator: u64 = 1; + const EInvalidPrice: u64 = 2; + const EInvalidLeaseDays: u64 = 3; + const EInvalidSize: u64 = 4; + + // 广告位结构 + public struct AdSpace has key, store { + id: UID, + game_id: String, // 游戏ID + location: String, // 位置信息 + size: String, // 广告尺寸 + is_available: bool, // 是否可购买 + creator: address, // 创建者地址 + created_at: u64, // 创建时间 + fixed_price: u64, // 基础固定价格(以SUI为单位,表示一天的租赁价格) + } + + // 事件定义 + public struct AdSpaceCreated has copy, drop { + ad_space_id: ID, + game_id: String, + location: String, + size: String, + creator: address + } + + public struct AdSpacePriceUpdated has copy, drop { + ad_space_id: ID, + new_price: u64, + updated_by: address + } + + public struct AdSpaceAvailabilityUpdated has copy, drop { + ad_space_id: ID, + is_available: bool, + updated_by: address + } + + // 广告位删除事件 + public struct AdSpaceDeleted has copy, drop { + ad_space_id: ID, + deleted_by: address + } + + // 创建广告位 + public fun create_ad_space( + game_id: String, + location: String, + size: String, + fixed_price: u64, + creator: address, + clock: &Clock, + ctx: &mut TxContext + ): AdSpace { + // 验证广告尺寸格式 (例如: "16:9") + let size_bytes = *string::as_bytes(&size); + let mut has_colon = false; + let size_len = vector::length(&size_bytes); + + let mut i = 0; + while (i < size_len) { + if (*vector::borrow(&size_bytes, i) == 58) { // ASCII ':' + has_colon = true; + break + }; + i = i + 1; + }; + + assert!(has_colon, EInvalidSize); + + let current_time = clock::timestamp_ms(clock) / 1000; + + let ad_space = AdSpace { + id: object::new(ctx), + game_id, + location, + size, + is_available: true, + creator, + created_at: current_time, + fixed_price, + }; + + event::emit(AdSpaceCreated { + ad_space_id: object::id(&ad_space), + game_id, + location, + size, + creator + }); + + ad_space + } + + // 更新广告位价格 + public fun update_price( + ad_space: &mut AdSpace, + new_price: u64, + ctx: &mut TxContext + ) { + assert!(tx_context::sender(ctx) == ad_space.creator, ENotCreator); + + ad_space.fixed_price = new_price; + + event::emit(AdSpacePriceUpdated { + ad_space_id: object::id(ad_space), + new_price, + updated_by: tx_context::sender(ctx) + }); + } + + // 设置广告位可用性 + public fun set_availability( + ad_space: &mut AdSpace, + is_available: bool, + ctx: &mut TxContext + ) { + // 检查是否为创建者 + assert!(tx_context::sender(ctx) == ad_space.creator, ENotCreator); + + ad_space.is_available = is_available; + + event::emit(AdSpaceAvailabilityUpdated { + ad_space_id: object::id(ad_space), + is_available, + updated_by: tx_context::sender(ctx) + }); + } + + // Getter 函数 + public fun is_available(ad_space: &AdSpace): bool { + ad_space.is_available + } + + public fun get_creator(ad_space: &AdSpace): address { + ad_space.creator + } + + public fun get_fixed_price(ad_space: &AdSpace): u64 { + ad_space.fixed_price + } + + public fun get_metadata(ad_space: &AdSpace): (String, String, String) { + (ad_space.game_id, ad_space.location, ad_space.size) + } + + // 获取广告位的游戏ID + public fun get_game_id(ad_space: &AdSpace): String { + ad_space.game_id + } + + // 获取广告位的 UID + public fun get_uid(ad_space: &AdSpace): &UID { + &ad_space.id + } + + // 共享广告位对象 + public fun share_ad_space(ad_space: AdSpace) { + transfer::share_object(ad_space) + } + + // 删除广告位 + public fun delete_ad_space( + ad_space: AdSpace, + ctx: &mut TxContext + ) { + // 确保只有创建者可以删除广告位 + assert!(tx_context::sender(ctx) == ad_space.creator, ENotCreator); + + // 发送删除事件 + event::emit(AdSpaceDeleted { + ad_space_id: object::id(&ad_space), + deleted_by: tx_context::sender(ctx) + }); + + // 解构并删除广告位 + let AdSpace { id, game_id: _, location: _, size: _, is_available: _, creator: _, created_at: _, fixed_price: _ } = ad_space; + object::delete(id); + } + + /// 使用几何级数公式计算租赁价格 + public fun calculate_lease_price(ad_space: &AdSpace, lease_days: u64): u64 { + // 验证租赁天数在有效范围内 + assert!(lease_days > 0 && lease_days <= 365, EInvalidLeaseDays); + + let daily_price = ad_space.fixed_price; // Y - 一天的租赁价格 + let ratio = 977000; // a - 比例因子,这里设为0.977000 + let base = 1000000; // 用于表示小数的基数 + let min_daily_factor = 500000; // 最低日因子(1/2) + + // 如果只租一天,直接返回每日价格 + if (lease_days == 1) { + return daily_price + }; + + // 计算租赁总价 + let mut total_price = daily_price; // 第一天的价格 + let mut factor = base; // 初始因子为1.0 + let mut i = 1; // 从第二天开始计算 + + while (i < lease_days) { + // 计算当前因子 + factor = factor * ratio / base; + + // 如果因子低于最低值(1/10),则使用最低值 + if (factor < min_daily_factor) { + // 增加(租赁天数-i)天的最低价格 + total_price = total_price + daily_price * min_daily_factor * (lease_days - i) / base; + break + }; + + // 否则增加当前因子对应的价格 + total_price = total_price + daily_price * factor / base; + i = i + 1; + }; + + total_price + } +} \ No newline at end of file diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard/sources/factory.move b/move202503/cuidaquan/nft-billboard/nft_billboard/sources/factory.move new file mode 100644 index 00000000..c5e48c6a --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard/sources/factory.move @@ -0,0 +1,318 @@ +module nft_billboard::factory { + use sui::object::{UID, ID}; + use sui::tx_context::TxContext; + use sui::transfer; + use std::vector; + use sui::event; + + // 错误码 + const ENotAuthorized: u64 = 1; + const EInvalidPlatformRatio: u64 = 2; + const EGameDevNotFound: u64 = 3; + const EAdSpaceNotFound: u64 = 4; + + // 广告位条目结构 + public struct AdSpaceEntry has store, copy, drop { + ad_space_id: ID, + creator: address, + nft_ids: vector // 存储广告位中的所有NFT ID + } + + // 工厂结构,用于管理广告位和分成比例 + public struct Factory has key { + id: UID, + admin: address, + ad_spaces: vector, // 改为vector,更容易在JSON中显示 + game_devs: vector
, // 游戏开发者地址列表 + platform_ratio: u8 // 平台分成比例,百分比 + } + + // 事件定义 + public struct FactoryCreated has copy, drop { + admin: address, + platform_ratio: u8 + } + + public struct AdSpaceRegistered has copy, drop { + ad_space_id: ID, + creator: address + } + + public struct RatioUpdated has copy, drop { + factory_id: ID, + platform_ratio: u8 + } + + // 游戏开发者移除事件 + public struct GameDevRemoved has copy, drop { + game_dev: address + } + + // 广告位从工厂移除事件 + public struct AdSpaceRemoved has copy, drop { + ad_space_id: ID, + removed_by: address + } + + // 默认分成比例 + const DEFAULT_PLATFORM_RATIO: u8 = 10; // 平台默认分成 10% + + // 初始化工厂 + public fun init_factory(ctx: &mut TxContext) { + let factory = Factory { + id: object::new(ctx), + admin: tx_context::sender(ctx), + ad_spaces: vector::empty(), + game_devs: vector::empty
(), + platform_ratio: DEFAULT_PLATFORM_RATIO + }; + + transfer::share_object(factory); + + event::emit(FactoryCreated { + admin: tx_context::sender(ctx), + platform_ratio: DEFAULT_PLATFORM_RATIO + }); + } + + // 注册广告位 + public fun register_ad_space( + factory: &mut Factory, + ad_space_id: ID, + creator: address + ) { + let entry = AdSpaceEntry { + ad_space_id, + creator, + nft_ids: vector::empty() + }; + vector::push_back(&mut factory.ad_spaces, entry); + + event::emit(AdSpaceRegistered { + ad_space_id, + creator + }); + } + + // 获取广告位创建者 + public fun get_ad_space_creator(factory: &Factory, ad_space_id: ID): address { + let len = vector::length(&factory.ad_spaces); + let mut i = 0; + + while (i < len) { + let entry = vector::borrow(&factory.ad_spaces, i); + if (entry.ad_space_id == ad_space_id) { + return entry.creator + }; + i = i + 1; + }; + + abort EAdSpaceNotFound + } + + // 获取管理员地址 + public fun get_admin(factory: &Factory): address { + factory.admin + } + + // 注册游戏开发者 + public fun register_game_dev(factory: &mut Factory, game_dev: address, ctx: &mut TxContext) { + // 只有管理员可以注册 + assert!(tx_context::sender(ctx) == factory.admin, ENotAuthorized); + + // 检查是否已存在 + let mut i = 0; + let len = vector::length(&factory.game_devs); + while (i < len) { + let dev = *vector::borrow(&factory.game_devs, i); + if (dev == game_dev) { + return + }; + i = i + 1; + }; + vector::push_back(&mut factory.game_devs, game_dev); + } + + // 移除游戏开发者 + public fun remove_game_dev(factory: &mut Factory, game_dev: address, ctx: &mut TxContext) { + // 只有管理员可以移除 + assert!(tx_context::sender(ctx) == factory.admin, ENotAuthorized); + + // 查找开发者的索引 + let mut i = 0; + let len = vector::length(&factory.game_devs); + let mut found = false; + let mut index = 0; + + while (i < len) { + let dev = *vector::borrow(&factory.game_devs, i); + if (dev == game_dev) { + found = true; + index = i; + break + }; + i = i + 1; + }; + + // 确保开发者存在 + assert!(found, EGameDevNotFound); + + // 移除开发者 + vector::remove(&mut factory.game_devs, index); + + // 发送事件 + event::emit(GameDevRemoved { + game_dev + }); + } + + // 获取游戏开发者列表 + public fun get_game_devs(factory: &Factory): vector
{ + let mut devs = vector::empty
(); + let mut i = 0; + let len = vector::length(&factory.game_devs); + while (i < len) { + let dev = *vector::borrow(&factory.game_devs, i); + vector::push_back(&mut devs, dev); + i = i + 1; + }; + devs + } + + // 检查是否是游戏开发者 + public fun is_game_dev(factory: &Factory, game_dev: address): bool { + let mut i = 0; + let len = vector::length(&factory.game_devs); + while (i < len) { + let dev = *vector::borrow(&factory.game_devs, i); + if (dev == game_dev) { + return true + }; + i = i + 1; + }; + false + } + + // 更新分成比例 + public fun update_ratios( + factory: &mut Factory, + platform_ratio: u8, + ctx: &mut TxContext + ) { + // 只有管理员可以更新 + assert!(tx_context::sender(ctx) == factory.admin, ENotAuthorized); + + // 验证分成比例的有效性 + assert!(platform_ratio <= 100, EInvalidPlatformRatio); + + factory.platform_ratio = platform_ratio; + + event::emit(RatioUpdated { + factory_id: object::id(factory), + platform_ratio + }); + } + + // 获取平台分成比例 + public fun get_platform_ratio(factory: &Factory): u8 { + factory.platform_ratio + } + + // 获取所有广告位 + public fun get_all_ad_spaces(factory: &Factory): vector { + let mut result = vector::empty(); + let len = vector::length(&factory.ad_spaces); + let mut i = 0; + + while (i < len) { + let entry = *vector::borrow(&factory.ad_spaces, i); + vector::push_back(&mut result, entry); + i = i + 1; + }; + + result + } + + // 从工厂中移除广告位 + public fun remove_ad_space( + factory: &mut Factory, + ad_space_id: ID, + ctx: &mut TxContext + ) { + // 查找广告位的索引 + let mut i = 0; + let len = vector::length(&factory.ad_spaces); + let mut found = false; + let mut index = 0; + let mut creator_address: address = @0x0; + + while (i < len) { + let entry = vector::borrow(&factory.ad_spaces, i); + if (entry.ad_space_id == ad_space_id) { + found = true; + index = i; + creator_address = entry.creator; + break + }; + i = i + 1; + }; + + // 确保广告位存在 + assert!(found, EAdSpaceNotFound); + + // 确保调用者是广告位的创建者或管理员 + assert!( + tx_context::sender(ctx) == creator_address || + tx_context::sender(ctx) == factory.admin, + ENotAuthorized + ); + + // 移除广告位 + vector::remove(&mut factory.ad_spaces, index); + + // 发送事件 + event::emit(AdSpaceRemoved { + ad_space_id, + removed_by: tx_context::sender(ctx) + }); + } + + // 添加NFT到广告位 + public fun add_nft_to_ad_space( + factory: &mut Factory, + ad_space_id: ID, + nft_id: ID + ) { + let len = vector::length(&factory.ad_spaces); + let mut i = 0; + + while (i < len) { + let entry = vector::borrow_mut(&mut factory.ad_spaces, i); + if (entry.ad_space_id == ad_space_id) { + vector::push_back(&mut entry.nft_ids, nft_id); + return + }; + i = i + 1; + }; + + // 如果找不到广告位,则中止执行 + abort EAdSpaceNotFound + } + + // 获取广告位的所有NFT ID + public fun get_ad_space_nft_ids(factory: &Factory, ad_space_id: ID): vector { + let len = vector::length(&factory.ad_spaces); + let mut i = 0; + + while (i < len) { + let entry = vector::borrow(&factory.ad_spaces, i); + if (entry.ad_space_id == ad_space_id) { + return *&entry.nft_ids + }; + i = i + 1; + }; + + // 如果找不到广告位,返回空vector + vector::empty() + } +} \ No newline at end of file diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard/sources/nft.move b/move202503/cuidaquan/nft-billboard/nft_billboard/sources/nft.move new file mode 100644 index 00000000..54f628fd --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard/sources/nft.move @@ -0,0 +1,236 @@ +module nft_billboard::nft { + use std::string::{String, utf8}; + use std::option::{Self, Option, some, none}; + use sui::object::{Self, UID, ID}; + use sui::tx_context::{Self, TxContext}; + use sui::transfer; + use sui::event; + use sui::clock::{Self, Clock}; + use sui::display; + use sui::package; + + use nft_billboard::ad_space::AdSpace; + + // 一次性见证类型 - 添加public可见性 + public struct NFT has drop {} + + // 添加模块初始化函数 + fun init(witness: NFT, ctx: &mut TxContext) { + let keys = vector[ + utf8(b"name"), + utf8(b"description"), + utf8(b"image_url"), + utf8(b"project_url"), + utf8(b"creator"), + utf8(b"brand_name"), + utf8(b"lease_start"), + utf8(b"lease_end"), + utf8(b"status"), + // 新增Walrus相关字段 + utf8(b"blob_id"), + utf8(b"storage_source") + ]; + + let values = vector[ + utf8(b"{brand_name} - Billboard Ad"), + utf8(b"A digital billboard advertisement space in the metaverse"), + utf8(b"{content_url}"), + utf8(b"{project_url}"), + utf8(b"{creator}"), + utf8(b"{brand_name}"), + utf8(b"{lease_start}"), + utf8(b"{lease_end}"), + utf8(b"{is_active}"), + // 新增Walrus相关值 + utf8(b"{blob_id}"), + utf8(b"{storage_source}") + ]; + + // 正确使用一次性见证 + let publisher = package::claim(witness, ctx); + let mut display = display::new_with_fields( + &publisher, keys, values, ctx + ); + + display::update_version(&mut display); + + transfer::public_transfer(publisher, tx_context::sender(ctx)); + transfer::public_transfer(display, tx_context::sender(ctx)); + } + + // 错误码 + const ENotOwner: u64 = 1; + const ELeaseExpired: u64 = 2; + const EInvalidLeaseDuration: u64 = 3; + + // 定义租期常量 + const SECONDS_PER_DAY: u64 = 24 * 60 * 60; + const MAX_LEASE_DAYS: u64 = 365; // 最大租期365天 + const MIN_LEASE_DAYS: u64 = 1; // 最小租期1天 + + // 广告牌NFT结构 - 添加blob_id和storage_source字段 + public struct AdBoardNFT has key, store { + id: UID, + ad_space_id: ID, // 对应的广告位ID + owner: address, // 当前所有者 + brand_name: String, // 品牌名称 + content_url: String, // 内容URL或指针 + project_url: String, // 项目URL + lease_start: u64, // 租约开始时间 + lease_end: u64, // 租约结束时间 + is_active: bool, // 是否激活 + blob_id: Option, // Walrus中的blob ID + storage_source: String, // 存储来源 ("walrus" 或 "external") + } + + // 事件定义 - 扩展事件添加blob_id字段 + public struct AdContentUpdated has copy, drop { + nft_id: ID, + content_url: String, + updated_by: address, + blob_id: Option, // 添加blob_id字段 + storage_source: String // 添加storage_source字段 + } + + public struct AdStatusUpdated has copy, drop { + nft_id: ID, + is_active: bool, + updated_by: address + } + + public struct LeaseRenewed has copy, drop { + nft_id: ID, + renewed_by: address, + lease_end: u64, + price: u64, + blob_id: Option // 添加blob_id字段 + } + + // 创建NFT - 添加blob_id和storage_source参数 + public fun create_nft( + ad_space: &AdSpace, + brand_name: String, + content_url: String, + project_url: String, + lease_duration: u64, + start_time: u64, + blob_id: Option, // 添加blob_id参数 + storage_source: String, // 添加storage_source参数 + clock: &Clock, + ctx: &mut TxContext + ): AdBoardNFT { + assert!(lease_duration >= MIN_LEASE_DAYS && lease_duration <= MAX_LEASE_DAYS, EInvalidLeaseDuration); + + let nft = AdBoardNFT { + id: object::new(ctx), + owner: tx_context::sender(ctx), + ad_space_id: object::id(ad_space), + brand_name, + content_url, + project_url, + lease_start: if (start_time == 0) { clock::timestamp_ms(clock) / 1000 } else { start_time }, + lease_end: if (start_time == 0) { + (clock::timestamp_ms(clock) / 1000) + (lease_duration * SECONDS_PER_DAY) + } else { + start_time + (lease_duration * SECONDS_PER_DAY) + }, + is_active: true, + blob_id, + storage_source + }; + + nft + } + + // 更新广告内容 - 添加blob_id和storage_source参数 + public fun update_content( + nft: &mut AdBoardNFT, + content_url: String, + blob_id: Option, // 添加blob_id参数 + storage_source: String, // 添加storage_source参数 + clock: &Clock, + ctx: &mut TxContext + ) { + assert!(tx_context::sender(ctx) == nft.owner, ENotOwner); + assert!(clock::timestamp_ms(clock) / 1000 <= nft.lease_end, ELeaseExpired); + + nft.content_url = content_url; + nft.blob_id = blob_id; + nft.storage_source = storage_source; + + event::emit(AdContentUpdated { + nft_id: object::id(nft), + content_url, + updated_by: tx_context::sender(ctx), + blob_id, + storage_source + }); + } + + // 设置广告活跃状态 + public fun set_active_status( + nft: &mut AdBoardNFT, + is_active: bool, + ctx: &mut TxContext + ) { + assert!(tx_context::sender(ctx) == nft.owner, ENotOwner); + + nft.is_active = is_active; + + event::emit(AdStatusUpdated { + nft_id: object::id(nft), + is_active, + updated_by: tx_context::sender(ctx) + }); + } + + // 续租广告位 + public fun renew_lease( + nft: &mut AdBoardNFT, + lease_duration: u64, + clock: &Clock, + ctx: &mut TxContext + ) { + assert!(tx_context::sender(ctx) == nft.owner, ENotOwner); + assert!(lease_duration >= MIN_LEASE_DAYS && lease_duration <= MAX_LEASE_DAYS, EInvalidLeaseDuration); + + nft.lease_end = nft.lease_end + (lease_duration * SECONDS_PER_DAY); + + event::emit(LeaseRenewed { + nft_id: object::id(nft), + renewed_by: tx_context::sender(ctx), + lease_end: nft.lease_end, + price: lease_duration * SECONDS_PER_DAY, + blob_id: nft.blob_id // 添加blob_id字段 + }); + } + + // Getter 函数 + public fun is_active(nft: &AdBoardNFT): bool { + nft.is_active + } + + public fun get_lease_status(nft: &AdBoardNFT, clock: &Clock): bool { + clock::timestamp_ms(clock) / 1000 <= nft.lease_end + } + + public fun get_owner(nft: &AdBoardNFT): address { + nft.owner + } + + public fun get_ad_space_id(nft: &AdBoardNFT): ID { + nft.ad_space_id + } + + + // 转移NFT + public fun transfer_nft(nft: AdBoardNFT, recipient: address) { + transfer::transfer(nft, recipient) + } + + #[test_only] + public fun init_display_for_testing(ctx: &mut TxContext) { + // 在测试中跳过publisher和display的创建,因为它们在测试中不是必需的 + // 这里只是为了让测试能够继续运行 + } +} \ No newline at end of file diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard/sources/nft_billboard.move b/move202503/cuidaquan/nft-billboard/nft_billboard/sources/nft_billboard.move new file mode 100644 index 00000000..8e706ef3 --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard/sources/nft_billboard.move @@ -0,0 +1,366 @@ +module nft_billboard::nft_billboard { + use sui::object::UID; + use sui::tx_context::{Self, TxContext}; + use sui::transfer; + use sui::event; + use sui::coin::{Self, Coin}; + use sui::sui::SUI; + use sui::clock::Clock; + use std::string::{Self, String}; + use std::option::{Self, Option}; + + use nft_billboard::ad_space::{Self, AdSpace}; + use nft_billboard::nft; + use nft_billboard::factory::{Self, Factory}; + + // 错误码 + const EAdSpaceNotAvailable: u64 = 4; + const EInvalidPayment: u64 = 5; + const ENotAdmin: u64 = 7; // 不是管理员 + const ENotGameDev: u64 = 8; // 不是游戏开发者 + const ENotAdSpaceCreator: u64 = 9; // 不是广告位创建者 + const ENftExpired: u64 = 12; // NFT已过期 + + // 一次性见证类型 + public struct NFT_BILLBOARD has drop {} + + // 事件 + public struct SystemInitialized has copy, drop { + admin: address + } + + public struct GameDevRegistered has copy, drop { + game_dev: address + } + + public struct GameDevRemoved has copy, drop { + game_dev: address + } + + public struct AdSpacePurchased has copy, drop { + ad_space_id: address, + buyer: address, + brand_name: String, + content_url: String, + lease_days: u64 + } + + // 续租事件 + public struct LeaseRenewed has copy, drop { + ad_space_id: address, + nft_id: address, + lease_days: u64 + } + + // 初始化函数 + fun init(_witness: NFT_BILLBOARD, ctx: &mut TxContext) { + // 初始化工厂 + factory::init_factory(ctx); + + // 发送事件 + event::emit(SystemInitialized { + admin: tx_context::sender(ctx) + }); + } + + // 注册游戏开发者 + public entry fun register_game_dev( + factory: &mut Factory, + game_dev: address, + ctx: &mut TxContext + ) { + // 验证调用者是否为管理员 + assert!(factory::get_admin(factory) == tx_context::sender(ctx), ENotAdmin); + + // 将开发者地址注册到Factory中 + factory::register_game_dev(factory, game_dev, ctx); + + // 发送事件 + event::emit(GameDevRegistered { + game_dev + }); + } + + // 移除游戏开发者 + public entry fun remove_game_dev( + factory: &mut Factory, + game_dev: address, + ctx: &mut TxContext + ) { + // 验证调用者是否为管理员 + assert!(factory::get_admin(factory) == tx_context::sender(ctx), ENotAdmin); + + // 从Factory中移除开发者地址 + factory::remove_game_dev(factory, game_dev, ctx); + + // 发送事件 + event::emit(GameDevRemoved { + game_dev + }); + } + + // 游戏开发商创建广告位 + public entry fun create_ad_space( + factory: &mut Factory, + game_id: String, + location: String, + size: String, + daily_price: u64, + clock: &Clock, + ctx: &mut TxContext + ) { + // 验证调用者是否为已注册的游戏开发者 + assert!(factory::is_game_dev(factory, tx_context::sender(ctx)), ENotGameDev); + + // 创建广告位 + let ad_space = ad_space::create_ad_space( + game_id, + location, + size, + daily_price, + tx_context::sender(ctx), + clock, + ctx + ); + + // 注册广告位到工厂 + factory::register_ad_space( + factory, + object::id(&ad_space), + tx_context::sender(ctx) + ); + + // 共享广告位对象 + ad_space::share_ad_space(ad_space); + } + + // 更新平台分成比例 + public entry fun update_platform_ratio( + factory: &mut Factory, + platform_ratio: u8, + ctx: &mut TxContext + ) { + // 验证调用者是否为管理员 + assert!(factory::get_admin(factory) == tx_context::sender(ctx), ENotAdmin); + + factory::update_ratios(factory, platform_ratio, ctx) + } + + // 更新广告位价格 + public entry fun update_ad_space_price( + ad_space: &mut AdSpace, + daily_price: u64, + ctx: &mut TxContext + ) { + // 验证调用者是否为广告位创建者 + assert!(ad_space::get_creator(ad_space) == tx_context::sender(ctx), ENotAdSpaceCreator); + + ad_space::update_price(ad_space, daily_price, ctx) + } + + // 删除广告位 + public entry fun delete_ad_space( + factory: &mut Factory, + ad_space: AdSpace, + ctx: &mut TxContext + ) { + // 验证调用者是否为广告位创建者 + assert!(ad_space::get_creator(&ad_space) == tx_context::sender(ctx), ENotAdSpaceCreator); + + // 从工厂中移除广告位 + factory::remove_ad_space(factory, object::id(&ad_space), ctx); + + // 删除广告位对象 + ad_space::delete_ad_space(ad_space, ctx); + } + + // 购买广告位并创建NFT - 添加blob_id和storage_source参数 + public entry fun purchase_ad_space( + factory: &mut Factory, + ad_space: &mut AdSpace, + mut payment: Coin, + brand_name: String, + content_url: String, + project_url: String, + lease_days: u64, + clock: &Clock, + start_time: u64, + blob_id: vector, // 添加:blob_id参数(序列化后的Option) + storage_source: String, // 添加:storage_source参数 + ctx: &mut TxContext + ) { + // 验证广告位是否可用 + assert!(ad_space::is_available(ad_space), EAdSpaceNotAvailable); + + // 获取广告位租赁价格 + let price = ad_space::calculate_lease_price(ad_space, lease_days); + + // 验证支付金额 + assert!(coin::value(&payment) >= price, EInvalidPayment); + + // 如果支付金额超过价格,先退还超出部分 + if (coin::value(&payment) > price) { + let payment_value = coin::value(&payment); + let refund = coin::split(&mut payment, payment_value - price, ctx); + transfer::public_transfer(refund, tx_context::sender(ctx)); + }; + + // 获取平台分成比例 + let platform_ratio = factory::get_platform_ratio(factory); + + // 计算分成金额 + let platform_amount = (price * (platform_ratio as u64)) / 100; + let game_dev_amount = price - platform_amount; // 剩余金额都给游戏开发者 + + // 分配支付金额 + let platform_payment = coin::split(&mut payment, platform_amount, ctx); + let game_dev_payment = coin::split(&mut payment, game_dev_amount, ctx); + + // 销毁可能的零值 Coin + coin::destroy_zero(payment); + + // 将平台收入转给平台管理员 + transfer::public_transfer(platform_payment, factory::get_admin(factory)); + + // 转账给游戏开发者 + transfer::public_transfer(game_dev_payment, ad_space::get_creator(ad_space)); + + // 解析blob_id参数 + let blob_id_option = if (vector::is_empty(&blob_id)) { + option::none() + } else { + option::some(string::utf8(blob_id)) + }; + + // 创建NFT - 添加blob_id和storage_source参数 + let nft = nft::create_nft( + ad_space, + brand_name, + content_url, + project_url, + lease_days, + start_time, + blob_id_option, // 传递blob_id + storage_source, // 传递storage_source + clock, + ctx + ); + + // 将NFT ID添加到对应广告位条目 + factory::add_nft_to_ad_space(factory, object::id(ad_space), object::id(&nft)); + + // 发送事件 + event::emit(AdSpacePurchased { + ad_space_id: object::id_address(ad_space), + buyer: tx_context::sender(ctx), + brand_name, + content_url, + lease_days + }); + + // 转移NFT给买家 + transfer::public_transfer(nft, tx_context::sender(ctx)); + } + + // 更新广告内容 - 添加blob_id和storage_source参数 + public entry fun update_ad_content( + nft: &mut nft::AdBoardNFT, + content_url: String, + blob_id: vector, // 添加:blob_id参数(序列化后的Option) + storage_source: String, // 添加:storage_source参数 + clock: &Clock, + ctx: &mut TxContext + ) { + // 解析blob_id参数 + let blob_id_option = if (vector::is_empty(&blob_id)) { + option::none() + } else { + option::some(string::utf8(blob_id)) + }; + + // 调用nft模块的更新函数,传递blob_id和storage_source + nft::update_content(nft, content_url, blob_id_option, storage_source, clock, ctx) + } + + // 暴露广告位价格计算接口 + public entry fun calculate_lease_price( + ad_space: &AdSpace, + lease_days: u64 + ): u64 { + ad_space::calculate_lease_price(ad_space, lease_days) + } + + // 续租广告位 + public entry fun renew_lease( + factory: &mut Factory, + ad_space: &mut AdSpace, + nft: &mut nft::AdBoardNFT, + mut payment: Coin, + lease_days: u64, + clock: &Clock, + ctx: &mut TxContext + ) { + // 修改验证逻辑:要求NFT未过期才能续租 + // 原来:assert!(!nft::get_lease_status(nft, clock), ENotExpired); + // get_lease_status为true表示NFT仍然有效,为false表示已过期 + assert!(nft::get_lease_status(nft, clock), ENftExpired); + + // 获取广告位租赁价格 + let price = ad_space::calculate_lease_price(ad_space, lease_days); + + // 验证支付金额 + assert!(coin::value(&payment) >= price, EInvalidPayment); + + // 如果支付金额超过价格,先退还超出部分 + if (coin::value(&payment) > price) { + let payment_value = coin::value(&payment); + let refund = coin::split(&mut payment, payment_value - price, ctx); + transfer::public_transfer(refund, tx_context::sender(ctx)); + }; + + // 获取平台分成比例 + let platform_ratio = factory::get_platform_ratio(factory); + + // 计算分成金额 + let platform_amount = (price * (platform_ratio as u64)) / 100; + let game_dev_amount = price - platform_amount; // 剩余金额都给游戏开发者 + + // 分配支付金额 + let platform_payment = coin::split(&mut payment, platform_amount, ctx); + let game_dev_payment = coin::split(&mut payment, game_dev_amount, ctx); + + // 销毁可能的零值 Coin + coin::destroy_zero(payment); + + // 将平台收入转给平台管理员 + transfer::public_transfer(platform_payment, factory::get_admin(factory)); + + // 转账给游戏开发者 + transfer::public_transfer(game_dev_payment, ad_space::get_creator(ad_space)); + + // 更新NFT的租赁期限 + nft::renew_lease(nft, lease_days, clock, ctx); + + // 发送续租事件 + event::emit(LeaseRenewed { + ad_space_id: object::id_address(ad_space), + nft_id: object::id_address(nft), + lease_days + }); + } + + #[test_only] + public fun register_game_dev_for_testing( + factory: &mut Factory, + game_dev: address, + ctx: &mut TxContext + ) { + // 将开发者地址注册到Factory中 + factory::register_game_dev(factory, game_dev, ctx); + + // 发送事件 + event::emit(GameDevRegistered { + game_dev + }); + } +} diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard/tests/nft_billboard_tests.move b/move202503/cuidaquan/nft-billboard/nft_billboard/tests/nft_billboard_tests.move new file mode 100644 index 00000000..d7183e76 --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard/tests/nft_billboard_tests.move @@ -0,0 +1,875 @@ +#[test_only] +module nft_billboard::nft_billboard_tests { + use sui::test_scenario::{Self as ts, Scenario}; + use sui::coin::{Self, Coin}; + use sui::sui::SUI; + use sui::clock; + use sui::transfer; + use std::string; + use std::vector; + + // 导入特殊的one_time_witness模块,用于测试使用 + use nft_billboard::nft_billboard; + use nft_billboard::factory::{Self, Factory}; + use nft_billboard::ad_space::{Self, AdSpace}; + use nft_billboard::nft::{Self, AdBoardNFT}; + + // 测试账户 + const ADMIN: address = @0xA; + const GAME_DEV: address = @0xB; + const BUYER: address = @0xC; + + // 测试参数 + const DAILY_PRICE: u64 = 1_000_000_000; // 1 SUI + const LEASE_DAYS: u64 = 30; // 租期30天 + const TEST_PAYMENT: u64 = 50_000_000_000; // 50 SUI + + // Billboard NFT的one-time witness类型 + public struct NFT_BILLBOARD has drop {} + + // 初始化函数 + fun init_test(): Scenario { + let mut scenario = ts::begin(ADMIN); + + // 初始化 + { + // 手动执行初始化步骤 + ts::next_tx(&mut scenario, ADMIN); + { + // 初始化工厂 + factory::init_factory(ts::ctx(&mut scenario)); + }; + }; + + scenario + } + + #[test] + fun test_system_initialization() { + let mut scenario = init_test(); + + // 验证工厂已被共享 + ts::next_tx(&mut scenario, ADMIN); + { + assert!(ts::has_most_recent_shared(), 0); + }; + + ts::end(scenario); + } + + #[test] + fun test_register_game_dev() { + let mut scenario = init_test(); + + // 管理员注册游戏开发者 + ts::next_tx(&mut scenario, ADMIN); + { + let mut factory = ts::take_shared(&scenario); + nft_billboard::register_game_dev(&mut factory, GAME_DEV, ts::ctx(&mut scenario)); + ts::return_shared(factory); + }; + + // 验证游戏开发者已注册 + ts::next_tx(&mut scenario, GAME_DEV); + { + let factory = ts::take_shared(&scenario); + assert!(factory::is_game_dev(&factory, GAME_DEV), 0); + ts::return_shared(factory); + }; + + ts::end(scenario); + } + + #[test] + fun test_create_ad_space() { + let mut scenario = init_test(); + + // 注册游戏开发者 + ts::next_tx(&mut scenario, ADMIN); + { + let mut factory = ts::take_shared(&scenario); + nft_billboard::register_game_dev(&mut factory, GAME_DEV, ts::ctx(&mut scenario)); + ts::return_shared(factory); + }; + + // 创建时钟 + let clock = clock::create_for_testing(ts::ctx(&mut scenario)); + + // 游戏开发者创建广告位 + ts::next_tx(&mut scenario, GAME_DEV); + { + let mut factory = ts::take_shared(&scenario); + + nft_billboard::create_ad_space( + &mut factory, + string::utf8(b"Game123"), + string::utf8(b"Lobby"), + string::utf8(b"16:9"), + DAILY_PRICE, + &clock, + ts::ctx(&mut scenario) + ); + + ts::return_shared(factory); + }; + + // 验证广告位创建成功 + ts::next_tx(&mut scenario, ADMIN); + { + let ad_space = ts::take_shared(&scenario); + assert!(ad_space::is_available(&ad_space), 0); + ts::return_shared(ad_space); + }; + + // 清理 + clock::destroy_for_testing(clock); + ts::end(scenario); + } + + #[test] + fun test_purchase_ad_space() { + let mut scenario = init_test(); + + // 注册游戏开发者 + ts::next_tx(&mut scenario, ADMIN); + { + let mut factory = ts::take_shared(&scenario); + nft_billboard::register_game_dev(&mut factory, GAME_DEV, ts::ctx(&mut scenario)); + ts::return_shared(factory); + }; + + // 创建时钟 + let clock = clock::create_for_testing(ts::ctx(&mut scenario)); + + // 游戏开发者创建广告位 + ts::next_tx(&mut scenario, GAME_DEV); + { + let mut factory = ts::take_shared(&scenario); + + nft_billboard::create_ad_space( + &mut factory, + string::utf8(b"Game123"), + string::utf8(b"Lobby"), + string::utf8(b"16:9"), + DAILY_PRICE, + &clock, + ts::ctx(&mut scenario) + ); + + ts::return_shared(factory); + }; + + // 为买家创建资金 + ts::next_tx(&mut scenario, ADMIN); + { + let coin = coin::mint_for_testing(TEST_PAYMENT, ts::ctx(&mut scenario)); + transfer::public_transfer(coin, BUYER); + }; + + // 买家购买广告位 + ts::next_tx(&mut scenario, BUYER); + { + let mut factory = ts::take_shared(&scenario); + let mut ad_space = ts::take_shared(&scenario); + let payment = ts::take_from_sender>(&scenario); + + nft_billboard::purchase_ad_space( + &mut factory, + &mut ad_space, + payment, + string::utf8(b"TestBrand"), + string::utf8(b"https://example.com/ad.jpg"), + string::utf8(b"https://example.com"), + LEASE_DAYS, + &clock, + 0, // 立即开始 + vector::empty(), // 空的blob_id,表示没有使用Walrus + string::utf8(b"external"), // 使用外部URL + ts::ctx(&mut scenario) + ); + + ts::return_shared(factory); + ts::return_shared(ad_space); + }; + + // 验证买家收到NFT + ts::next_tx(&mut scenario, BUYER); + { + assert!(ts::has_most_recent_for_sender(&scenario), 0); + }; + + // 清理 + clock::destroy_for_testing(clock); + ts::end(scenario); + } + + #[test] + fun test_update_ad_content() { + let mut scenario = init_test(); + + // 注册游戏开发者 + ts::next_tx(&mut scenario, ADMIN); + { + let mut factory = ts::take_shared(&scenario); + nft_billboard::register_game_dev(&mut factory, GAME_DEV, ts::ctx(&mut scenario)); + ts::return_shared(factory); + }; + + // 创建时钟 + let clock = clock::create_for_testing(ts::ctx(&mut scenario)); + + // 游戏开发者创建广告位 + ts::next_tx(&mut scenario, GAME_DEV); + { + let mut factory = ts::take_shared(&scenario); + + nft_billboard::create_ad_space( + &mut factory, + string::utf8(b"Game123"), + string::utf8(b"Lobby"), + string::utf8(b"16:9"), + DAILY_PRICE, + &clock, + ts::ctx(&mut scenario) + ); + + ts::return_shared(factory); + }; + + // 为买家创建资金 + ts::next_tx(&mut scenario, ADMIN); + { + let coin = coin::mint_for_testing(TEST_PAYMENT, ts::ctx(&mut scenario)); + transfer::public_transfer(coin, BUYER); + }; + + // 买家购买广告位 + ts::next_tx(&mut scenario, BUYER); + { + let mut factory = ts::take_shared(&scenario); + let mut ad_space = ts::take_shared(&scenario); + let payment = ts::take_from_sender>(&scenario); + + nft_billboard::purchase_ad_space( + &mut factory, + &mut ad_space, + payment, + string::utf8(b"TestBrand"), + string::utf8(b"https://example.com/ad.jpg"), + string::utf8(b"https://example.com"), + LEASE_DAYS, + &clock, + 0, // 立即开始 + vector::empty(), // 空的blob_id,表示没有使用Walrus + string::utf8(b"external"), // 使用外部URL + ts::ctx(&mut scenario) + ); + + ts::return_shared(factory); + ts::return_shared(ad_space); + }; + + // 买家更新广告内容 + ts::next_tx(&mut scenario, BUYER); + { + let mut nft = ts::take_from_sender(&scenario); + + nft_billboard::update_ad_content( + &mut nft, + string::utf8(b"https://example.com/new_ad.jpg"), + vector::empty(), // 空的blob_id,表示没有使用Walrus + string::utf8(b"external"), // 使用外部URL + &clock, + ts::ctx(&mut scenario) + ); + + ts::return_to_sender(&scenario, nft); + }; + + // 清理 + clock::destroy_for_testing(clock); + ts::end(scenario); + } + + #[test] + fun test_update_platform_ratio() { + let mut scenario = init_test(); + + // 管理员更新平台分成比例 + ts::next_tx(&mut scenario, ADMIN); + { + let mut factory = ts::take_shared(&scenario); + + nft_billboard::update_platform_ratio( + &mut factory, + 20, // 更新为20% + ts::ctx(&mut scenario) + ); + + ts::return_shared(factory); + }; + + // 验证平台分成比例已更新 + ts::next_tx(&mut scenario, ADMIN); + { + let factory = ts::take_shared(&scenario); + assert!(factory::get_platform_ratio(&factory) == 20, 0); + ts::return_shared(factory); + }; + + ts::end(scenario); + } + + #[test] + fun test_update_ad_space_price() { + let mut scenario = init_test(); + + // 注册游戏开发者 + ts::next_tx(&mut scenario, ADMIN); + { + let mut factory = ts::take_shared(&scenario); + nft_billboard::register_game_dev(&mut factory, GAME_DEV, ts::ctx(&mut scenario)); + ts::return_shared(factory); + }; + + // 创建时钟 + let clock = clock::create_for_testing(ts::ctx(&mut scenario)); + + // 游戏开发者创建广告位 + ts::next_tx(&mut scenario, GAME_DEV); + { + let mut factory = ts::take_shared(&scenario); + + nft_billboard::create_ad_space( + &mut factory, + string::utf8(b"Game123"), + string::utf8(b"Lobby"), + string::utf8(b"16:9"), + DAILY_PRICE, + &clock, + ts::ctx(&mut scenario) + ); + + ts::return_shared(factory); + }; + + // 游戏开发者更新广告位价格 + ts::next_tx(&mut scenario, GAME_DEV); + { + let mut ad_space = ts::take_shared(&scenario); + + nft_billboard::update_ad_space_price( + &mut ad_space, + DAILY_PRICE * 2, // 翻倍价格 + ts::ctx(&mut scenario) + ); + + ts::return_shared(ad_space); + }; + + // 验证广告位价格已更新 + ts::next_tx(&mut scenario, ADMIN); + { + let ad_space = ts::take_shared(&scenario); + assert!(ad_space::get_fixed_price(&ad_space) == DAILY_PRICE * 2, 0); + ts::return_shared(ad_space); + }; + + // 清理 + clock::destroy_for_testing(clock); + ts::end(scenario); + } + + #[test] + fun test_remove_game_dev() { + let mut scenario = init_test(); + + // 注册游戏开发者 + ts::next_tx(&mut scenario, ADMIN); + { + let mut factory = ts::take_shared(&scenario); + nft_billboard::register_game_dev(&mut factory, GAME_DEV, ts::ctx(&mut scenario)); + ts::return_shared(factory); + }; + + // 验证游戏开发者已注册 + ts::next_tx(&mut scenario, GAME_DEV); + { + let factory = ts::take_shared(&scenario); + assert!(factory::is_game_dev(&factory, GAME_DEV), 0); + ts::return_shared(factory); + }; + + // 管理员移除游戏开发者 + ts::next_tx(&mut scenario, ADMIN); + { + let mut factory = ts::take_shared(&scenario); + nft_billboard::remove_game_dev(&mut factory, GAME_DEV, ts::ctx(&mut scenario)); + ts::return_shared(factory); + }; + + // 验证游戏开发者已被移除 + ts::next_tx(&mut scenario, GAME_DEV); + { + let factory = ts::take_shared(&scenario); + assert!(!factory::is_game_dev(&factory, GAME_DEV), 0); + ts::return_shared(factory); + }; + + ts::end(scenario); + } + + #[test] + #[expected_failure(abort_code = factory::EGameDevNotFound)] + fun test_remove_nonexistent_game_dev() { + let mut scenario = init_test(); + + // 管理员尝试移除不存在的游戏开发者,预期失败 + ts::next_tx(&mut scenario, ADMIN); + { + let mut factory = ts::take_shared(&scenario); + nft_billboard::remove_game_dev(&mut factory, GAME_DEV, ts::ctx(&mut scenario)); + ts::return_shared(factory); + }; + + ts::end(scenario); + } + + #[test] + #[expected_failure(abort_code = nft_billboard::ENotAdmin)] + fun test_remove_game_dev_not_admin() { + let mut scenario = init_test(); + + // 注册游戏开发者 + ts::next_tx(&mut scenario, ADMIN); + { + let mut factory = ts::take_shared(&scenario); + nft_billboard::register_game_dev(&mut factory, GAME_DEV, ts::ctx(&mut scenario)); + ts::return_shared(factory); + }; + + // 非管理员尝试移除游戏开发者,预期失败 + ts::next_tx(&mut scenario, GAME_DEV); + { + let mut factory = ts::take_shared(&scenario); + nft_billboard::remove_game_dev(&mut factory, GAME_DEV, ts::ctx(&mut scenario)); + ts::return_shared(factory); + }; + + ts::end(scenario); + } + + #[test] + fun test_renew_lease() { + let mut scenario = init_test(); + + // 注册游戏开发者 + ts::next_tx(&mut scenario, ADMIN); + { + let mut factory = ts::take_shared(&scenario); + nft_billboard::register_game_dev(&mut factory, GAME_DEV, ts::ctx(&mut scenario)); + ts::return_shared(factory); + }; + + // 创建时钟 + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + + // 游戏开发者创建广告位 + ts::next_tx(&mut scenario, GAME_DEV); + { + let mut factory = ts::take_shared(&scenario); + + nft_billboard::create_ad_space( + &mut factory, + string::utf8(b"Game123"), + string::utf8(b"Lobby"), + string::utf8(b"16:9"), + DAILY_PRICE, + &clock, + ts::ctx(&mut scenario) + ); + + ts::return_shared(factory); + }; + + // 为买家创建资金 + ts::next_tx(&mut scenario, ADMIN); + { + let coin = coin::mint_for_testing(TEST_PAYMENT * 2, ts::ctx(&mut scenario)); + transfer::public_transfer(coin, BUYER); + }; + + // 买家购买广告位 + ts::next_tx(&mut scenario, BUYER); + { + let mut factory = ts::take_shared(&scenario); + let mut ad_space = ts::take_shared(&scenario); + let payment = ts::take_from_sender>(&scenario); + + nft_billboard::purchase_ad_space( + &mut factory, + &mut ad_space, + payment, + string::utf8(b"TestBrand"), + string::utf8(b"https://example.com/ad.jpg"), + string::utf8(b"https://example.com"), + LEASE_DAYS, + &clock, + 0, // 立即开始 + vector::empty(), // 空的blob_id,表示没有使用Walrus + string::utf8(b"external"), // 使用外部URL + ts::ctx(&mut scenario) + ); + + ts::return_shared(factory); + ts::return_shared(ad_space); + }; + + // 修改:不再设置时钟到过期后的时间 + // 在NFT仍然有效期内进行续租 + // 偏移时间为原租期的一半 + let lease_seconds = LEASE_DAYS * 24 * 60 * 60; + clock::increment_for_testing(&mut clock, lease_seconds * 500); // 租期的一半时间 + + // 买家续租广告位 + ts::next_tx(&mut scenario, BUYER); + { + let mut factory = ts::take_shared(&scenario); + let mut ad_space = ts::take_shared(&scenario); + let mut nft = ts::take_from_sender(&scenario); + let payment = ts::take_from_sender>(&scenario); + + nft_billboard::renew_lease( + &mut factory, + &mut ad_space, + &mut nft, + payment, + LEASE_DAYS, + &clock, + ts::ctx(&mut scenario) + ); + + ts::return_shared(factory); + ts::return_shared(ad_space); + ts::return_to_sender(&scenario, nft); + }; + + // 验证续租后的NFT状态 + ts::next_tx(&mut scenario, BUYER); + { + let nft = ts::take_from_sender(&scenario); + assert!(nft::get_lease_status(&nft, &clock), 0); + ts::return_to_sender(&scenario, nft); + }; + + // 清理 + clock::destroy_for_testing(clock); + ts::end(scenario); + } + + #[test] + fun test_delete_ad_space() { + let mut scenario = init_test(); + + // 注册游戏开发者 + ts::next_tx(&mut scenario, ADMIN); + { + let mut factory = ts::take_shared(&scenario); + nft_billboard::register_game_dev(&mut factory, GAME_DEV, ts::ctx(&mut scenario)); + ts::return_shared(factory); + }; + + // 创建时钟 + let clock = clock::create_for_testing(ts::ctx(&mut scenario)); + + // 游戏开发者创建广告位 + ts::next_tx(&mut scenario, GAME_DEV); + { + let mut factory = ts::take_shared(&scenario); + + nft_billboard::create_ad_space( + &mut factory, + string::utf8(b"Game123"), + string::utf8(b"Lobby"), + string::utf8(b"16:9"), + DAILY_PRICE, + &clock, + ts::ctx(&mut scenario) + ); + + ts::return_shared(factory); + }; + + // 验证广告位创建成功 + ts::next_tx(&mut scenario, ADMIN); + { + let ad_space = ts::take_shared(&scenario); + assert!(ad_space::is_available(&ad_space), 0); + ts::return_shared(ad_space); + }; + + // 游戏开发者删除广告位 + ts::next_tx(&mut scenario, GAME_DEV); + { + let mut factory = ts::take_shared(&scenario); + let ad_space = ts::take_shared(&scenario); + + nft_billboard::delete_ad_space( + &mut factory, + ad_space, + ts::ctx(&mut scenario) + ); + + ts::return_shared(factory); + }; + + // 验证广告位已被删除(不存在共享对象) + ts::next_tx(&mut scenario, ADMIN); + { + assert!(!ts::has_most_recent_shared(), 0); + }; + + // 清理 + clock::destroy_for_testing(clock); + ts::end(scenario); + } + + #[test] + #[expected_failure(abort_code = nft_billboard::ENotAdSpaceCreator)] + fun test_delete_ad_space_not_creator() { + let mut scenario = init_test(); + + // 注册游戏开发者 + ts::next_tx(&mut scenario, ADMIN); + { + let mut factory = ts::take_shared(&scenario); + nft_billboard::register_game_dev(&mut factory, GAME_DEV, ts::ctx(&mut scenario)); + ts::return_shared(factory); + }; + + // 创建时钟 + let clock = clock::create_for_testing(ts::ctx(&mut scenario)); + + // 游戏开发者创建广告位 + ts::next_tx(&mut scenario, GAME_DEV); + { + let mut factory = ts::take_shared(&scenario); + + nft_billboard::create_ad_space( + &mut factory, + string::utf8(b"Game123"), + string::utf8(b"Lobby"), + string::utf8(b"16:9"), + DAILY_PRICE, + &clock, + ts::ctx(&mut scenario) + ); + + ts::return_shared(factory); + }; + + // 非创建者尝试删除广告位,预期失败 + ts::next_tx(&mut scenario, BUYER); + { + let mut factory = ts::take_shared(&scenario); + let ad_space = ts::take_shared(&scenario); + + // 这里会失败并中止测试,所以不需要返回对象 + nft_billboard::delete_ad_space( + &mut factory, + ad_space, + ts::ctx(&mut scenario) + ); + + // 由于上面的调用会失败,以下代码不会执行 + // 因此不需要返回ad_space (ad_space已经被移动) + ts::return_shared(factory); + }; + + // 清理 + clock::destroy_for_testing(clock); + ts::end(scenario); + } + + #[test] + fun test_update_and_delete_ad_space() { + let mut scenario = init_test(); + + // 注册游戏开发者 + ts::next_tx(&mut scenario, ADMIN); + { + let mut factory = ts::take_shared(&scenario); + nft_billboard::register_game_dev(&mut factory, GAME_DEV, ts::ctx(&mut scenario)); + ts::return_shared(factory); + }; + + // 创建时钟 + let clock = clock::create_for_testing(ts::ctx(&mut scenario)); + + // 游戏开发者创建广告位 + ts::next_tx(&mut scenario, GAME_DEV); + { + let mut factory = ts::take_shared(&scenario); + + nft_billboard::create_ad_space( + &mut factory, + string::utf8(b"Game123"), + string::utf8(b"Lobby"), + string::utf8(b"16:9"), + DAILY_PRICE, + &clock, + ts::ctx(&mut scenario) + ); + + ts::return_shared(factory); + }; + + // 游戏开发者更新广告位价格 + ts::next_tx(&mut scenario, GAME_DEV); + { + let mut ad_space = ts::take_shared(&scenario); + + nft_billboard::update_ad_space_price( + &mut ad_space, + DAILY_PRICE * 3, // 更新为三倍价格 + ts::ctx(&mut scenario) + ); + + ts::return_shared(ad_space); + }; + + // 验证广告位价格已更新 + ts::next_tx(&mut scenario, ADMIN); + { + let ad_space = ts::take_shared(&scenario); + assert!(ad_space::get_fixed_price(&ad_space) == DAILY_PRICE * 3, 0); + ts::return_shared(ad_space); + }; + + // 游戏开发者删除广告位 + ts::next_tx(&mut scenario, GAME_DEV); + { + let mut factory = ts::take_shared(&scenario); + let ad_space = ts::take_shared(&scenario); + + nft_billboard::delete_ad_space( + &mut factory, + ad_space, + ts::ctx(&mut scenario) + ); + + ts::return_shared(factory); + }; + + // 验证广告位已被删除 + ts::next_tx(&mut scenario, ADMIN); + { + assert!(!ts::has_most_recent_shared(), 0); + }; + + // 清理 + clock::destroy_for_testing(clock); + ts::end(scenario); + } + + #[test] + #[expected_failure(abort_code = nft_billboard::ENftExpired)] + fun test_renew_expired_lease() { + let mut scenario = init_test(); + + // 注册游戏开发者 + ts::next_tx(&mut scenario, ADMIN); + { + let mut factory = ts::take_shared(&scenario); + nft_billboard::register_game_dev(&mut factory, GAME_DEV, ts::ctx(&mut scenario)); + ts::return_shared(factory); + }; + + // 创建时钟 + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + + // 游戏开发者创建广告位 + ts::next_tx(&mut scenario, GAME_DEV); + { + let mut factory = ts::take_shared(&scenario); + + nft_billboard::create_ad_space( + &mut factory, + string::utf8(b"Game123"), + string::utf8(b"Lobby"), + string::utf8(b"16:9"), + DAILY_PRICE, + &clock, + ts::ctx(&mut scenario) + ); + + ts::return_shared(factory); + }; + + // 为买家创建资金 + ts::next_tx(&mut scenario, ADMIN); + { + let coin = coin::mint_for_testing(TEST_PAYMENT * 2, ts::ctx(&mut scenario)); + transfer::public_transfer(coin, BUYER); + }; + + // 买家购买广告位 + ts::next_tx(&mut scenario, BUYER); + { + let mut factory = ts::take_shared(&scenario); + let mut ad_space = ts::take_shared(&scenario); + let payment = ts::take_from_sender>(&scenario); + + nft_billboard::purchase_ad_space( + &mut factory, + &mut ad_space, + payment, + string::utf8(b"TestBrand"), + string::utf8(b"https://example.com/ad.jpg"), + string::utf8(b"https://example.com"), + LEASE_DAYS, + &clock, + 0, // 立即开始 + vector::empty(), // 空的blob_id,表示没有使用Walrus + string::utf8(b"external"), // 使用外部URL + ts::ctx(&mut scenario) + ); + + ts::return_shared(factory); + ts::return_shared(ad_space); + }; + + // 设置时钟到期后的时间 + let lease_seconds = LEASE_DAYS * 24 * 60 * 60; + clock::increment_for_testing(&mut clock, lease_seconds * 1000 + 1000); // 加1秒确保过期 + + // 买家尝试续租已过期的广告位,预期失败 + ts::next_tx(&mut scenario, BUYER); + { + let mut factory = ts::take_shared(&scenario); + let mut ad_space = ts::take_shared(&scenario); + let mut nft = ts::take_from_sender(&scenario); + let payment = ts::take_from_sender>(&scenario); + + // 这个调用应该失败,因为NFT已过期 + nft_billboard::renew_lease( + &mut factory, + &mut ad_space, + &mut nft, + payment, + LEASE_DAYS, + &clock, + ts::ctx(&mut scenario) + ); + + // 以下代码不会执行,因为上面的调用会失败 + ts::return_shared(factory); + ts::return_shared(ad_space); + ts::return_to_sender(&scenario, nft); + }; + + // 清理 + clock::destroy_for_testing(clock); + ts::end(scenario); + } +} \ No newline at end of file diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/.env.development b/move202503/cuidaquan/nft-billboard/nft_billboard_web/.env.development new file mode 100644 index 00000000..4f22dfb0 --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/.env.development @@ -0,0 +1,16 @@ +# 合约配置 +REACT_APP_CONTRACT_PACKAGE_ID=0x13e525a35fb7dfeaf10b8d85bbe0b61d0a71d8234164a8ce9357b935f1e07201 +REACT_APP_FACTORY_OBJECT_ID=0x5cc850109320d6282bd723e4d8f18c4d218ce15989738c6421f84b04fd9f22ec + +# 网络配置 +REACT_APP_DEFAULT_NETWORK=testnet + +# 开发配置 +REACT_APP_USE_MOCK_DATA=false +REACT_APP_ENV=development + +# API接口配置 +REACT_APP_API_TIMEOUT=30000 + +# Walrus配置 +REACT_APP_WALRUS_ENVIRONMENT=testnet \ No newline at end of file diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/.env.production b/move202503/cuidaquan/nft-billboard/nft_billboard_web/.env.production new file mode 100644 index 00000000..bd5c7840 --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/.env.production @@ -0,0 +1,17 @@ +# 合约配置 +# 这里需要替换为生产环境的合约地址 +REACT_APP_CONTRACT_PACKAGE_ID=0x13e525a35fb7dfeaf10b8d85bbe0b61d0a71d8234164a8ce9357b935f1e07201 +REACT_APP_FACTORY_OBJECT_ID=0x5cc850109320d6282bd723e4d8f18c4d218ce15989738c6421f84b04fd9f22ec + +# 网络配置 +REACT_APP_DEFAULT_NETWORK=testnet + +# 开发配置 +REACT_APP_USE_MOCK_DATA=false +REACT_APP_ENV=production + +# API接口配置 +REACT_APP_API_TIMEOUT=30000 + +# Walrus配置 +REACT_APP_WALRUS_ENVIRONMENT=testnet \ No newline at end of file diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/.gitignore b/move202503/cuidaquan/nft-billboard/nft_billboard_web/.gitignore new file mode 100644 index 00000000..33071c6b --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/.gitignore @@ -0,0 +1,19 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/.npmrc b/move202503/cuidaquan/nft-billboard/nft_billboard_web/.npmrc new file mode 100644 index 00000000..8a0279c1 --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/.npmrc @@ -0,0 +1,3 @@ +legacy-peer-deps=true +dedupe-peer-deps=true +strict-peer-dependencies=false diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/README.md b/move202503/cuidaquan/nft-billboard/nft_billboard_web/README.md new file mode 100644 index 00000000..bea0fec4 --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/README.md @@ -0,0 +1,172 @@ +# NFT Billboards - 前端应用 + +## 概述 + +NFT Billboards前端应用是一个基于React和TypeScript构建的现代化Web应用,为用户提供直观的界面来浏览、购买和管理区块链广告位NFT。该应用与Sui区块链智能合约无缝集成,支持钱包连接、交易签名和NFT内容管理。 + +## 核心功能 + +- **广告位浏览与购买**:浏览可用广告位,查看详情并购买 +- **NFT管理**:管理已购买的广告牌NFT,更新内容和续租 +- **开发者工具**:游戏开发者可创建和管理广告位 +- **管理员功能**:平台管理员可注册开发者和设置系统参数 +- **钱包集成**:支持Sui钱包连接和交易签名 +- **内容存储**:集成Walrus去中心化存储网络 + +## 技术栈 + +- **前端框架**:React 18 + TypeScript +- **UI组件库**:Ant Design +- **状态管理**:React Query + Context API +- **路由**:React Router +- **区块链交互**:@mysten/sui + @mysten/dapp-kit +- **样式**:SCSS +- **构建工具**:Vite + +## 快速开始 + +### 环境要求 + +- Node.js 16+ +- NPM 8+ +- Sui钱包浏览器扩展 + +### 安装与运行 + +```bash +# 安装依赖 +npm install + +# 启动开发服务器 +npm start + +# 构建生产版本 +npm run build +``` + +开发服务器将在 [http://localhost:3000](http://localhost:3000) 启动。 + +## 项目结构 + +``` +src/ +├── assets/ # 静态资源 +├── components/ # 组件 +│ ├── adSpace/ # 广告位相关组件 +│ ├── common/ # 通用组件 +│ ├── layout/ # 布局组件 +│ ├── nft/ # NFT相关组件 +│ └── walrus/ # Walrus存储相关组件 +├── config/ # 配置文件 +├── hooks/ # 自定义钩子 +├── pages/ # 页面组件 +├── types/ # TypeScript类型定义 +├── utils/ # 工具函数 +│ ├── auth.ts # 权限验证 +│ ├── contract.ts # 合约交互 +│ ├── env.ts # 环境配置工具 +│ └── format.ts # 数据格式化 +└── App.tsx # 应用入口 +``` + +## 主要页面 + +- **首页 (Home.tsx)**:系统介绍和功能导航 +- **广告位列表 (AdSpaces.tsx)**:浏览和筛选可用广告位 +- **广告位详情 (AdSpaceDetail.tsx)**:查看广告位详情和购买 +- **我的NFT (MyNFTs.tsx)**:管理已购买的NFT +- **NFT详情 (NFTDetail.tsx)**:查看NFT详情和更新内容 +- **管理页面 (Manage.tsx)**:开发者和管理员功能 + +## 环境配置 + +项目使用环境变量配置关键参数,支持开发和生产环境: + +``` +# .env.development / .env.production +REACT_APP_CONTRACT_PACKAGE_ID=0x... # 合约包ID +REACT_APP_CONTRACT_MODULE_NAME=nft_billboard # 合约模块名 +REACT_APP_FACTORY_OBJECT_ID=0x... # 工厂对象ID +REACT_APP_CLOCK_ID=0x6 # 时钟对象ID + +# 网络配置 +REACT_APP_DEFAULT_NETWORK=testnet # mainnet, testnet + +# 环境配置 +REACT_APP_ENV=development/production # 由npm脚本通过cross-env设置 + +# Walrus配置 +REACT_APP_WALRUS_ENVIRONMENT=testnet +REACT_APP_WALRUS_AGGREGATOR_URL_MAINNET=https://walrus.globalstake.io/v1/blobs/by-object-id/ +REACT_APP_WALRUS_AGGREGATOR_URL_TESTNET=https://aggregator.walrus-testnet.walrus.space/v1/blobs/by-object-id/ +``` + +使用`npm start`启动开发环境,使用`npm run build`构建生产环境。 + +## 网络支持 + +应用支持连接到多个Sui网络: + +- **mainnet**:Sui主网 +- **testnet**:Sui测试网 + +默认网络可通过环境变量`REACT_APP_DEFAULT_NETWORK`配置。 + +## 用户界面 + +### 广告位展示 + +- 卡片式布局展示广告位信息 +- 支持多种尺寸规格:小(128x128)、中(256x256)、大(512x512)和超大(1024x512) +- 价格显示为365天租期的总价 +- 详情页展示完整信息和购买选项 + +### NFT管理 + +- 网格布局展示用户拥有的NFT +- 支持内容更新和续租操作 +- 显示租约状态和到期时间 +- 详情页提供完整的NFT信息和管理选项 + +### 管理中心 + +- 标签式界面区分不同功能 +- 创建广告位表单 +- 广告位管理列表 +- 开发者管理界面 + + +## 优化与改进 + +最新版本包含以下优化: + +1. **用户体验改进** + - 响应式设计,支持多种设备 + - 加载状态和错误处理优化 + - 统一的价格展示和说明 + +2. **性能优化** + - 组件懒加载 + - 数据缓存和状态管理优化 + - 图片加载优化 + +3. **安全增强** + - 输入验证和参数检查 + - 交易确认流程优化 + - 权限验证逻辑增强 + +## 开发指南 + +### 添加新页面 + +1. 在`src/pages`目录创建新页面组件 +2. 在`App.tsx`中添加路由配置 +3. 更新导航菜单 + + +## 部署指南 + +1. 更新环境配置文件,设置正确的合约参数 +2. 构建生产版本:`npm run build` +3. 部署到静态网站托管服务,如Vercel、Netlify等 + diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/babel.config.js b/move202503/cuidaquan/nft-billboard/nft_billboard_web/babel.config.js new file mode 100644 index 00000000..243a801f --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/babel.config.js @@ -0,0 +1,8 @@ +module.exports = { + presets: ['react-app'], + env: { + production: { + plugins: ['transform-remove-console'] + } + } +}; diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/config-overrides.js b/move202503/cuidaquan/nft-billboard/nft_billboard_web/config-overrides.js new file mode 100644 index 00000000..7b308421 --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/config-overrides.js @@ -0,0 +1,37 @@ +const { override, addBabelPlugin } = require('customize-cra'); + +module.exports = override( + // 添加babel插件移除console语句 + process.env.NODE_ENV === 'production' && + addBabelPlugin('transform-remove-console'), + + // 使用TerserPlugin移除console语句(双重保障) + (config) => { + if (process.env.NODE_ENV === 'production') { + if (config.optimization && config.optimization.minimizer) { + const terserPluginIndex = config.optimization.minimizer.findIndex( + minimizer => minimizer.constructor.name === 'TerserPlugin' + ); + + if (terserPluginIndex !== -1) { + const terserPlugin = config.optimization.minimizer[terserPluginIndex]; + + if (!terserPlugin.options) { + terserPlugin.options = {}; + } + + if (!terserPlugin.options.terserOptions) { + terserPlugin.options.terserOptions = {}; + } + + if (!terserPlugin.options.terserOptions.compress) { + terserPlugin.options.terserOptions.compress = {}; + } + + terserPlugin.options.terserOptions.compress.drop_console = true; + } + } + } + return config; + } +); diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/package.json b/move202503/cuidaquan/nft-billboard/nft_billboard_web/package.json new file mode 100644 index 00000000..b3ca4718 --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/package.json @@ -0,0 +1,80 @@ +{ + "name": "nft_billboard_web", + "version": "0.1.0", + "private": true, + "dependencies": { + "@ant-design/icons": "^6.0.0", + "@mysten/dapp-kit": "^0.15.6", + "@mysten/sui": "^1.27.1", + "@mysten/walrus": "^0.0.14", + "@mysten/walrus-wasm": "^0.0.5", + "@tanstack/react-query": "^5.28.2", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^13.5.0", + "@types/jest": "^27.5.2", + "antd": "^5.24.6", + "dayjs": "^1.11.13", + "i18next": "^25.0.1", + "i18next-browser-languagedetector": "^8.0.5", + "moment": "^2.30.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-i18next": "^15.5.1", + "react-router-dom": "^7.4.1", + "react-scripts": "5.0.1", + "sass": "^1.86.2", + "web-vitals": "^2.1.4" + }, + "scripts": { + "start": "cross-env REACT_APP_ENV=development react-scripts start", + "start:prod": "cross-env REACT_APP_ENV=production react-scripts start", + "build": "cross-env NODE_ENV=production REACT_APP_ENV=production react-app-rewired build", + "build:dev": "cross-env NODE_ENV=development REACT_APP_ENV=development react-app-rewired build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@types/node": "^22.14.1", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@types/react-router-dom": "^5.3.3", + "ajv": "^8.12.0", + "ajv-keywords": "^5.1.0", + "babel-plugin-transform-remove-console": "^6.9.4", + "cross-env": "^7.0.3", + "customize-cra": "^1.0.0", + "react-app-rewired": "^2.2.1", + "typescript": "^4.9.5" + }, + "overrides": { + "typescript": "^4.9.5", + "react-i18next": { + "typescript": "^4.9.5" + }, + "@mysten/sui": "^1.27.1" + }, + "resolutions": { + "@mysten/sui": "^1.27.1", + "typescript": "^4.9.5" + } +} diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/public/favicon.ico b/move202503/cuidaquan/nft-billboard/nft_billboard_web/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a11777cc471a4344702741ab1c8a588998b1311a GIT binary patch literal 3870 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 baj7?x%VD!5Cxk2YbYLNVeiXvvpMCWYo=by@ literal 0 HcmV?d00001 diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/public/index.html b/move202503/cuidaquan/nft-billboard/nft_billboard_web/public/index.html new file mode 100644 index 00000000..60357bc5 --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/public/index.html @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + NFT Billboards + + + +
+ + + diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/public/logo.svg b/move202503/cuidaquan/nft-billboard/nft_billboard_web/public/logo.svg new file mode 100644 index 00000000..55253ade --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/public/logo.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/public/logo192.png b/move202503/cuidaquan/nft-billboard/nft_billboard_web/public/logo192.png new file mode 100644 index 0000000000000000000000000000000000000000..fc44b0a3796c0e0a64c3d858ca038bd4570465d9 GIT binary patch literal 5347 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 b{|{}U^kRn2wo}j7|0ATu<;8xA7zX}7|B6mN literal 0 HcmV?d00001 diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/public/manifest.json b/move202503/cuidaquan/nft-billboard/nft_billboard_web/public/manifest.json new file mode 100644 index 00000000..d30a63a7 --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/public/manifest.json @@ -0,0 +1,30 @@ +{ + "short_name": "链上广告牌", + "name": "NFT Billboards - 重新定义区块链广告的未来", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + }, + { + "src": "logo.svg", + "type": "image/svg+xml", + "sizes": "any" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/public/robots.txt b/move202503/cuidaquan/nft-billboard/nft_billboard_web/public/robots.txt new file mode 100644 index 00000000..e9e57dc4 --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/App.css b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/App.css new file mode 100644 index 00000000..74b5e053 --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/App.css @@ -0,0 +1,38 @@ +.App { + text-align: center; +} + +.App-logo { + height: 40vmin; + pointer-events: none; +} + +@media (prefers-reduced-motion: no-preference) { + .App-logo { + animation: App-logo-spin infinite 20s linear; + } +} + +.App-header { + background-color: #282c34; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: calc(10px + 2vmin); + color: white; +} + +.App-link { + color: #61dafb; +} + +@keyframes App-logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/App.scss b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/App.scss new file mode 100644 index 00000000..55b8705a --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/App.scss @@ -0,0 +1,274 @@ +/* Antd 默认已经内置了样式,不需要额外导入 */ +/* 全局样式 */ + +// 导入字体 +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Roboto+Mono:wght@400;500&display=swap'); + +// 定义全局 CSS 变量 +:root { + --primary: #4e63ff; + --primary-light: #6e7eff; + --primary-dark: #3a4edb; + --secondary: #6e56cf; + --secondary-light: #8a74e3; + --secondary-dark: #5642a6; + + --text-dark: #101432; + --text-medium: #505780; + --text-light: #9098b6; + + --background: #fbfcff; + --background-content: #ffffff; + + --border-radius-sm: 4px; + --border-radius-md: 8px; + --border-radius-lg: 16px; + + --box-shadow-sm: 0 2px 10px rgba(0, 0, 0, 0.05); + --box-shadow-md: 0 5px 15px rgba(0, 0, 0, 0.08); + --box-shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.12); + + --primary-gradient: linear-gradient(90deg, var(--primary), var(--secondary)); + --primary-gradient-hover: linear-gradient(90deg, #5a6eff, #7a62d9); + + --animation-speed-slow: 0.5s; + --animation-speed-normal: 0.3s; + --animation-speed-fast: 0.15s; +} + +body { + margin: 0; + padding: 0; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background-color: var(--background); + color: var(--text-dark); +} + +.app-container { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.site-layout-content { + padding: 24px; + min-height: calc(100vh - 64px - 70px); +} + +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 300px; + + p { + margin-top: 16px; + color: #666; + } +} + +/* 基础样式重置 */ +code, pre { + font-family: 'Roboto Mono', source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; +} + +/* 通用样式 */ +.text-center { + text-align: center; +} + +.mt-1 { + margin-top: 8px; +} + +.mt-2 { + margin-top: 16px; +} + +.mt-3 { + margin-top: 24px; +} + +.mb-1 { + margin-bottom: 8px; +} + +.mb-2 { + margin-bottom: 16px; +} + +.mb-3 { + margin-bottom: 24px; +} + +.ml-1 { + margin-left: 8px; +} + +.mr-1 { + margin-right: 8px; +} + +/* 页面容器 */ +.page-container { + padding: 24px 0; +} + +.section-title { + margin-bottom: 24px; + + h2 { + margin-bottom: 8px; + } + + p { + color: rgba(0, 0, 0, 0.45); + } +} + +/* 栅格系统 */ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 24px; +} + +/* 响应式调整 */ +@media (max-width: 768px) { + .page-container { + padding: 16px 0; + } + + .grid { + grid-template-columns: 1fr; + } +} + +// 全局滚动条样式 +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #f0f2f5; + border-radius: 10px; +} + +::-webkit-scrollbar-thumb { + background: #c0c6d9; + border-radius: 10px; + + &:hover { + background: #9aa0b9; + } +} + +// 全局表单元素样式 +.ant-input, +.ant-select-selector, +.ant-picker, +.ant-input-number, +.ant-input-affix-wrapper { + border-radius: var(--border-radius-md) !important; + border-color: #e0e6f5 !important; + + &:hover, &:focus, &-focused { + border-color: var(--primary) !important; + box-shadow: 0 0 0 2px rgba(78, 99, 255, 0.1) !important; + } +} + +// 全局按钮样式增强 +.ant-btn { + border-radius: var(--border-radius-md); + font-weight: 500; + transition: all var(--animation-speed-normal); + + &.ant-btn-primary { + background: var(--primary); + border: none; + box-shadow: 0 4px 10px rgba(78, 99, 255, 0.2); + + &:hover { + background: var(--primary-light); + transform: translateY(-2px); + box-shadow: 0 6px 15px rgba(78, 99, 255, 0.3); + } + } + + &.ant-btn-gradient { + background: var(--primary-gradient); + border: none; + color: white; + box-shadow: 0 4px 10px rgba(78, 99, 255, 0.2); + + &:hover { + background: var(--primary-gradient-hover); + transform: translateY(-2px); + box-shadow: 0 6px 15px rgba(78, 99, 255, 0.3); + } + } +} + +// 强调文本效果 +.gradient-text { + background: var(--primary-gradient); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + display: inline-block; +} + +// 卡片样式增强 +.ant-card { + border-radius: var(--border-radius-lg); + overflow: hidden; + transition: all var(--animation-speed-normal); + border: none; + box-shadow: var(--box-shadow-sm); + + &:hover { + box-shadow: var(--box-shadow-md); + transform: translateY(-5px); + } +} + +// 全局标题样式 +h1, h2, h3, h4, h5, h6 { + color: var(--text-dark); + font-weight: 700; +} + +// 添加一些共用动画 +@keyframes pulse { + 0% { transform: scale(1); } + 50% { transform: scale(1.05); } + 100% { transform: scale(1); } +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideUp { + from { transform: translateY(20px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.fade-in { + animation: fadeIn var(--animation-speed-normal) ease-in-out; +} + +.slide-up { + animation: slideUp var(--animation-speed-normal) ease-in-out; +} + +.pulse { + animation: pulse 2s infinite ease-in-out; +} \ No newline at end of file diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/App.tsx b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/App.tsx new file mode 100644 index 00000000..dba20e75 --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/App.tsx @@ -0,0 +1,81 @@ +import React, { useEffect } from 'react'; +import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +import { SuiClientProvider, WalletProvider } from '@mysten/dapp-kit'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { getFullnodeUrl } from '@mysten/sui/client'; +import { DEFAULT_NETWORK } from './config/config'; + +// 布局 +import MainLayout from './components/layout/MainLayout'; + +// 页面 +import HomePage from './pages/Home'; +import AdSpacesPage from './pages/AdSpaces'; +import AdSpaceDetailPage from './pages/AdSpaceDetail'; +import PurchaseAdSpacePage from './pages/PurchaseAdSpace'; +import MyNFTsPage from './pages/MyNFTs'; +import NFTDetailPage from './pages/NFTDetail'; +import ManagePage from './pages/Manage'; +import NotFoundPage from './pages/NotFound'; + +// 全局样式 +import './App.scss'; +import '@mysten/dapp-kit/dist/index.css'; +import './styles/AdSpaceDetailFix.css'; + +const queryClient = new QueryClient(); + +// 配置网络 +const networks = { + mainnet: { url: getFullnodeUrl('mainnet') }, + testnet: { url: getFullnodeUrl('testnet') }, + devnet: { url: getFullnodeUrl('devnet') }, + localnet: { url: 'http://localhost:9000' }, +}; + +// 推荐:所有页面/组件的链上数据请求都用 react-query 包裹 contract.ts 的异步方法 +// 例如:const { data, isLoading } = useQuery(['adSpaces'], getAvailableAdSpaces) + +function App() { + // 监听网络变化 + useEffect(() => { + // 保存当前网络到 localStorage + localStorage.setItem('current_network', DEFAULT_NETWORK); + + // 监听网络变化 + window.addEventListener('sui_networkChange', (event: any) => { + const newNetwork = event.detail?.network || DEFAULT_NETWORK; + localStorage.setItem('current_network', newNetwork); + }); + + return () => { + window.removeEventListener('sui_networkChange', () => {}); + }; + }, []); + + return ( + + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + + ); +} + +export default App; diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/assets/logo.svg b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/assets/logo.svg new file mode 100644 index 00000000..55253ade --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/assets/logo.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/adSpace/AdSpaceCard.scss b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/adSpace/AdSpaceCard.scss new file mode 100644 index 00000000..63dc8fdc --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/adSpace/AdSpaceCard.scss @@ -0,0 +1,291 @@ +.ad-space-card { + width: 100%; + margin-bottom: 24px; + transition: all 0.3s; + border-radius: 16px !important; + overflow: hidden; + border: none; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.08); + background: linear-gradient(145deg, #ffffff, #f5f7ff); + + &:hover { + transform: translateY(-8px); + box-shadow: 0 15px 30px rgba(0, 0, 0, 0.12); + } + + .card-cover { + height: 200px; + background: linear-gradient(135deg, rgba(78, 99, 255, 0.9), rgba(110, 86, 207, 0.85)); + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; + + &:before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-image: + linear-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px); + background-size: 20px 20px; + z-index: 1; + pointer-events: none; + } + + .loading-container { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + z-index: 2; + + .ant-spin { + .ant-spin-dot-item { + background-color: white; + } + } + } + + .active-nft-cover { + width: 100%; + height: 100%; + position: relative; + z-index: 2; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .carousel-controls { + position: absolute; + bottom: 10px; + right: 10px; + z-index: 4; + cursor: pointer; + transition: transform 0.3s; + + &:hover { + transform: scale(1.05); + } + + .switch-tag { + font-weight: 600; + font-size: 12px; + padding: 3px 10px; + border-radius: 20px; + background: rgba(24, 144, 255, 0.85); + border: none; + color: white; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + display: flex; + align-items: center; + + .anticon { + margin-right: 5px; + animation: rotate 2s infinite linear; + } + } + } + + .active-tag { + position: absolute; + top: 10px; + right: 10px; + font-weight: 600; + font-size: 12px; + padding: 3px 10px; + border-radius: 20px; + background: rgba(82, 196, 26, 0.9); + border: none; + color: white; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + animation: pulse 2s infinite; + z-index: 3; + } + } + + .empty-ad-space-placeholder { + text-align: center; + color: white; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + position: relative; + z-index: 2; + + .anticon { + font-size: 42px; + margin-bottom: 16px; + display: block; + } + + span { + color: white; + font-size: 16px; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + } + + .empty-text { + margin-top: 16px; + font-size: 14px; + background: rgba(255, 255, 255, 0.15); + padding: 6px 16px; + border-radius: 20px; + backdrop-filter: blur(5px); + border: 1px solid rgba(255, 255, 255, 0.2); + } + } + } + + .ant-card-body { + padding: 20px; + } + + .ad-title { + margin-bottom: 16px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 700; + color: var(--text-dark); + font-size: 18px; + } + + .ad-info { + margin-bottom: 16px; + + .info-item { + display: flex; + align-items: center; + margin-bottom: 10px; + + .anticon { + margin-right: 10px; + font-size: 16px; + color: #4e63ff; + background: linear-gradient(90deg, #4e63ff, #6e56cf); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + } + + .price-text { + white-space: nowrap; + font-weight: 600; + color: #4e63ff; + } + } + } + + .ad-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 10px; + + .ant-tag { + margin: 0; + padding: 2px 10px; + border-radius: 12px; + font-weight: 500; + font-size: 12px; + border: none; + + &:nth-child(1) { + background: rgba(78, 99, 255, 0.1); + color: #4e63ff; + } + + &:nth-child(2) { + background: rgba(110, 86, 207, 0.1); + color: #6e56cf; + } + + &:nth-child(3) { + background: rgba(82, 196, 26, 0.1); + color: #52c41a; + } + } + } + + .ant-card-actions { + background: transparent; + border-top: 1px solid rgba(78, 99, 255, 0.1); + + li { + margin: 12px 0; + border-right: 1px solid rgba(78, 99, 255, 0.1); + + &:last-child { + border-right: none; + } + } + + button { + width: 90%; + border-radius: 8px; + font-weight: 600; + height: 36px; + + &.ant-btn-primary { + background: linear-gradient(90deg, #4e63ff, #6e56cf); + border: none; + box-shadow: 0 4px 10px rgba(78, 99, 255, 0.2); + + &:hover { + transform: translateY(-2px); + box-shadow: 0 6px 15px rgba(78, 99, 255, 0.3); + background: linear-gradient(90deg, #5a6eff, #7a62d9); + } + } + + &.ant-btn-default { + border: 1px solid rgba(78, 99, 255, 0.3); + color: #4e63ff; + + &:hover { + border-color: #4e63ff; + color: #4e63ff; + transform: translateY(-2px); + box-shadow: 0 6px 15px rgba(78, 99, 255, 0.1); + } + } + + a { + text-decoration: none; + color: inherit; + } + } + } +} + +@keyframes pulse { + 0% { + box-shadow: 0 0 0 0 rgba(82, 196, 26, 0.4); + } + 70% { + box-shadow: 0 0 0 6px rgba(82, 196, 26, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(82, 196, 26, 0); + } +} + +@keyframes rotate { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} \ No newline at end of file diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/adSpace/AdSpaceCard.tsx b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/adSpace/AdSpaceCard.tsx new file mode 100644 index 00000000..d4685504 --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/adSpace/AdSpaceCard.tsx @@ -0,0 +1,283 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Card, Button, Typography, Space, Tag, Spin, Tooltip } from 'antd'; +import { EnvironmentOutlined, ColumnWidthOutlined, DollarOutlined, QuestionCircleOutlined, SwapOutlined } from '@ant-design/icons'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { AdSpace, UserRole, BillboardNFT } from '../../types'; +import { useCurrentAccount } from '@mysten/dapp-kit'; +import { useMemo } from 'react'; +import { getNFTDetails } from '../../utils/contract'; +import MediaContent from '../nft/MediaContent'; +import './AdSpaceCard.scss'; + +const { Title, Text } = Typography; + +interface AdSpaceCardProps { + adSpace: AdSpace; + userRole?: UserRole; + creatorAddress?: string; +} + +const AdSpaceCard: React.FC = ({ adSpace, userRole, creatorAddress }) => { + const { t } = useTranslation(); + const account = useCurrentAccount(); + const [activeNfts, setActiveNfts] = useState([]); + const [currentNftIndex, setCurrentNftIndex] = useState(0); + const [loading, setLoading] = useState(false); + const [userOwnedNfts, setUserOwnedNfts] = useState([]); + + // 获取活跃NFT内容和用户拥有的NFT + useEffect(() => { + const fetchNftData = async () => { + // 如果广告位有NFT ID列表且不为空 + if (adSpace.nft_ids && adSpace.nft_ids.length > 0) { + try { + setLoading(true); + console.log(`开始获取广告位[${adSpace.id}]的NFT信息,NFT IDs:`, adSpace.nft_ids); + + const now = new Date(); + const userAddress = account ? account.address.toLowerCase() : null; + const userNfts: BillboardNFT[] = []; + const activeNftsFound: BillboardNFT[] = []; + + // 遍历所有NFT + for (const nftId of adSpace.nft_ids) { + console.log(`正在检查NFT[${nftId}]`); + const nftDetails = await getNFTDetails(nftId); + + if (nftDetails) { + console.log(`获取到NFT[${nftId}]详情:`, { + brandName: nftDetails.brandName, + contentUrl: nftDetails.contentUrl, + leaseEnd: nftDetails.leaseEnd, + isActive: nftDetails.isActive, + owner: nftDetails.owner + }); + + const leaseStart = new Date(nftDetails.leaseStart); + const leaseEnd = new Date(nftDetails.leaseEnd); + + console.log(`NFT[${nftId}]租期: 开始=${leaseStart.toLocaleString()}, 结束=${leaseEnd.toLocaleString()}, 当前时间=${now.toLocaleString()}`); + + // 检查NFT是否属于当前用户 + if (userAddress && nftDetails.owner.toLowerCase() === userAddress) { + console.log(`NFT[${nftId}]属于当前用户`); + userNfts.push(nftDetails); + } + + // 只有当前时间在租期内的NFT才被视为活跃 + if (now >= leaseStart && now <= leaseEnd && nftDetails.isActive) { + console.log(`找到活跃NFT[${nftId}],将显示在卡片中, 内容URL:`, nftDetails.contentUrl); + + // 检查contentUrl是否有效 + if (!nftDetails.contentUrl) { + console.warn(`NFT[${nftId}]的contentUrl为空,可能导致图片无法显示`); + } else { + // 尝试预加载图片 + const img = new Image(); + img.onload = () => console.log(`NFT[${nftId}]图片加载成功`, img.width, img.height); + img.onerror = (err) => console.error(`NFT[${nftId}]图片加载失败`, err); + img.src = nftDetails.contentUrl; + } + + // 收集所有活跃的NFT,而不是只保留第一个 + activeNftsFound.push(nftDetails); + } else if (now < leaseStart) { + console.log(`NFT[${nftId}]尚未开始展示,租期开始时间:`, leaseStart.toLocaleString()); + } else { + console.log(`NFT[${nftId}]已过期,租期结束时间:`, leaseEnd.toLocaleString()); + } + } else { + console.log(`未能获取NFT[${nftId}]详情`); + } + } + + // 保存用户拥有的NFT列表 + setUserOwnedNfts(userNfts); + + // 保存所有活跃NFT + setActiveNfts(activeNftsFound); + setCurrentNftIndex(0); // 重置轮播索引 + + } catch (err) { + console.error(`获取广告位[${adSpace.id}]的NFT失败:`, err); + } finally { + setLoading(false); + } + } else { + console.log(`广告位[${adSpace.id}]没有关联的NFT ID`); + } + }; + + fetchNftData(); + }, [adSpace.id, adSpace.nft_ids, account]); + + // 轮播效果 - 每30秒切换一次活跃NFT + useEffect(() => { + // 如果有多个活跃NFT,启动轮播 + if (activeNfts.length > 1) { + const intervalId = setInterval(() => { + setCurrentNftIndex(prevIndex => (prevIndex + 1) % activeNfts.length); + }, 30000); + + return () => clearInterval(intervalId); + } + }, [activeNfts]); + + // 手动切换轮播 + const handleSwitchNft = useCallback(() => { + if (activeNfts.length > 1) { + setCurrentNftIndex(prevIndex => (prevIndex + 1) % activeNfts.length); + } + }, [activeNfts]); + + // 当前展示的NFT + const currentNft = activeNfts.length > 0 ? activeNfts[currentNftIndex] : null; + + // 判断是否应该显示购买按钮 + const shouldShowPurchase = useMemo(() => { + // 如果是管理员,不显示购买按钮 + if (userRole === UserRole.ADMIN) { + console.log('用户是管理员,不显示购买按钮'); + return false; + } + + // 标准化地址格式用于比较 + const normalizedCreator = creatorAddress ? creatorAddress.toLowerCase() : null; + const normalizedUser = account ? account.address.toLowerCase() : null; + + console.log(`广告位[${adSpace.id}]创建者信息:`, { + name: adSpace.name, + creatorAddress: normalizedCreator, + userAddress: normalizedUser, + isMatch: normalizedCreator === normalizedUser, + userRole + }); + + // 如果是游戏开发者,且是自己创建的广告位,不显示购买按钮 + if (userRole === UserRole.GAME_DEV && + normalizedCreator && + normalizedUser && + normalizedCreator === normalizedUser) { + console.log(`广告位[${adSpace.id}]是当前开发者创建的,隐藏购买按钮`); + return false; + } + + // 如果用户拥有该广告位的活跃或待展示NFT,不显示购买按钮 + if (userOwnedNfts.length > 0) { + // 检查用户拥有的NFT是否有活跃或待展示的 + const now = new Date(); + const hasActiveOrFutureNft = userOwnedNfts.some(nft => { + const leaseEnd = new Date(nft.leaseEnd); + // 如果NFT还未过期,则不显示购买按钮 + return now <= leaseEnd; + }); + + if (hasActiveOrFutureNft) { + console.log(`用户拥有广告位[${adSpace.id}]的活跃或待展示NFT,隐藏购买按钮`); + return false; + } + } + + // 其他情况显示购买按钮 + return true; + }, [userRole, creatorAddress, account, adSpace.id, adSpace.name, userOwnedNfts]); + + // 判断是否应该显示"我的NFT"标记 + const shouldShowMyNftTag = useMemo(() => { + if (userOwnedNfts.length === 0) return false; + + // 检查用户拥有的NFT是否有活跃或待展示的 + const now = new Date(); + return userOwnedNfts.some(nft => { + const leaseEnd = new Date(nft.leaseEnd); + // 如果NFT还未过期,则显示"我的NFT"标记 + return now <= leaseEnd; + }); + }, [userOwnedNfts]); + + return ( + + {loading ? ( +
+ +
+ ) : currentNft && currentNft.contentUrl ? ( +
+ + {activeNfts.length > 1 && ( +
+ } color="blue"> + {currentNftIndex + 1}/{activeNfts.length} + +
+ )} + {t('adSpaces.status.active')} +
+ ) : ( +
+ + {adSpace.aspectRatio || '16:9'} + {t('adSpaces.status.waiting')} +
+ )} + + } + actions={[ + , + shouldShowPurchase ? ( + + ) : null + ].filter(Boolean)} + > + {adSpace.name} + + +
+ + {t('manage.createAdSpace.form.location')}: {adSpace.location} +
+ +
+ + + {`${t('manage.createAdSpace.form.dimension')}: ${adSpace.aspectRatio || '16:9'}`} + +
+ +
+ + + {parseFloat((Number(adSpace.price) / 1000000000).toFixed(9))} SUI/{t('common.time.day')} + + + + +
+
+ +
+ {adSpace.location} + {creatorAddress && account && creatorAddress.toLowerCase() === account.address.toLowerCase() && ( + {t('adSpaces.status.myAdSpace')} + )} + {shouldShowMyNftTag && ( + {t('adSpaces.status.myNFT')} + )} +
+
+ ); +}; + +export default AdSpaceCard; \ No newline at end of file diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/adSpace/AdSpaceForm.scss b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/adSpace/AdSpaceForm.scss new file mode 100644 index 00000000..7185f7e7 --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/adSpace/AdSpaceForm.scss @@ -0,0 +1,264 @@ +.ad-space-form { + margin-top: 20px; + border-radius: 16px; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.08); + border: none; + background: #ffffff; + transition: all 0.3s; + + &:hover { + transform: translateY(-8px); + box-shadow: 0 15px 30px rgba(0, 0, 0, 0.12); + } + + .ant-card-body { + padding: 30px; + } + + h3 { + font-weight: 700; + margin-bottom: 20px; + color: var(--text-dark); + } +} + +.ad-space-info { + background: #f5f7ff; + padding: 20px; + border-radius: 16px; + margin-bottom: 24px; + position: relative; + overflow: hidden; + + .ant-typography-strong { + color: var(--text-dark); + font-weight: 600; + } + + .ant-row { + margin-bottom: 8px; + } + + strong { + margin-right: 8px; + } +} + +.price-summary { + background: #f5f7ff; + border-radius: 16px; + padding: 24px; + margin-bottom: 30px; + border: none; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); + + .price-breakdown { + margin-bottom: 16px; + + .price-breakdown-item { + display: flex; + justify-content: space-between; + margin-bottom: 10px; + padding: 8px 0; + border-bottom: 1px dashed #e0e0e0; + + &:last-child { + border-bottom: none; + } + } + } + + .total-price { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid rgba(78, 99, 255, 0.1); + font-size: 16px; + font-weight: bold; + + .ant-row { + .ant-col:first-child { + font-size: 15px; + color: var(--text-medium); + } + + .ant-col:last-child { + font-size: 20px; + color: var(--primary); + font-weight: 700; + } + } + } +} + +.submit-button { + width: 100%; + height: 46px; + font-size: 16px; + margin-top: 16px; + background: linear-gradient(90deg, var(--primary), var(--secondary)); + border: none; + border-radius: 8px; + box-shadow: 0 4px 10px rgba(78, 99, 255, 0.2); + transition: all 0.3s; + font-weight: 600; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 6px 15px rgba(78, 99, 255, 0.3); + background: linear-gradient(90deg, #5a6eff, #7a62d9); + } + + &:disabled { + background: #f5f5f5; + color: rgba(0, 0, 0, 0.25); + border-color: #d9d9d9; + box-shadow: none; + cursor: not-allowed; + + &:hover { + transform: none; + } + } +} + +.price-loading { + display: flex; + align-items: center; + margin: 8px 0; + padding: 12px; + background: rgba(78, 99, 255, 0.05); + border-radius: 8px; +} + +.form-section-title { + font-size: 16px; + font-weight: 600; + color: var(--primary); + margin-bottom: 16px; + position: relative; + padding-left: 12px; + + &:before { + content: ""; + position: absolute; + left: 0; + top: 5px; + bottom: 5px; + width: 3px; + background: linear-gradient(to bottom, var(--primary), var(--secondary)); + border-radius: 3px; + } +} + +.help-text { + color: #666; + font-size: 12px; + margin-top: 4px; +} + +.preview-section { + margin: 20px 0; + padding: 20px; + background: rgba(24, 144, 255, 0.03); + border-radius: 12px; + border: 1px dashed rgba(24, 144, 255, 0.3); + + .preview-title { + font-size: 15px; + font-weight: 600; + color: #1677ff; + margin-bottom: 16px; + } + + .preview-image { + max-width: 100%; + max-height: 300px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + transition: transform 0.3s; + + &:hover { + transform: scale(1.02); + } + } +} + +.ant-form-item-label > label { + font-weight: 500; + color: var(--text-dark); +} + +.ant-form-item-extra { + color: var(--text-light); + font-size: 12px; + margin-top: 4px; +} + +.ant-form-item { + margin-bottom: 24px; +} + +.ant-input, +.ant-input-number, +.ant-picker, +.ant-select:not(.ant-select-customize-input) .ant-select-selector { + border-radius: var(--border-radius-md); + border: 1px solid #e0e6f5; + transition: all 0.3s; + + &:hover, &:focus { + border-color: var(--primary); + box-shadow: 0 0 0 2px rgba(78, 99, 255, 0.1); + } +} + +// 添加滑块样式 +.ant-slider { + margin: 10px 0; +} + +.ant-slider-rail { + background-color: #e1e1e1; +} + +.ant-slider-track { + background-color: var(--primary); +} + +.ant-slider-handle { + border-color: var(--primary); + + &:focus { + box-shadow: 0 0 0 5px rgba(78, 99, 255, 0.2); + } +} + +// 开关样式 +.ant-switch { + background-color: rgba(0, 0, 0, 0.25); + + &.ant-switch-checked { + background-color: var(--primary); + } +} + +// 添加Alert样式 +.ant-alert { + border-radius: var(--border-radius-md); + margin-bottom: 24px; + + &.ant-alert-success { + background-color: #f6ffed; + border-color: #b7eb8f; + } + + &.ant-alert-info { + background-color: #e6f7ff; + border-color: #91d5ff; + } + + &.ant-alert-warning { + background-color: #fffbe6; + border-color: #ffe58f; + } +} \ No newline at end of file diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/adSpace/AdSpaceForm.tsx b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/adSpace/AdSpaceForm.tsx new file mode 100644 index 00000000..19a7898e --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/adSpace/AdSpaceForm.tsx @@ -0,0 +1,502 @@ +import React, { useState, useEffect } from 'react'; +import { Form, Input, Button, Typography, Card, Divider, Select, Space, Spin, Row, Col, Tooltip, Slider, InputNumber, DatePicker, Switch, Alert, message } from 'antd'; +import { InfoCircleOutlined, ShoppingCartOutlined, QuestionCircleOutlined, ClockCircleOutlined, WalletOutlined } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; +import { AdSpace, PurchaseAdSpaceParams } from '../../types'; +import { calculateLeasePrice, formatSuiAmount } from '../../utils/contract'; +import WalrusUpload from '../walrus/WalrusUpload'; +import dayjs from 'dayjs'; +import './AdSpaceForm.scss'; +import { useCurrentAccount, useSuiClient } from '@mysten/dapp-kit'; +import { getWalCoinType } from '../../config/walrusConfig'; + +const { Title, Text, Paragraph } = Typography; +const { Option } = Select; +const { TextArea } = Input; + +interface AdSpaceFormProps { + adSpace: AdSpace; + onSubmit: (values: PurchaseAdSpaceParams) => void; + isLoading: boolean; +} + +const AdSpaceForm: React.FC = ({ + adSpace, + onSubmit, + isLoading +}) => { + const { t } = useTranslation(); + const [form] = Form.useForm(); + const [leaseDays, setLeaseDays] = useState(30); + const [totalPrice, setTotalPrice] = useState("0"); + const [calculating, setCalculating] = useState(false); + const [contentUrl, setContentUrl] = useState(""); + const [useCustomStartTime, setUseCustomStartTime] = useState(false); + const [startTime, setStartTime] = useState(null); + const [contentUploaded, setContentUploaded] = useState(false); // 添加上传成功状态 + + // 添加上传参数状态 + const [contentParams, setContentParams] = useState<{ + url: string; + blobId?: string; + storageSource: string; + }>({ + url: '', + storageSource: 'external' + }); + + // 添加钱包余额相关状态 + const [walletBalance, setWalletBalance] = useState('0'); + const [insufficientBalance, setInsufficientBalance] = useState(false); + const [walBalance, setWalBalance] = useState('0'); + + // 获取钱包和Sui客户端 + const account = useCurrentAccount(); + const suiClient = useSuiClient(); + + // 获取租赁价格和检查钱包余额 + useEffect(() => { + const fetchPriceAndCheckBalance = async () => { + setCalculating(true); + try { + console.log('正在获取广告位价格', adSpace.id, leaseDays); + // 直接调用contract.ts中的calculateLeasePrice函数 + const price = await calculateLeasePrice(adSpace.id, leaseDays); + console.log('获取到的价格 (原始):', price); + const formattedPrice = formatSuiAmount(price); + console.log('格式化后的价格:', formattedPrice); + + if (formattedPrice === 'NaN' || !formattedPrice) { + throw new Error('获取到的价格无效'); + } + + setTotalPrice(formattedPrice); + + // 如果有账户,检查余额是否足够 + if (account && suiClient) { + try { + // 获取SUI代币余额 + const { totalBalance } = await suiClient.getBalance({ + owner: account.address, + coinType: '0x2::sui::SUI' + }); + + const priceValue = BigInt(price); + const balanceValue = BigInt(totalBalance); + + // 保存钱包余额到状态 + const formattedBalance = formatSuiAmount(totalBalance); + setWalletBalance(formattedBalance); + + // 显示余额信息 + console.log('钱包余额:', formattedBalance, 'SUI'); + console.log('购买价格:', formattedPrice, 'SUI'); + + // 判断余额是否足够 + const isBalanceInsufficient = balanceValue < priceValue; + setInsufficientBalance(isBalanceInsufficient); + + // 如果余额不足,显示警告 + if (isBalanceInsufficient) { + // 先使用 t() 函数处理占位符替换,然后将结果传递给 message.warning + const warningMsg = t('nftDetail.transaction.insufficientBalance', { + price: formattedPrice, + balance: formattedBalance + }); + message.warning({ + content: warningMsg, + duration: 5 + }); + } + + // 获取WAL代币余额(如果存在) + try { + // 从配置文件获取当前环境的WAL代币类型 + const walCoinType = getWalCoinType(); + console.log('使用WAL代币类型:', walCoinType); + + const { totalBalance: walTotalBalance } = await suiClient.getBalance({ + owner: account.address, + coinType: walCoinType + }); + + // 保存WAL余额到状态 + const formattedWalBalance = formatSuiAmount(walTotalBalance); + setWalBalance(formattedWalBalance); + console.log('WAL余额:', formattedWalBalance, 'WAL'); + } catch (walError) { + console.error('获取WAL余额失败:', walError); + // 如果获取WAL余额失败,设置为0 + setWalBalance('0'); + } + } catch (balanceError) { + console.error('检查余额失败:', balanceError); + } + } + } catch (error) { + console.error('获取价格失败:', error); + setTotalPrice(''); + } finally { + setCalculating(false); + } + }; + + fetchPriceAndCheckBalance(); + }, [adSpace.id, leaseDays, adSpace.price, account, suiClient]); + + // 处理内容上传参数变更 + const handleContentParamsChange = (data: { url: string; blobId?: string; storageSource: string }) => { + setContentParams(data); + setContentUrl(data.url); + form.setFieldsValue({ contentUrl: data.url }); + + // 只有当存储来源是walrus且URL存在时,才设置为已上传状态 + if (data.storageSource === 'walrus' && data.url) { + setContentUploaded(true); + } + // 外部URL的成功状态由WalrusUpload组件中的确认按钮触发 + // 这里只处理参数变更,不设置成功状态 + }; + + const handleSubmit = (values: any) => { + // 如果价格无效,不允许提交 + if (totalPrice === 'NaN' || !totalPrice) { + console.error('价格无效,无法提交'); + return; + } + + const priceInMist = (Number(totalPrice) * 1000000000).toString(); + console.log('提交的价格 (MIST):', priceInMist); + + // 如果projectUrl为空,则使用"#"作为默认值 + const projectUrl = values.projectUrl ? values.projectUrl : "#"; + + const params: PurchaseAdSpaceParams = { + adSpaceId: adSpace.id, + contentUrl: contentParams.url, + brandName: values.brandName, + projectUrl: projectUrl, + leaseDays: values.leaseDays, + price: priceInMist, + blobId: contentParams.blobId, + storageSource: contentParams.storageSource + }; + + // 如果使用自定义开始时间,添加startTime字段(移除了!contentUploaded条件) + if (useCustomStartTime && startTime) { + params.startTime = Math.floor(startTime.valueOf() / 1000); // 转换为Unix时间戳(秒) + console.log('使用自定义开始时间:', new Date(params.startTime * 1000).toLocaleString(), '时间戳:', params.startTime); + } else { + console.log('使用当前时间作为开始时间'); + } + + onSubmit(params); + }; + + const handleContentUrlChange = (e: React.ChangeEvent) => { + setContentUrl(e.target.value); + setContentParams({ + url: e.target.value, + storageSource: 'external' + }); + }; + + // 当内容上传成功后,更新表单字段的禁用状态 + useEffect(() => { + if (contentUploaded) { + // 设置租赁天数为只读 + form.setFieldValue('leaseDays', leaseDays); + // 不再自动关闭自定义开始时间,保留用户的选择 + // 只是禁用UI控件,但保留值 + } + }, [contentUploaded, leaseDays, form]); + + return ( + + {t('purchase.title')} + + +
+ + +
+ {t('purchase.form.brandName')}: + {adSpace.name} +
+
+ {t('manage.createAdSpace.form.location')}: + {adSpace.location} +
+
+ {t('manage.createAdSpace.form.dimension')}: + {adSpace.aspectRatio || '16:9'} +
+ + +
+ {t('manage.createAdSpace.form.price')}: + + {Number(adSpace.price) / 1000000000} SUI / {t('common.time.day')} + + + + +
+ +
+
+ + + {t('purchase.form.fillAdInfo')} + + +
+ + +
{t('purchase.form.basicInfo')}
+ + + + + + + + + { + const days = Number(value); + if (!isNaN(days) && days >= 1 && days <= 365) { + setLeaseDays(days); + form.setFieldsValue({ leaseDays: days }); + } + }} + addonAfter={t('common.time.day')} + style={{ width: '100%' }} + disabled={contentUploaded && contentParams.storageSource === 'walrus'} + /> + + +
+ + + +
{t('purchase.form.leaseSettings')}
+ + + + setUseCustomStartTime(checked)} + disabled={contentUploaded && contentParams.storageSource === 'walrus'} + /> + + + {t('purchase.form.defaultTimeDesc')} + + + + ({ + validator(_, value) { + if (!getFieldValue('useCustomStartTime') || value) { + return Promise.resolve(); + } + return Promise.reject(new Error(t('purchase.form.timeRequired'))); + }, + }), + ]} + > + setStartTime(date)} + disabledDate={(current) => { + // 不能选择过去的日期 + return current && current < dayjs().startOf('day'); + }} + format="YYYY-MM-DD HH:mm:ss" + /> + + +
+ + {contentUploaded && contentParams.storageSource === 'walrus' && ( + + )} + + {contentUploaded && contentParams.storageSource === 'external' && ( + + )} + + + +
{t('purchase.form.adContent')}
+ + + + + + + + + + + +
+ + + +
+ +
+
+ {t('purchase.form.yourLeasePeriod')}: + {leaseDays} {t('purchase.form.days')} +
+
+ + {calculating ? ( +
+ + {t('purchase.form.calculatingPrice')} +
+ ) : ( +
+ + {t('purchase.form.totalPrice')}: + + {totalPrice === 'NaN' || !totalPrice ? ( + {t('purchase.form.priceCalculationFailed')} + ) : ( + {totalPrice} SUI + )} + + + + {/* 显示钱包余额 */} + + + + + {t('nftDetail.modals.renewLease.walletBalance')} + + + + + {walletBalance} SUI {insufficientBalance && {t('nftDetail.modals.renewLease.insufficientBalanceHint')}} + + + + + {/* 如果选择上传到Walrus,显示WAL余额 */} + {contentParams.storageSource === 'walrus' && ( + + + + + {t('nftDetail.modals.renewLease.walBalance')} + + + + + {walBalance} WAL + + + + )} +
+ )} +
+
+ + + + + +
+ ); +}; + +export default AdSpaceForm; \ No newline at end of file diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/adSpace/AdSpaceItem.scss b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/adSpace/AdSpaceItem.scss new file mode 100644 index 00000000..b2542858 --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/adSpace/AdSpaceItem.scss @@ -0,0 +1,183 @@ +.ad-space-card { + border-radius: 12px; + overflow: hidden; + transition: all 0.3s ease; + height: 100%; + display: flex; + flex-direction: column; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + + &:hover { + transform: translateY(-5px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + } + + .card-cover { + position: relative; + height: 200px; + overflow: hidden; + background: #f5f7ff; + + .loading-container { + height: 100%; + display: flex; + justify-content: center; + align-items: center; + } + + .active-nft-cover { + width: 100%; + height: 100%; + position: relative; + z-index: 2; + + img, video { + width: 100%; + height: 100%; + object-fit: cover; + } + + .active-tag { + position: absolute; + top: 10px; + right: 10px; + z-index: 3; + font-weight: 600; + border-radius: 20px; + padding: 2px 10px; + } + + .carousel-controls { + position: absolute; + bottom: 10px; + right: 10px; + z-index: 4; + cursor: pointer; + transition: transform 0.3s; + + &:hover { + transform: scale(1.05); + } + + .switch-tag { + font-weight: 600; + font-size: 12px; + padding: 3px 10px; + border-radius: 20px; + background: rgba(24, 144, 255, 0.85); + border: none; + color: white; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + display: flex; + align-items: center; + + .anticon { + margin-right: 5px; + animation: rotate 2s infinite linear; + } + } + } + } + + .empty-ad-space-placeholder { + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + color: #8c8c8c; + + .anticon { + font-size: 32px; + margin-bottom: 8px; + color: #4e63ff; + } + } + + .availability-badge { + position: absolute; + top: 10px; + left: 10px; + z-index: 3; + + span { + display: inline-block; + padding: 2px 10px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + + &.available { + background-color: rgba(82, 196, 26, 0.85); + color: white; + } + + &.unavailable { + background-color: rgba(245, 34, 45, 0.85); + color: white; + } + } + } + } + + .ad-space-meta { + margin: 16px 0; + + .ant-card-meta-title { + font-size: 18px; + margin-bottom: 8px; + color: #1a1a1a; + } + } + + .ad-space-info { + margin-bottom: 16px; + + .info-item { + display: flex; + margin-bottom: 8px; + + .label { + color: #8c8c8c; + min-width: 80px; + } + + .value { + color: #1a1a1a; + font-weight: 500; + + &.price { + color: #4e63ff; + } + } + } + } + + .action-buttons { + display: flex; + gap: 8px; + margin-top: auto; + + .edit-button, .delete-button { + flex: 1; + } + + .delete-button { + color: #ff4d4f; + border-color: #ff4d4f; + + &:hover { + background-color: #fff1f0; + } + } + } +} + +@keyframes rotate { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/adSpace/AdSpaceItem.tsx b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/adSpace/AdSpaceItem.tsx new file mode 100644 index 00000000..0749fd97 --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/adSpace/AdSpaceItem.tsx @@ -0,0 +1,211 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Card, Button, Typography, Tag, Spin, Popconfirm, Col } from 'antd'; +import { ColumnWidthOutlined, DollarOutlined, DeleteOutlined, SwapOutlined } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; +import { AdSpace, BillboardNFT } from '../../types'; +import { getNFTDetails } from '../../utils/contract'; +import MediaContent from '../nft/MediaContent'; +import './AdSpaceItem.scss'; + +const { Text } = Typography; + +interface AdSpaceItemProps { + adSpace: AdSpace; + onUpdatePrice: (adSpace: AdSpace) => void; + onDeleteAdSpace: (adSpaceId: string) => void; + deleteLoading: boolean; +} + +const AdSpaceItem: React.FC = ({ + adSpace, + onUpdatePrice, + onDeleteAdSpace, + deleteLoading +}) => { + const { t } = useTranslation(); + const [loadingNft, setLoadingNft] = useState(false); + const [activeNfts, setActiveNfts] = useState([]); + const [currentNftIndex, setCurrentNftIndex] = useState(0); + const [mediaErrors, setMediaErrors] = useState>({}); + + // useEffect需要在组件顶层调用 + useEffect(() => { + // 只有当不是示例数据且有NFT IDs时才执行 + if (adSpace.isExample || !adSpace.nft_ids?.length) { + return; + } + + const getActiveNfts = async () => { + try { + setLoadingNft(true); + + // 确保nft_ids存在 + const nftIds = adSpace.nft_ids || []; + const activeNftsFound: BillboardNFT[] = []; + + // 尝试获取每个NFT详情,收集所有活跃的NFT + for (const nftId of nftIds) { + const nft = await getNFTDetails(nftId); + if (nft && nft.isActive) { + activeNftsFound.push(nft); + } + } + + // 保存所有活跃的NFT + setActiveNfts(activeNftsFound); + // 重置轮播索引 + setCurrentNftIndex(0); + console.log(`广告位[${adSpace.id}]找到${activeNftsFound.length}个活跃NFT`); + } catch (error) { + console.error('获取活跃NFT失败:', error); + } finally { + setLoadingNft(false); + } + }; + + getActiveNfts(); + }, [adSpace.nft_ids, adSpace.isExample]); + + // 轮播效果 - 每30秒切换一次活跃NFT + useEffect(() => { + // 如果有多个活跃NFT,启动轮播 + if (activeNfts.length > 1) { + const intervalId = setInterval(() => { + // 查找下一个未出错的媒体 + let nextIndex = (currentNftIndex + 1) % activeNfts.length; + let attempts = 0; + while (mediaErrors[activeNfts[nextIndex]?.contentUrl] && attempts < activeNfts.length) { + nextIndex = (nextIndex + 1) % activeNfts.length; + attempts++; + } + setCurrentNftIndex(nextIndex); + }, 30000); + + return () => clearInterval(intervalId); + } + }, [activeNfts, currentNftIndex, mediaErrors]); + + // 手动切换轮播 + const handleSwitchNft = useCallback(() => { + if (activeNfts.length > 1) { + // 查找下一个未出错的媒体 + let nextIndex = (currentNftIndex + 1) % activeNfts.length; + let attempts = 0; + while (mediaErrors[activeNfts[nextIndex]?.contentUrl] && attempts < activeNfts.length) { + nextIndex = (nextIndex + 1) % activeNfts.length; + attempts++; + } + setCurrentNftIndex(nextIndex); + } + }, [activeNfts, currentNftIndex, mediaErrors]); + + // 如果这是示例数据,不要显示完整卡片 + if (adSpace.isExample) { + return ( + +
+ +
{adSpace.name}
+
{adSpace.description}
+
+ + ); + } + + return ( + + +
+ {loadingNft ? ( +
+ +
+ ) : activeNfts.length > 0 ? ( +
+ { + setMediaErrors(prev => ({ + ...prev, + [activeNfts[currentNftIndex].contentUrl]: true + })); + // 自动切换到下一个媒体 + handleSwitchNft(); + }} + /> + {activeNfts.length > 1 && ( +
+ } color="blue"> + {currentNftIndex + 1}/{activeNfts.length} + +
+ )} + {t('adSpaces.status.active')} +
+ ) : ( +
+ + {adSpace.aspectRatio || '16:9'} + {t('adSpaces.status.waiting')} +
+ )} +
+ + {adSpace.available ? t('adSpaces.status.available') : t('adSpaces.status.occupied')} + +
+
+ +
+
+ {t('manage.createAdSpace.form.location')}: + {adSpace.location} +
+
+ {t('manage.createAdSpace.form.dimension')}: + + {adSpace.aspectRatio || '16:9'} + +
+
+ {t('manage.createAdSpace.form.price')}: + + {parseFloat((Number(adSpace.price) / 1000000000).toFixed(9))} SUI/{t('common.time.day')} + +
+
+
+ + onDeleteAdSpace(adSpace.id)} + okText={t('common.buttons.confirm')} + cancelText={t('common.buttons.cancel')} + okButtonProps={{ loading: deleteLoading }} + > + + +
+
+ + ); +}; + +export default AdSpaceItem; \ No newline at end of file diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/common/ConnectWallet.scss b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/common/ConnectWallet.scss new file mode 100644 index 00000000..e9d83855 --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/common/ConnectWallet.scss @@ -0,0 +1,46 @@ +.wallet-address { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + + .ant-typography { + max-width: 150px; + } +} + +.wallet-item { + display: flex; + align-items: center; + padding: 8px 0; + + .wallet-icon { + width: 24px; + height: 24px; + margin-right: 12px; + border-radius: 4px; + } +} + +.ant-dropdown-menu { + min-width: 200px; + + .ant-dropdown-menu-item { + padding: 8px 12px; + } +} + +// 深色背景下的钱包按钮样式 +.app-header { + .ant-btn-primary { + background-color: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.25); + color: white; + font-weight: 500; + + &:hover { + background-color: rgba(255, 255, 255, 0.25); + border-color: rgba(255, 255, 255, 0.35); + } + } +} \ No newline at end of file diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/common/ConnectWallet.tsx b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/common/ConnectWallet.tsx new file mode 100644 index 00000000..3f91d549 --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/common/ConnectWallet.tsx @@ -0,0 +1,169 @@ +import React, { useState } from 'react'; +import { Button, Dropdown, Avatar, Space, Typography, message } from 'antd'; +import { WalletOutlined, DownOutlined, DisconnectOutlined, CopyOutlined } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; +import { + useCurrentAccount, + useCurrentWallet, + useConnectWallet, + useWallets, + useDisconnectWallet, + type WalletWithFeatures +} from '@mysten/dapp-kit'; +import './ConnectWallet.scss'; + +const { Text } = Typography; + +type WalletMenuItem = { + key: string; + label: React.ReactNode; + icon?: React.ReactNode; + onClick?: () => void; +}; + +type WalletMenuDivider = { + type: 'divider'; + key?: string; +}; + +type WalletMenuItemType = WalletMenuItem | WalletMenuDivider; + +/** + * 钱包连接组件 + * 提供连接钱包、切换钱包、断开连接等功能 + */ +const ConnectWallet: React.FC = () => { + const { t } = useTranslation(); + const [walletMenuVisible, setWalletMenuVisible] = useState(false); + const currentAccount = useCurrentAccount(); + const { currentWallet } = useCurrentWallet(); + const availableWallets = useWallets(); + const { mutate: connectWallet, isPending: isConnecting } = useConnectWallet(); + const { mutate: disconnectWallet } = useDisconnectWallet(); + + // 格式化地址显示 + const formatAddress = (address: string) => { + if (!address) return ''; + if (address.length <= 12) return address; + return `${address.slice(0, 6)}...${address.slice(-4)}`; + }; + + // 复制地址到剪贴板 + const copyAddress = () => { + if (!currentAccount?.address) return; + + navigator.clipboard.writeText(currentAccount.address) + .then(() => { + message.success(t('common.messages.success')); + }) + .catch(err => { + console.error('复制地址失败:', err); + message.error(t('common.messages.error')); + }); + }; + + // 切换钱包 + const handleWalletSelect = (wallet: any) => { + connectWallet({ wallet }); + }; + + // 已连接钱包状态 + if (currentAccount && currentWallet) { + const walletMenuItems: WalletMenuItemType[] = [ + { + key: 'address', + label: ( +
+ {currentAccount.address} +
+ ), + }, + { + type: 'divider', + key: 'divider-1' + }, + { + key: 'disconnect', + icon: , + label: t('common.buttons.cancel'), + onClick: () => disconnectWallet(), + }, + ]; + + return ( + + + + ); + } + + // 点击连接钱包 + const handleConnectClick = () => { + if (availableWallets.length === 1) { + // 只有一个钱包可用,直接连接 + connectWallet({ wallet: availableWallets[0] }); + } else if (availableWallets.length > 1) { + // 显示钱包列表 + setWalletMenuVisible(true); + } else { + // 没有可用钱包 + message.error(t('common.messages.error')); + } + }; + + // 未连接钱包状态 + const walletItems = availableWallets.map(wallet => ({ + key: wallet.name, + label: ( +
+ {wallet.name} + {wallet.name} +
+ ), + onClick: () => handleWalletSelect(wallet), + })); + + return ( + 1} + disabled={isConnecting} + > + + + ); +}; + +export default ConnectWallet; \ No newline at end of file diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/common/LanguageSwitcher.scss b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/common/LanguageSwitcher.scss new file mode 100644 index 00000000..7d8d5763 --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/common/LanguageSwitcher.scss @@ -0,0 +1,28 @@ +.language-switcher-btn { + color: rgba(255, 255, 255, 0.85); + + &:hover { + color: #fff; + background: rgba(255, 255, 255, 0.1); + } +} + +.language-item { + display: flex; + align-items: center; + padding: 4px 0; + cursor: pointer; + + &:hover { + background-color: rgba(0, 0, 0, 0.04); + } + + .language-flag { + margin-right: 8px; + font-size: 16px; + } + + .language-name { + font-size: 14px; + } +} diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/common/LanguageSwitcher.tsx b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/common/LanguageSwitcher.tsx new file mode 100644 index 00000000..b4712113 --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/common/LanguageSwitcher.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Button, Dropdown, Space } from 'antd'; +import { GlobalOutlined } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; +import './LanguageSwitcher.scss'; + +const LanguageSwitcher: React.FC = () => { + const { i18n } = useTranslation(); + + const changeLanguage = (lng: string) => { + i18n.changeLanguage(lng); + }; + + const currentLanguage = i18n.language; + + const items = [ + { + key: 'en', + label: ( +
changeLanguage('en')}> + 🇺🇸 + English +
+ ), + }, + { + key: 'zh', + label: ( +
changeLanguage('zh')}> + 🇨🇳 + 中文 +
+ ), + }, + ]; + + return ( + + + + ); +}; + +export default LanguageSwitcher; diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/layout/Footer.scss b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/layout/Footer.scss new file mode 100644 index 00000000..2be8a5d6 --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/layout/Footer.scss @@ -0,0 +1,42 @@ +.app-footer { + text-align: center; + padding: 24px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + .footer-links { + margin-top: 16px; + + a { + margin: 0 12px; + position: relative; + transition: all 0.3s ease; + padding: 4px 8px; + border-radius: 4px; + + &:hover { + color: #ffffff !important; + background-color: rgba(255, 255, 255, 0.1); + transform: translateY(-2px); + } + + &:after { + content: ''; + position: absolute; + width: 0; + height: 1px; + bottom: 0; + left: 50%; + background-color: #ffffff; + transition: all 0.3s ease; + transform: translateX(-50%); + } + + &:hover:after { + width: 80%; + } + } + } +} \ No newline at end of file diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/layout/Footer.tsx b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/layout/Footer.tsx new file mode 100644 index 00000000..9f9de85a --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/layout/Footer.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Layout, Typography } from 'antd'; +import { useTranslation } from 'react-i18next'; +import './Footer.scss'; + +const { Footer } = Layout; +const { Text, Link } = Typography; + +const AppFooter: React.FC = () => { + const { t } = useTranslation(); + + return ( +
+ {t('footer.copyright', { year: new Date().getFullYear() })} +
+ {t('footer.links.sui')} + {t('footer.links.walrus')} + {t('footer.links.github')} + {t('footer.links.about')} +
+
+ ); +}; + +export default AppFooter; \ No newline at end of file diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/layout/Header.scss b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/layout/Header.scss new file mode 100644 index 00000000..e92fcd1c --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/layout/Header.scss @@ -0,0 +1,176 @@ +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 24px; + height: 70px; + position: relative; + + // 添加高科技特效 + &:after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, + transparent 0%, + rgba(78, 99, 255, 0.3) 20%, + rgba(78, 99, 255, 0.6) 50%, + rgba(78, 99, 255, 0.3) 80%, + transparent 100% + ); + } + + .logo { + margin-right: 30px; + display: flex; + align-items: center; + position: relative; + z-index: 2; + + h3 { + margin: 0; + font-size: 24px; + font-weight: 700; + text-shadow: 0 0 10px rgba(78, 99, 255, 0.5); + + a { + color: #fff; + text-decoration: none; + display: flex; + align-items: center; + position: relative; + font-weight: bold; + letter-spacing: 0.5px; + background: linear-gradient(90deg, #fff, #e0e7ff); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + transition: all 0.3s ease; + + .logo-image { + width: 52px; + height: 52px; + margin-right: 12px; + filter: drop-shadow(0 0 10px rgba(78, 99, 255, 0.8)); + border-radius: 8px; + background-color: rgba(255, 255, 255, 0.15); + padding: 6px; + border: 2px solid rgba(255, 255, 255, 0.3); + box-shadow: 0 0 15px rgba(78, 99, 255, 0.5); + transition: all 0.3s ease; + animation: logo-pulse 3s infinite ease-in-out; + + &:hover { + transform: scale(1.08); + filter: drop-shadow(0 0 15px rgba(78, 99, 255, 1)); + border-color: rgba(255, 255, 255, 0.5); + background-color: rgba(255, 255, 255, 0.25); + animation-play-state: paused; + } + } + + @keyframes logo-pulse { + 0% { box-shadow: 0 0 15px rgba(78, 99, 255, 0.5); } + 50% { box-shadow: 0 0 20px rgba(78, 99, 255, 0.8); } + 100% { box-shadow: 0 0 15px rgba(78, 99, 255, 0.5); } + } + + .anticon { + margin-right: 10px; + font-size: 24px; + } + } + } + } + + .header-menu { + flex: 1; + background: transparent; + border-bottom: none; + line-height: 70px; + color: rgba(255, 255, 255, 0.85); + + .ant-menu-item { + padding: 0 20px; + + a { + color: rgba(255, 255, 255, 0.85); + } + + .anticon { + margin-right: 8px; + font-size: 16px; + } + + &.ant-menu-item-selected { + background: transparent; + + a { + color: #fff; + } + + &:after { + border-bottom: 2px solid #fff; + } + } + + &:hover { + a { + color: #fff; + } + + &:after { + border-bottom: 2px solid rgba(255, 255, 255, 0.5); + } + } + } + } + + .header-right { + margin-left: 24px; + display: flex; + align-items: center; + } + + // 动态科技感效果 + .header-decoration { + position: absolute; + top: 0; + right: 0; + width: 100px; + height: 100%; + pointer-events: none; + overflow: hidden; + + &:before { + content: ''; + position: absolute; + top: 20px; + right: 20px; + width: 50px; + height: 50px; + border-radius: 50%; + border: 1px dashed rgba(255, 255, 255, 0.2); + animation: rotate 20s linear infinite; + } + + &:after { + content: ''; + position: absolute; + top: 30px; + right: 30px; + width: 30px; + height: 30px; + border-radius: 50%; + border: 1px dashed rgba(255, 255, 255, 0.15); + animation: rotate 15s linear infinite reverse; + } + } + + @keyframes rotate { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } +} \ No newline at end of file diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/layout/Header.tsx b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/layout/Header.tsx new file mode 100644 index 00000000..9003a323 --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/layout/Header.tsx @@ -0,0 +1,98 @@ +import React, { useState, useEffect } from 'react'; +import { Layout, Menu, Typography } from 'antd'; +import { HomeOutlined, AppstoreOutlined, PictureOutlined, SettingOutlined } from '@ant-design/icons'; +import { Link, useLocation } from 'react-router-dom'; +import { useCurrentAccount, useSuiClient } from '@mysten/dapp-kit'; +import { useTranslation } from 'react-i18next'; +import { UserRole } from '../../types'; +import { checkUserRole } from '../../utils/auth'; +import ConnectWallet from '../common/ConnectWallet'; +import LanguageSwitcher from '../common/LanguageSwitcher'; +import './Header.scss'; +import logo from '../../assets/logo.svg'; + +const { Header } = Layout; +const { Title } = Typography; + +const AppHeader: React.FC = () => { + const location = useLocation(); + const currentAccount = useCurrentAccount(); + const suiClient = useSuiClient(); + const { t } = useTranslation(); + const [userRole, setUserRole] = useState(UserRole.USER); + const [forceUpdateKey, setForceUpdateKey] = useState(0); + + // 当账户变化时检查用户角色 + useEffect(() => { + if (currentAccount) { + checkUserRole(currentAccount.address, suiClient) + .then(role => { + setUserRole(role); + }) + .catch(error => { + console.error('检查用户角色失败:', error); + setUserRole(UserRole.USER); + }); + } else { + setUserRole(UserRole.USER); + } + }, [currentAccount, suiClient, forceUpdateKey]); + + const menuItems = [ + { + key: '/', + icon: , + label: {t('header.home')}, + }, + { + key: '/ad-spaces', + icon: , + label: {t('header.adSpaces')}, + }, + { + key: '/my-nfts', + icon: , + label: {t('header.myNFTs')}, + }, + ]; + + // 管理员角色显示管理菜单 + if (userRole === UserRole.ADMIN || userRole === UserRole.OWNER || userRole === UserRole.GAME_DEV) { + menuItems.push({ + key: '/manage', + icon: , + label: {t('header.manage')}, + }); + } + + return ( +
+
+ + <Link to="/"> + <img src={logo} alt="NFT Billboard" className="logo-image" /> + {t('app.shortName')} + </Link> + +
+ + + +
+ + +
+ + {/* 添加科技感装饰元素 */} +
+
+ ); +}; + +export default AppHeader; \ No newline at end of file diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/layout/MainLayout.scss b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/layout/MainLayout.scss new file mode 100644 index 00000000..9f70349a --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/layout/MainLayout.scss @@ -0,0 +1,124 @@ +.main-layout { + min-height: 100vh; + background: linear-gradient(to bottom, #f0f2ff, #fbfcff); + + .ant-layout-header { + background: #001529; /* 深色背景 */ + padding: 0; + position: sticky; + top: 0; + z-index: 1000; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); + transition: all 0.3s; + + &.scrolled { + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + } + + // 菜单项样式 + .ant-menu { + background: transparent; + border-bottom: none; + line-height: 70px; + + .ant-menu-item a { + color: rgba(255, 255, 255, 0.85); + } + + .ant-menu-item-selected a { + color: #fff; + } + } + } + + .main-content { + padding: 40px 24px; + background: transparent; + position: relative; + + &:before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-image: radial-gradient(rgba(78, 99, 255, 0.03) 1px, transparent 1px); + background-size: 50px 50px; + pointer-events: none; + } + + .content-container { + max-width: 1200px; + margin: 0 auto; + background: rgba(255, 255, 255, 0.9); + padding: 40px; + border-radius: 16px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05); + backdrop-filter: blur(5px); + border: 1px solid rgba(78, 99, 255, 0.1); + position: relative; + overflow: hidden; + + // 科技装饰元素 + &:before { + content: ''; + position: absolute; + width: 500px; + height: 500px; + border-radius: 50%; + background: radial-gradient(circle, rgba(78, 99, 255, 0.03) 0%, transparent 70%); + top: -250px; + right: -250px; + z-index: 0; + } + + &:after { + content: ''; + position: absolute; + width: 300px; + height: 300px; + border-radius: 50%; + background: radial-gradient(circle, rgba(110, 86, 207, 0.03) 0%, transparent 70%); + bottom: -150px; + left: -150px; + z-index: 0; + } + + & > * { + position: relative; + z-index: 1; + } + } + } + + .ant-layout-footer { + background: #101432; + color: rgba(255, 255, 255, 0.8); + padding: 40px 24px; + position: relative; + overflow: hidden; + + // 添加科技风格网格背景 + &:before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-image: + linear-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px); + background-size: 40px 40px; + pointer-events: none; + } + + .footer-content { + max-width: 1200px; + margin: 0 auto; + position: relative; + z-index: 1; + } + } +} \ No newline at end of file diff --git a/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/layout/MainLayout.tsx b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/layout/MainLayout.tsx new file mode 100644 index 00000000..de984e1d --- /dev/null +++ b/move202503/cuidaquan/nft-billboard/nft_billboard_web/src/components/layout/MainLayout.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Layout } from 'antd'; +import Header from './Header'; +import Footer from './Footer'; +import './MainLayout.scss'; + +const { Content } = Layout; + +interface MainLayoutProps { + children: React.ReactNode; +} + +const MainLayout: React.FC = ({ children }) => { + return ( + +
+ +
+ {children} +
+
+