first commit

This commit is contained in:
2026-02-13 17:36:42 +08:00
commit f067e1bb78
155 changed files with 46676 additions and 0 deletions

1
.env Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=https://api.1818ai.com

26
.eslintrc.js Normal file
View File

@@ -0,0 +1,26 @@
module.exports = {
root: true,
env: {
browser: true,
node: true,
es2021: true
},
extends: [
'eslint:recommended',
'plugin:vue/vue3-recommended'
],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module'
},
globals: {
uni: 'readonly',
wx: 'readonly',
getCurrentPages: 'readonly'
},
rules: {
'vue/multi-word-component-names': 'off',
'no-console': 'warn',
'no-debugger': 'warn'
}
}

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules
dist
.DS_Store
*.local
.env.local
.env.*.local
unpackage

2
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,2 @@
{
}

View File

@@ -0,0 +1,80 @@
# 造梦页面 - 虚拟键盘适配功能
## 功能说明
当用户在造梦页面的创造模式中输入文字时,虚拟键盘会弹起。为了避免上传组件被键盘遮挡,实现了以下适配方案:
### 适配方案
1. **键盘监听**
- 使用 `uni.onKeyboardHeightChange` 监听虚拟键盘的显示和隐藏
- 实时获取键盘高度,判断键盘是否可见
2. **响应式布局变化**
- **键盘未弹起时**:上传组件显示为大型卡片样式,位于内容区域中央
- **键盘弹起时**
- 隐藏大型上传卡片
- 在输入框右上角显示紧凑的长方形上传按钮
- 输入框自动为上传按钮留出空间(右侧 padding
3. **视觉效果**
- 紧凑上传按钮采用圆角矩形设计
- 包含图标和"上传"文字
- 带有滑入动画效果
- 点击时有缩放反馈
## 技术实现
### 状态管理
```javascript
const keyboardHeight = ref(0) // 键盘高度
const isKeyboardVisible = ref(false) // 键盘是否可见
```
### 键盘监听
```javascript
const setupKeyboardListener = () => {
uni.onKeyboardHeightChange((res) => {
keyboardHeight.value = res.height
isKeyboardVisible.value = res.height > 0
})
}
const removeKeyboardListener = () => {
uni.offKeyboardHeightChange()
}
```
### 生命周期
- `onShow`: 设置键盘监听器
- `onHide`: 移除键盘监听器
### 条件渲染
- 大型上传卡片:`v-if="isExpanded && !isKeyboardVisible"`
- 紧凑上传按钮:`v-if="isKeyboardVisible && canAddMoreImages"`
## 样式特点
### 紧凑上传按钮
- 位置:绝对定位在输入框右上角
- 尺寸:自适应内容,紧凑设计
- 颜色:深色背景 (#3f3f46),与整体风格一致
- 动画:滑入效果 (slideInRight)
- 交互:点击缩放反馈
### 输入框适配
- 动态 padding-right当上传按钮显示时自动调整
- 平滑过渡:使用 CSS transition
## 用户体验
1. **无缝切换**:键盘弹起/收起时,上传功能始终可用
2. **空间优化**:键盘弹起时最大化利用可见区域
3. **视觉连贯**:保持统一的设计语言
4. **操作便捷**:上传按钮始终在易于点击的位置
## 兼容性
- 支持 uni-app 的所有平台
- 特别优化移动端体验
- 自动适配不同尺寸的虚拟键盘

41
README.md Normal file
View File

@@ -0,0 +1,41 @@
# UniApp 企业级项目模板
## 技术栈
- Vue 3 + UniApp
- uView Plus 3 (全端兼容组件库)
- TailwindCSS 3
- Pinia 状态管理
- ESLint 代码规范
## 项目结构
```
src/
├── api/ # 接口请求
├── components/ # 公共组件
├── hooks/ # 组合式函数
├── pages/ # 页面
├── static/ # 静态资源
├── store/ # 状态管理
├── styles/ # 样式文件
└── utils/ # 工具函数
```
## 开发命令
```bash
npm install # 安装依赖
npm run dev:h5 # H5 开发
npm run dev:mp-weixin # 微信小程序开发
npm run build:h5 # H5 构建
```
## uView Plus 组件
项目已配置 easycom可直接使用 `up-xxx` 组件:
- `up-button` 按钮
- `up-cell` 单元格
- `up-form` 表单
- `up-navbar` 导航栏
- 更多组件请参考https://uview-plus.jiangruyi.com/
## 注意事项
- tabbar 图标需替换为实际图片
- `.env` 文件配置 API 地址

View File

@@ -0,0 +1,250 @@
# 微信小程序分享功能验证指南
## 功能实现状态 ✅
项目已完整实现微信小程序的分享功能和推广配置:
### 1. 全局分享混入 (globalShareMixin.js)
-**使用Vue全局混入确保所有页面都能分享**
- ✅ 所有页面自动支持"分享给好友"功能
- ✅ 所有页面自动支持"分享到朋友圈"功能
- ✅ 自动添加推广码到分享链接
- ✅ 根据不同页面生成不同的分享内容
- ✅ 解决"未设置分享"的问题
### 2. 推广码处理机制
- ✅ 用户分享时自动携带自己的邀请码(`inviteCode`)作为`shareCode`参数
- ✅ 新用户通过分享链接进入时自动保存推广码到本地存储
- ✅ 推广码可用于邀请奖励等功能
### 3. 分享菜单配置
- ✅ 启用微信小程序右上角胶囊的分享功能
- ✅ 支持两种分享方式:分享给好友、分享到朋友圈
## 验证步骤
### 第一步:验证分享菜单是否启用
1. 在微信开发者工具或真机上运行项目
2. 点击右上角的"···"胶囊按钮
3. **预期结果**:应该看到"转发"和"分享到朋友圈"选项
### 第二步:测试分享给好友功能
1. 登录小程序并确保用户有邀请码
2. 进入任意页面(如灵感广场、造梦详情页)
3. 点击右上角"···" → 选择"转发"
4. **预期结果**:
- 显示自定义的分享标题和图片
- 打开控制台,应该看到类似的日志:
```
=== 微信分享给好友触发 ===
最终分享路径: /pages/inspiration/index?shareCode=用户的邀请码
```
### 第三步:测试分享到朋友圈功能
1. 进入任意页面
2. 点击右上角"···" → 选择"分享到朋友圈"
3. **预期结果**:
- 显示自定义的分享标题和图片
- 打开控制台,应该看到类似的日志:
```
=== 微信分享到朋友圈触发 ===
最终分享query: shareCode=用户的邀请码
```
### 第四步:验证推广码接收
1. 用A账号分享任意页面给B
2. B通过分享卡片进入小程序
3. 打开控制台查看日志
4. **预期结果**:
```
=== handleShareCode ===
提取的shareCode: A的邀请码
shareCode已保存到localStorage
```
5. 检查本地存储:
```javascript
uni.getStorageSync('shareCode') // 应该返回A的邀请码
```
## 分享配置说明
### 不同页面的分享内容
| 页面 | 分享标题 | 分享路径 | 备注 |
|------|---------|---------|------|
| 灵感广场 | AI创作神器 - 发现精彩作品 | /pages/inspiration/index | 默认首页 |
| 造梦首页 | AI造梦 - 释放无限想象 | /pages/dream/index | - |
| 造梦详情 | 看看我用AI生成的作品! | /pages/dream/detail?taskNo=xxx | 包含任务编号 |
| 作品详情 | 作品提示词或描述 | /pages/work/detail?id=xxx | 使用作品图片 |
| 我的页面 | AI创作作品集 | /pages/inspiration/index | - |
| 资产页面 | 我的AI创作资产 | /pages/assets/index | - |
| AI模型详情 | 模型名称 - 强大的AI创作工具 | /pages/ai/detail?id=xxx | 使用模型图标 |
| 邀请页面 | 邀好友赢500积分 | /pages/inspiration/index | 重点推广 |
### 推广码生成规则
**分享给好友:**
- 格式: `{原始路径}?shareCode={用户邀请码}`
- 示例: `/pages/inspiration/index?shareCode=ABC123`
**分享到朋友圈:**
- 格式: `{原始query}&shareCode={用户邀请码}`
- 示例: `id=123&shareCode=ABC123`
## 常见问题排查
### 问题1:看不到分享菜单
**可能原因:**
- 未在微信小程序真机或开发者工具中运行
- `App.vue` 中的 `uni.showShareMenu` 未正常调用
**解决方案:**
检查 `App.vue` 的 `onLaunch` 和 `onShow` 方法是否正常执行
### 问题2:分享时没有携带推广码
**可能原因:**
- 用户未登录
- 用户没有邀请码(`inviteCode`)
**解决方案:**
1. 确保用户已登录: `userStore.isLogin === true`
2. 确保用户信息中有邀请码: `userStore.userInfo.inviteCode`
3. 检查控制台日志,查看 `generateSharePath` 的输出
### 问题3:接收到的推广码未保存
**可能原因:**
- 页面未调用 `handleShareCode(options)`
- 本地存储功能异常
**解决方案:**
确保页面的 `onLoad` 生命周期中调用了:
```javascript
import { handleShareCode } from '@/utils/navigation'
onLoad((options) => {
handleShareCode(options)
})
```
### 问题4:视频作品分享时图片显示不正确
**说明:**
这是预期行为。根据代码逻辑:
- 图片作品:使用作品图片作为分享图
- 视频作品:使用默认图片(因为微信不支持视频封面)
## 技术架构说明
### 核心文件
1. **src/mixins/globalShareMixin.js** - 🌟 全局分享混入(新增)
- **通过Vue的app.mixin()注册到所有页面**
- `onShareAppMessage()` - 自动为所有页面提供分享给好友功能
- `onShareTimeline()` - 自动为所有页面提供分享到朋友圈功能
- `getShareConfigByRoute()` - 根据路由智能生成分享配置
- **解决了微信小程序"未设置分享"的问题**
2. **src/main.js** - 应用入口
- 注册全局分享混入: `app.mixin(globalShareMixin)`
- 确保每个页面都自动具备分享功能
3. **src/mixins/shareMixin.js** - 页面级分享配置(可选)
- `useShareMixin()` - 页面级自定义分享配置
- `shareConfigs` - 预定义的分享配置生成器
4. **src/utils/navigation.js** - 推广码工具
- `generateSharePath()` - 生成带推广码的路径
- `generateShareQuery()` - 生成带推广码的query
- `handleShareCode()` - 处理接收到的推广码
5. **App.vue** - 应用级配置
- 在`onLaunch`和`onShow`中启用分享菜单
- 提供额外的兜底逻辑
### 分享流程图
```
用户A分享
生成分享链接(携带A的邀请码)
用户B点击分享卡片
小程序打开,onLoad接收参数
handleShareCode保存推广码
推广码可用于邀请奖励统计
```
## 扩展功能建议
### 1. 添加分享统计
在 `App.vue` 的分享方法中添加统计:
```javascript
onShareAppMessage() {
// 记录分享事件
uni.request({
url: '/api/statistics/share',
method: 'POST',
data: {
userId: userStore.userInfo.userId,
page: route,
type: 'appMessage'
}
})
// 返回分享配置
return { ... }
}
```
### 2. 分享奖励提示
用户分享成功后给予提示:
```javascript
onShareAppMessage((res) => {
if (res.errMsg === 'shareAppMessage:ok') {
uni.showToast({
title: '分享成功,感谢推广!',
icon: 'success'
})
}
})
```
### 3. 自定义分享按钮
在页面中添加自定义分享按钮:
```vue
<button open-type="share" class="custom-share-btn">
<image src="/static/icons/share.png" />
<text>分享给好友</text>
</button>
```
## 测试检查清单
- [ ] 所有页面都能打开分享菜单
- [ ] 分享给好友时显示正确的标题、图片和路径
- [ ] 分享到朋友圈时显示正确的内容
- [ ] 登录用户分享时携带推广码
- [ ] 通过分享链接进入时正确保存推广码
- [ ] 造梦详情页分享包含正确的taskNo
- [ ] 作品详情页分享包含正确的作品ID
- [ ] 图片作品分享时使用作品图片
- [ ] 视频作品分享时使用默认图片
- [ ] 未登录用户分享时不携带推广码但功能正常
## 相关文档
- [微信小程序分享功能官方文档](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/share.html)
- [uni-app分享API文档](https://uniapp.dcloud.net.cn/api/plugins/share.html)

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>UniApp Enterprise</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

10666
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "uniapp-enterprise",
"version": "1.0.0",
"description": "企业级 UniApp 项目",
"scripts": {
"dev:h5": "uni",
"build:h5": "uni build",
"dev:mp-weixin": "uni -p mp-weixin",
"build:mp-weixin": "uni build -p mp-weixin",
"dev:app": "uni -p app",
"build:app": "uni build -p app",
"lint": "eslint --ext .js,.vue src",
"lint:fix": "eslint --ext .js,.vue src --fix"
},
"dependencies": {
"clipboard": "^2.0.11",
"dayjs": "^1.11.11",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.1",
"uview-plus": "^3.3.36",
"vue": "^3.4.21"
},
"devDependencies": {
"@dcloudio/types": "^3.4.8",
"@dcloudio/uni-app": "3.0.0-alpha-5000020260104004",
"@dcloudio/uni-app-plus": "3.0.0-alpha-5000020260104004",
"@dcloudio/uni-components": "3.0.0-alpha-5000020260104004",
"@dcloudio/uni-h5": "3.0.0-alpha-5000020260104004",
"@dcloudio/uni-mp-weixin": "3.0.0-alpha-5000020260104004",
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-5000020260104004",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.26.0",
"postcss": "^8.4.38",
"sass": "^1.97.2",
"tailwindcss": "^3.4.4",
"vite": "^5.2.8"
}
}

3538
pencil/yi.pen Normal file

File diff suppressed because it is too large Load Diff

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

426
src/App.vue Normal file
View File

@@ -0,0 +1,426 @@
<script>
import { generateSharePath, generateShareQuery } from '@/utils/navigation'
export default {
onLaunch() {
console.log('App Launch')
// 设置全局默认分享
// #ifdef MP-WEIXIN
uni.showShareMenu({
withShareTicket: true,
menus: ['shareAppMessage', 'shareTimeline']
})
// #endif
},
onShow() {
console.log('App Show')
// 确保每次显示时都启用分享菜单
// #ifdef MP-WEIXIN
uni.showShareMenu({
withShareTicket: true,
menus: ['shareAppMessage', 'shareTimeline']
})
// #endif
},
onHide() {
console.log('App Hide')
},
// 全局分享给好友 - 作为所有页面的兜底分享
onShareAppMessage() {
console.log('=== App.vue 全局分享给好友触发 ===')
try {
// 获取当前页面信息
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const route = currentPage.route
const options = currentPage.options
console.log('当前页面路由:', route)
console.log('当前页面参数:', options)
// 尝试获取用户store如果失败则使用默认值
let userStore = null
try {
// 通过全局方式获取用户信息 - 修复存储键名
const userStoreData = uni.getStorageSync('user-store')
let userInfo = null
let isLogin = false
if (userStoreData) {
const parsedData = JSON.parse(userStoreData)
userInfo = parsedData.userInfo
isLogin = !!parsedData.token && !!userInfo
}
userStore = {
isLogin,
userInfo: userInfo || null
}
console.log('用户登录状态:', isLogin)
console.log('用户信息:', userInfo)
console.log('用户邀请码:', userInfo?.inviteCode)
} catch (e) {
console.log('获取用户信息失败,使用默认值:', e)
userStore = { isLogin: false, userInfo: null }
}
// 根据不同页面生成不同的分享配置
let shareConfig = this.getShareConfigByRoute(route, options, userStore)
// 生成带推广码的分享路径
const finalPath = generateSharePath(shareConfig.path, userStore)
console.log('全局分享配置:', shareConfig)
console.log('最终分享路径:', finalPath)
const result = {
title: shareConfig.title,
path: finalPath,
imageUrl: shareConfig.imageUrl
}
console.log('=== 返回分享结果 ===', result)
return result
} catch (error) {
console.error('=== 全局分享出错 ===', error)
// 返回默认分享配置
return {
title: 'AI创作神器 - 一键生成精美图片和视频',
path: '/pages/inspiration/index',
imageUrl: 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
}
}
},
// 全局分享到朋友圈 - 作为所有页面的兜底分享
onShareTimeline() {
console.log('=== App.vue 全局分享到朋友圈触发 ===')
try {
// 获取当前页面信息
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const route = currentPage.route
const options = currentPage.options
console.log('朋友圈分享 - 当前页面路由:', route)
console.log('朋友圈分享 - 当前页面参数:', options)
// 尝试获取用户信息
let userStore = null
try {
// 通过全局方式获取用户信息 - 修复存储键名
const userStoreData = uni.getStorageSync('user-store')
let userInfo = null
let isLogin = false
if (userStoreData) {
const parsedData = JSON.parse(userStoreData)
userInfo = parsedData.userInfo
isLogin = !!parsedData.token && !!userInfo
}
userStore = {
isLogin,
userInfo: userInfo || null
}
console.log('朋友圈分享 - 用户登录状态:', isLogin)
console.log('朋友圈分享 - 用户信息:', userInfo)
console.log('朋友圈分享 - 用户邀请码:', userInfo?.inviteCode)
} catch (e) {
console.log('获取用户信息失败,使用默认值:', e)
userStore = { isLogin: false, userInfo: null }
}
// 根据不同页面生成不同的分享配置
let shareConfig = this.getShareConfigByRoute(route, options, userStore)
// 生成带推广码的分享query
const baseQuery = shareConfig.query || ''
const finalQuery = generateShareQuery(baseQuery, userStore)
console.log('朋友圈分享配置:', shareConfig)
console.log('朋友圈最终query:', finalQuery)
const result = {
title: shareConfig.title,
query: finalQuery,
imageUrl: shareConfig.imageUrl
}
console.log('=== 返回朋友圈分享结果 ===', result)
return result
} catch (error) {
console.error('=== 朋友圈分享出错 ===', error)
// 返回默认分享配置
return {
title: 'AI创作神器 - 一键生成精美图片和视频',
query: '',
imageUrl: 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
}
}
},
methods: {
/**
* 根据页面路由生成分享配置
*/
getShareConfigByRoute(route, options = {}, userStore) {
console.log('=== 根据路由生成分享配置 ===')
console.log('页面路由:', route)
console.log('页面参数:', options)
console.log('用户登录状态:', userStore?.isLogin)
console.log('用户邀请码:', userStore?.userInfo?.inviteCode)
const defaultConfig = {
title: 'AI创作神器 - 一键生成精美图片和视频',
path: '/pages/inspiration/index',
query: '',
imageUrl: 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
}
// 根据不同页面返回不同的分享配置
switch (route) {
case 'pages/dream/detail':
// 获取造梦任务信息用于分享
const taskNo = options.taskNo || ''
let shareImageUrl = 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
// 尝试从当前页面获取任务数据
try {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
if (currentPage && currentPage.$vm && currentPage.$vm.taskData) {
const taskData = currentPage.$vm.taskData
if (taskData.outputResult) {
try {
const result = JSON.parse(taskData.outputResult)
const outputUrl = result.result || result.url || result.videoUrl || result.imageUrl || ''
// 判断是否为视频文件 - 参考广场作品详情页的做法
const isVideo = outputUrl && (
outputUrl.includes('.mp4') ||
outputUrl.includes('.mov') ||
outputUrl.includes('video') ||
outputUrl.includes('.avi') ||
outputUrl.includes('.mkv')
)
// 视频作品使用默认图片,图片作品使用作品图片(与广场保持一致)
if (!isVideo && outputUrl) {
shareImageUrl = outputUrl
}
// 视频作品保持使用默认图片
} catch (e) {
console.log('解析任务输出结果失败,使用默认图片')
}
}
}
} catch (e) {
console.log('获取造梦任务信息失败,使用默认值')
}
return {
title: '看看我用AI生成的作品',
path: `/pages/dream/detail?taskNo=${taskNo}`,
query: `taskNo=${taskNo}`,
imageUrl: shareImageUrl
}
case 'pages/work/detail':
// 获取作品信息用于分享
const workId = options.id || ''
let workTitle = 'AI创作作品分享'
let workImageUrl = 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
// 尝试从当前页面获取作品信息
try {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
if (currentPage && currentPage.$vm && currentPage.$vm.currentWork) {
const work = currentPage.$vm.currentWork
if (work) {
workTitle = work.prompt || work.description || '看看我的AI创作作品'
// 对于视频作品,直接使用默认图片;对于图片作品,使用作品图片
if (work.contentType === 2) {
// 视频作品使用默认图片
workImageUrl = 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
} else if (work.contentUrl) {
// 图片作品使用作品图片
workImageUrl = work.contentUrl
}
}
}
} catch (e) {
console.log('获取作品信息失败,使用默认值')
}
return {
title: workTitle,
path: `/pages/work/detail?id=${workId}`,
query: `id=${workId}`,
imageUrl: workImageUrl
}
case 'pages/invite/index':
return {
title: '邀好友赢500积分 - 快来体验AI创作神器',
path: '/pages/inspiration/index',
query: '',
imageUrl: 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
}
case 'pages/user/index':
return {
title: 'AI创作作品集',
path: '/pages/inspiration/index',
query: '',
imageUrl: 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
}
case 'pages/ai/detail':
// 获取AI模型信息用于分享
const aiModelId = options.id || ''
let aiModelName = 'AI功能'
let aiModelIcon = 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
// 尝试从当前页面获取模型信息
try {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
if (currentPage && currentPage.$vm && currentPage.$vm.model) {
const model = currentPage.$vm.model
aiModelName = model.name || aiModelName
aiModelIcon = model.icon || aiModelIcon
}
} catch (e) {
console.log('获取AI模型信息失败使用默认值')
}
return {
title: `${aiModelName} - 强大的AI创作工具`,
path: `/pages/ai/detail?id=${aiModelId}`,
query: `id=${aiModelId}`,
imageUrl: aiModelIcon
}
case 'pages/dream/index':
return {
title: 'AI造梦 - 释放无限想象',
path: '/pages/dream/index',
query: '',
imageUrl: 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
}
case 'pages/assets/index':
return {
title: '我的AI创作资产',
path: '/pages/assets/index',
query: '',
imageUrl: 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
}
case 'pages/ai/create':
return {
title: 'AI创作工具 - 释放你的创意',
path: '/pages/inspiration/index',
query: '',
imageUrl: 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
}
case 'pages/ai/models':
return {
title: 'AI模型库 - 探索强大的AI功能',
path: '/pages/inspiration/index',
query: '',
imageUrl: 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
}
case 'pages/ai/task':
return {
title: 'AI任务进度 - 创作进行中',
path: '/pages/inspiration/index',
query: '',
imageUrl: 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
}
case 'pages/points/subscribe':
return {
title: 'AI创作神器 - 积分订阅,解锁更多功能',
path: '/pages/inspiration/index',
query: '',
imageUrl: 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
}
case 'pages/profile/edit':
return {
title: 'AI创作神器 - 完善个人资料',
path: '/pages/inspiration/index',
query: '',
imageUrl: 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
}
case 'pages/login/index':
return {
title: 'AI创作神器 - 登录体验更多功能',
path: '/pages/inspiration/index',
query: '',
imageUrl: 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
}
case 'pages/dream/create':
return {
title: 'AI造梦创作 - 一键生成精美作品',
path: '/pages/dream/index',
query: '',
imageUrl: 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
}
case 'pages/inspiration/index':
return {
title: 'AI创作神器 - 发现精彩作品,释放创意灵感',
path: '/pages/inspiration/index',
query: '',
imageUrl: 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
}
case 'pages/search/index':
return {
title: 'AI创作神器 - 搜索发现更多精彩',
path: '/pages/inspiration/index',
query: '',
imageUrl: 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
}
case 'pages/work/publish':
return {
title: 'AI创作神器 - 发布你的精彩作品',
path: '/pages/inspiration/index',
query: '',
imageUrl: 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
}
default:
console.log('未匹配到特定页面配置,使用默认配置')
console.log('当前页面路由:', route)
return {
...defaultConfig,
title: 'AI创作神器 - 一键生成精美图片和视频'
}
}
}
}
}
</script>
<style>
/* 小程序兼容的滚动条隐藏方式 */
scroll-view {
scrollbar-width: none;
-ms-overflow-style: none;
}
</style>

95
src/api/ai.js Normal file
View File

@@ -0,0 +1,95 @@
import request from '@/utils/request'
// 获取可用的AI模型列表
export function getAiModels(type) {
return request({
url: '/ai/models',
method: 'GET',
data: { type }
})
}
// 获取首页展示的模型列表(最多返回指定数量)
export function getHomeAiModels(limit = 4) {
return request({
url: '/ai/models/home',
method: 'GET',
data: { limit }
})
}
// 获取模型详情
export function getAiModel(id) {
return request({
url: `/ai/models/${id}`,
method: 'GET'
})
}
// 根据编码获取模型详情
export function getAiModelByCode(code, options = {}) {
return request({
url: `/ai/models/code/${code}`,
method: 'GET',
...options
})
}
// 创建AI任务
export function createAiTask(data) {
return request({
url: '/ai/tasks',
method: 'POST',
data
})
}
// 获取我的任务列表
export function getMyAiTasks(params, options = {}) {
return request({
url: '/ai/tasks',
method: 'GET',
data: params,
...options
})
}
// 获取任务详情
export function getAiTask(id) {
return request({
url: `/ai/tasks/${id}`,
method: 'GET'
})
}
// 根据任务编号获取任务详情
export function getAiTaskByNo(taskNo) {
return request({
url: `/ai/tasks/no/${taskNo}`,
method: 'GET'
})
}
// 取消任务
export function cancelAiTask(id) {
return request({
url: `/ai/tasks/${id}/cancel`,
method: 'POST'
})
}
// 删除任务
export function deleteAiTask(id) {
return request({
url: `/ai/tasks/${id}`,
method: 'DELETE'
})
}
// 获取任务详情(公开接口,不需要认证)
export function getAiTaskByIdPublic(id) {
return request({
url: `/ai/tasks/public/${id}`,
method: 'GET'
})
}

9
src/api/banner.js Normal file
View File

@@ -0,0 +1,9 @@
import { get } from '@/utils/request'
/**
* 获取Banner列表
* @param {string} position - 位置home首页 plaza广场
*/
export function getBannerList(position = 'home') {
return get('/banner/list', { position })
}

11
src/api/category.js Normal file
View File

@@ -0,0 +1,11 @@
import { get } from '@/utils/request'
/**
* 获取分类列表(无需登录)
*/
export const getCategoryList = () => get('/category/list')
/**
* 获取分类列表(别名)
*/
export const getCategories = getCategoryList

7
src/api/model.js Normal file
View File

@@ -0,0 +1,7 @@
import { get } from '@/utils/request'
/**
* 获取模型分类列表(用于资产页面筛选)
* 返回启用状态的模型列表
*/
export const getModelCategories = () => get('/ai/models/categories', {}, { showLoading: false })

49
src/api/notice.js Normal file
View File

@@ -0,0 +1,49 @@
import { get, post } from '@/utils/request'
/**
* 获取公告列表
* @param {Object} params
* @param {number} params.page - 页码默认1
* @param {number} params.pageSize - 每页条数默认10
* @param {number} [params.type] - 公告类型(可选)
*/
export function getNoticeList(params = { page: 1, pageSize: 20 }) {
return get('/notice/list', params)
}
/**
* 获取公告详情
* @param {number} id - 公告ID
*/
export function getNoticeDetail(id) {
return get(`/notice/${id}`)
}
/**
* 获取未读公告数量(需登录)
*/
export function getUnreadCount() {
return get('/notice/unread/count')
}
/**
* 标记单条公告已读(需登录)
* @param {number} id - 公告ID
*/
export function markNoticeRead(id) {
return post(`/notice/${id}/read`)
}
/**
* 标记所有公告已读(需登录)
*/
export function markAllNoticeRead() {
return post('/notice/read/all')
}
/**
* 获取弹窗公告(需登录,返回未读的弹窗公告列表)
*/
export function getPopupNotices() {
return get('/notice/popup')
}

27
src/api/points.js Normal file
View File

@@ -0,0 +1,27 @@
import request from '@/utils/request'
// 获取积分套餐列表
export function getPointsPackages() {
return request({
url: '/points/packages',
method: 'get'
})
}
// 创建积分订单
export function createPointsOrder(data) {
return request({
url: '/points/order',
method: 'post',
data
})
}
// 取消订单
export function cancelPointsOrder(orderNo) {
return request({
url: '/points/order/cancel',
method: 'post',
params: { orderNo }
})
}

134
src/api/upload.js Normal file
View File

@@ -0,0 +1,134 @@
import request from '@/utils/request'
import config from '@/config'
import { useUserStore } from '@/store/modules/user'
const BASE_URL = config.baseUrl
/**
* 上传单个图片
* @param {File} file 图片文件
* @returns {Promise}
*/
export function uploadImage(file) {
const formData = new FormData()
formData.append('file', file)
return request({
url: '/upload/image',
method: 'post',
data: formData,
header: {
'Content-Type': 'multipart/form-data'
}
})
}
/**
* 上传多个图片
* @param {Array<File>} files 图片文件数组
* @returns {Promise}
*/
export function uploadImages(files) {
const formData = new FormData()
files.forEach(file => {
formData.append('files', file)
})
return request({
url: '/upload/images',
method: 'post',
data: formData,
header: {
'Content-Type': 'multipart/form-data'
}
})
}
/**
* uni-app 上传单个图片(使用 uni.uploadFile
* @param {String} filePath 本地文件路径
* @param {String} type 文件类型 image/video
* @returns {Promise}
*/
export function uniUploadImage(filePath, type = 'image') {
return new Promise((resolve, reject) => {
// 使用pinia store获取token
const userStore = useUserStore()
// 检查登录状态
if (!userStore.isLogin) {
reject(new Error('请先登录'))
return
}
const token = userStore.token
const endpoint = type === 'video' ? '/upload/video' : '/upload/image'
console.log('开始上传文件:', {
filePath,
type,
endpoint,
url: BASE_URL + endpoint,
hasToken: !!token,
isLogin: userStore.isLogin
})
uni.uploadFile({
url: BASE_URL + endpoint,
filePath: filePath,
name: 'file',
header: {
'Authorization': `Bearer ${token}`
},
success: (res) => {
console.log('上传响应:', res)
if (res.statusCode === 200) {
try {
const data = JSON.parse(res.data)
console.log('解析后的响应数据:', data)
if (data.code === 0) {
resolve(data.data)
} else {
reject(new Error(data.message || '上传失败'))
}
} catch (e) {
console.error('解析响应失败:', e, res.data)
reject(new Error('解析响应失败'))
}
} else if (res.statusCode === 401) {
// token过期清除登录状态
userStore.logout()
reject(new Error('登录已过期,请重新登录'))
} else {
console.error('上传失败,状态码:', res.statusCode, res)
reject(new Error(`上传失败(${res.statusCode})`))
}
},
fail: (err) => {
console.error('上传请求失败:', err)
reject(new Error(err.errMsg || '网络错误'))
}
})
})
}
/**
* uni-app 上传视频
* @param {String} filePath 本地文件路径
* @returns {Promise}
*/
export function uniUploadVideo(filePath) {
return uniUploadImage(filePath, 'video')
}
/**
* uni-app 上传多个图片(使用 uni.uploadFile
* @param {Array<String>} filePaths 本地文件路径数组
* @returns {Promise}
*/
export function uniUploadImages(filePaths) {
const uploadPromises = filePaths.map(filePath => uniUploadImage(filePath))
return Promise.all(uploadPromises)
}

131
src/api/user.js Normal file
View File

@@ -0,0 +1,131 @@
import { get, post } from '@/utils/request'
/**
* 检查用户是否存在及信息完整度
* @param {Object} data - 参数
* @param {string} data.code - 微信登录code
* @returns {Object} { openid: string, exists: boolean, isComplete: boolean, user: { phone, avatar, nickname } }
*/
export const checkUser = (data) => post('/user/check', data)
/**
* 微信登录
* @param {Object} data - 登录参数
* @param {string} data.code - 微信登录code首次登录
* @param {string} data.openid - 微信openid从check接口获取
* @param {string} data.phoneCode - 手机号授权code
* @param {string} data.nickname - 用户昵称
* @param {string} data.avatar - 用户头像URL
* @returns {Object} { userId, token, refreshToken, expiresIn, nickname, avatar, phone, vipLevel, points }
*/
export const wxLogin = (data) => {
console.log('=== 用户API微信登录 ===')
console.log('请求参数:', data)
return post('/user/wx-login', data).then(res => {
console.log('=== 用户API登录响应 ===')
console.log('响应数据:', res)
return res
}).catch(err => {
console.error('=== 用户API登录失败 ===')
console.error('错误信息:', err)
throw err
})
}
/**
* 刷新Token
* @param {Object} data - 参数
* @param {string} data.refreshToken - 刷新令牌
* @returns {Object} { token, refreshToken, expiresIn }
*/
export const refreshToken = (data) => post('/user/refresh-token', data)
/**
* 获取用户信息
*/
export const getUserInfo = () => get('/user/info')
/**
* 获取用户主页信息(包含统计数据)
* @returns {Object} { userId, nickname, avatar, inviteCode, vipLevel, points, publishCount, likedCount }
*/
export const getUserProfile = () => get('/user/profile')
/**
* 获取邀请统计信息
* @returns {Object} { totalPoints, inviteCount }
*/
export const getInviteStats = () => get('/user/invite-stats')
/**
* 获取积分统计信息
* @returns {Object} { subscribePoints, giftPoints }
*/
export const getPointsStats = () => get('/user/points-stats')
/**
* 获取邀请记录列表
* @param {number} pageNum - 页码
* @param {number} pageSize - 每页数量
* @returns {Object} { total, list }
*/
export const getInviteRecords = (pageNum = 1, pageSize = 20) =>
get('/user/invite-records', { pageNum, pageSize })
/**
* 获取积分记录列表
* @param {number} pageNum - 页码
* @param {number} pageSize - 每页数量
* @param {number} type - 类型筛选: 1充值 2消费 3赠送 4推广奖励 5签到 6退款
* @returns {Object} { total, list }
*/
export const getPointsRecords = (pageNum = 1, pageSize = 20, type = null) => {
const params = { pageNum, pageSize }
if (type !== null) {
params.type = type
}
return get('/user/points-records', params)
}
/**
* 获取用户发布的作品
* @param {Object} params - 分页参数
* @param {number} params.pageNum - 页码
* @param {number} params.pageSize - 每页数量
*/
export const getUserWorks = (params) => get('/user/works', params, { showLoading: false })
/**
* 获取用户点赞的作品
* @param {Object} params - 分页参数
* @param {number} params.pageNum - 页码
* @param {number} params.pageSize - 每页数量
*/
export const getUserLikedWorks = (params) => get('/user/liked-works', params, { showLoading: false })
/**
* 更新用户资料
* @param {Object} data - 用户资料
* @param {string} data.nickname - 昵称
* @param {string} data.avatar - 头像URL
*/
export const updateUserProfile = (data) => post('/user/update-profile', data)
/**
* 上传头像(用于将微信临时头像持久化)
* 注意:此接口使用 uni.uploadFile 调用,不走此处
*/
export const uploadAvatar = '/user/upload-avatar'
/**
* 获取用户订阅状态
* @returns {boolean} 是否已订阅
*/
export const getSubscribed = () => get('/user/subscribed', {}, { showLoading: false })
/**
* 更新用户订阅状态
* @param {boolean} subscribed - 是否订阅
*/
export const updateSubscribed = (subscribed) => post('/user/subscribed', { subscribed })

318
src/api/videoProject.js Normal file
View File

@@ -0,0 +1,318 @@
import request from '@/utils/request'
// ========== 项目管理 ==========
// 创建项目
export function createProject() {
return request({
url: '/video-project/create',
method: 'POST'
})
}
// 获取项目详情
export function getProject(projectId) {
return request({
url: `/video-project/${projectId}`,
method: 'GET'
})
}
// 获取项目列表
export function getProjectList(params) {
return request({
url: '/video-project/list',
method: 'GET',
data: params
})
}
// 更新项目设置
export function updateProjectSettings(projectId, data) {
return request({
url: `/video-project/${projectId}/settings`,
method: 'PUT',
data
})
}
// 删除项目
export function deleteProject(projectId) {
return request({
url: `/video-project/${projectId}`,
method: 'DELETE'
})
}
// ========== 剧本生成 ==========
// 生成剧本
export function generateScript(projectId, idea) {
return request({
url: `/video-project/${projectId}/generate-script`,
method: 'POST',
data: { idea },
timeout: 120000
})
}
// ========== 角色管理 ==========
// 获取项目角色列表
export function getProjectCharacters(projectId) {
return request({
url: `/video-project/${projectId}/characters`,
method: 'GET'
})
}
// 保存角色
export function saveCharacter(projectId, data) {
return request({
url: `/video-project/${projectId}/character`,
method: 'POST',
data
})
}
// 删除角色
export function deleteCharacter(projectId, characterId) {
return request({
url: `/video-project/${projectId}/character/${characterId}`,
method: 'DELETE'
})
}
// 生成角色形象
export function generateCharacterImage(projectId, characterId) {
return request({
url: `/video-project/${projectId}/character/${characterId}/generate-image`,
method: 'POST',
timeout: 60000
})
}
// 获取角色模板库
export function getCharacterTemplates(category) {
return request({
url: '/video-project/character-templates',
method: 'GET',
data: { category }
})
}
// 获取用户角色库(跨项目,已生成形象的角色)
export function getUserCharacterLibrary() {
return request({
url: '/video-project/character-library',
method: 'GET'
})
}
// ========== 场次管理 ==========
// 获取场次列表
export function getProjectScenes(projectId) {
return request({
url: `/video-project/${projectId}/scenes`,
method: 'GET'
})
}
// 保存场次
export function saveScene(projectId, data) {
return request({
url: `/video-project/${projectId}/scene`,
method: 'POST',
data
})
}
// 删除场次
export function deleteScene(projectId, sceneId) {
return request({
url: `/video-project/${projectId}/scene/${sceneId}`,
method: 'DELETE'
})
}
// ========== 分镜管理 ==========
// 获取分镜列表
export function getSceneStoryboards(projectId, sceneId) {
return request({
url: `/video-project/${projectId}/scene/${sceneId}/storyboards`,
method: 'GET'
})
}
// 生成分镜
export function generateStoryboards(projectId, sceneId) {
return request({
url: `/video-project/${projectId}/scene/${sceneId}/generate-storyboards`,
method: 'POST',
timeout: 120000
})
}
// 更新分镜
export function updateStoryboard(projectId, storyboardId, data) {
return request({
url: `/video-project/${projectId}/storyboard/${storyboardId}`,
method: 'PUT',
data
})
}
// 删除分镜
export function deleteStoryboard(projectId, storyboardId) {
return request({
url: `/video-project/${projectId}/storyboard/${storyboardId}`,
method: 'DELETE'
})
}
// 新增分镜
export function addStoryboard(projectId, sceneId, afterIndex) {
return request({
url: `/video-project/${projectId}/scene/${sceneId}/storyboard`,
method: 'POST',
data: { afterIndex }
})
}
// 生成分镜画面
export function generateStoryboardImage(projectId, storyboardId) {
return request({
url: `/video-project/${projectId}/storyboard/${storyboardId}/generate-image`,
method: 'POST',
timeout: 60000
})
}
// 智能优化画面描述
export function optimizeDescription(projectId, storyboardId) {
return request({
url: `/video-project/${projectId}/storyboard/${storyboardId}/optimize-description`,
method: 'POST',
timeout: 60000
})
}
// ========== 视频合成 ==========
// 生成分镜视频单个分镜图转视频调用sora2
export function generateStoryboardVideo(projectId, storyboardId) {
return request({
url: `/video-project/${projectId}/storyboard/${storyboardId}/generate-video`,
method: 'POST',
timeout: 120000
})
}
// 合成场次视频(将场次内所有分镜视频拼接)
export function compositeSceneVideo(projectId, sceneId) {
return request({
url: `/video-project/${projectId}/scene/${sceneId}/composite-video`,
method: 'POST',
timeout: 180000
})
}
// 生成场次视频(支持选择模型、时长、比例)
export function generateSceneVideo(projectId, sceneId, params = {}) {
return request({
url: `/video-project/${projectId}/scene/${sceneId}/generate-video`,
method: 'POST',
data: params,
timeout: 300000
})
}
// 合成最终视频(将所有场次视频拼接)
export function compositeFinalVideo(projectId) {
return request({
url: `/video-project/${projectId}/composite-final-video`,
method: 'POST',
timeout: 300000
})
}
// 获取项目视频合成进度
export function getVideoCompositeProgress(projectId) {
return request({
url: `/video-project/${projectId}/video-progress`,
method: 'GET',
showLoading: false
})
}
// ========== 任务查询 ==========
// 查询任务状态静默请求不显示loading
export function getTaskStatus(taskNo) {
return request({
url: `/ai/tasks/no/${taskNo}`,
method: 'GET',
showLoading: false,
showError: false
})
}
// 轮询等待任务完成(支持 signal 取消)
export async function pollTaskUntilComplete(taskNo, options = {}) {
const { maxPolls = 120, interval = 10000, onProgress, signal } = options
for (let i = 0; i < maxPolls; i++) {
// 检查是否已取消
if (signal && signal.aborted) {
console.log(`轮询已取消: ${taskNo}`)
throw new Error('轮询已取消')
}
try {
const task = await getTaskStatus(taskNo)
// 如果任务不存在或查询失败
if (!task || task.status === undefined) {
throw new Error('任务不存在或查询失败')
}
if (onProgress) {
onProgress(task.progress || 0, task.statusText)
}
if (task.status === 2) {
// 成功
return task
} else if (task.status === 3 || task.status === 4) {
// 失败或取消 - 加前缀确保catch能正确识别为任务失败而非网络错误
throw new Error('任务执行失败: ' + (task.errorMessage || '未知错误'))
}
// 等待后继续轮询(支持取消)
await new Promise(resolve => setTimeout(resolve, interval))
// 等待结束后再次检查是否已取消
if (signal && signal.aborted) {
console.log(`轮询已取消: ${taskNo}`)
return null
}
} catch (e) {
// 取消时直接退出
if (e.message === '轮询已取消') {
return null
}
// 如果是任务失败/取消/不存在/查询失败,立即抛出异常,不重试
if (e.message && (e.message.includes('任务不存在') || e.message.includes('查询失败') || e.message.includes('无权限') || e.message.includes('任务执行失败') || e.message.includes('任务已取消') || e.message.includes('exception') || e.message.includes('失败'))) {
throw e
}
// 其他错误,继续重试
if (i === maxPolls - 1) {
throw e
}
await new Promise(resolve => setTimeout(resolve, interval))
}
}
throw new Error('任务超时')
}

43
src/api/work.js Normal file
View File

@@ -0,0 +1,43 @@
import { get, post } from '@/utils/request'
/**
* 获取作品列表
* @param {Object} params - 查询参数
* @param {string} params.sortType - 排序类型hot最热 new最新
* @param {number} params.categoryId - 分类ID
* @param {number} params.pageNum - 页码
* @param {number} params.pageSize - 每页数量
*/
export const getWorkList = (params) => get('/work/list', params, { showLoading: false })
/**
* 获取作品详情
* @param {number} id - 作品ID
*/
export const getWorkDetail = (id) => get(`/work/${id}`)
/**
* 切换点赞状态(已点赞则取消,未点赞则点赞)
* @param {number} workId - 作品ID
* @returns {Promise<{liked: boolean, likeCount: number}>}
*/
export const toggleLike = (workId) => post(`/work/${workId}/like`)
/**
* 发布作品
* @param {Object} data - 发布数据
* @param {number} data.taskId - 任务ID
* @param {string} data.title - 作品标题
* @param {string} data.description - 作品描述
* @param {number} data.categoryId - 分类ID
*/
export const publishWork = (data) => post('/work/publish', data)
/**
* 搜索作品
* @param {Object} params - 搜索参数
* @param {string} params.keyword - 搜索关键词
* @param {number} params.pageNum - 页码
* @param {number} params.pageSize - 每页数量
*/
export const searchWorks = (params) => get('/work/list', params, { showLoading: false })

View File

@@ -0,0 +1,453 @@
<template>
<view class="ai-task-form">
<!-- 模型选择 -->
<view class="form-item">
<text class="label">选择模型</text>
<picker :value="modelIndex" :range="models" range-key="name" @change="onModelChange">
<view class="picker">
{{ currentModel ? currentModel.name : '请选择模型' }}
</view>
</picker>
</view>
<!-- 动态参数表单 -->
<view v-if="currentModel && inputParams.length > 0" class="params-section">
<view v-for="param in inputParams" :key="param.name" class="form-item">
<text class="label">{{ param.label }}</text>
<!-- 文本输入 -->
<input
v-if="param.type === 'text'"
v-model="formData[param.name]"
:placeholder="param.placeholder || `请输入${param.label}`"
class="input"
/>
<!-- 多行文本 -->
<textarea
v-else-if="param.type === 'textarea'"
v-model="formData[param.name]"
:placeholder="param.placeholder || `请输入${param.label}`"
class="textarea"
/>
<!-- 数字输入 -->
<input
v-else-if="param.type === 'number'"
v-model.number="formData[param.name]"
type="number"
:placeholder="param.placeholder || `请输入${param.label}`"
class="input"
/>
<!-- 单图上传 -->
<view v-else-if="param.type === 'image'" class="image-upload">
<view v-if="formData[param.name]" class="image-preview">
<image :src="formData[param.name]" mode="aspectFill" />
<view class="image-actions">
<text class="action-btn" @click="previewImage(formData[param.name])">预览</text>
<text class="action-btn delete" @click="removeImage(param.name)">删除</text>
</view>
</view>
<view v-else class="upload-btn" @click="uploadSingleImage(param.name)">
<text class="upload-icon">+</text>
<text class="upload-text">上传图片</text>
</view>
</view>
<!-- 多图上传 -->
<view v-else-if="param.type === 'images'" class="images-upload">
<view class="images-list">
<view
v-for="(img, index) in formData[param.name]"
:key="index"
class="image-item"
>
<image :src="img" mode="aspectFill" />
<view class="image-mask" @click="removeImageFromList(param.name, index)">
<text class="delete-icon">×</text>
</view>
</view>
<view
v-if="!formData[param.name] || formData[param.name].length < (param.maxCount || 9)"
class="upload-btn-small"
@click="uploadMultipleImages(param.name, param.maxCount)"
>
<text class="upload-icon">+</text>
</view>
</view>
</view>
<!-- 选择器 -->
<picker
v-else-if="param.type === 'select'"
:value="getSelectIndex(param.name, param.options)"
:range="param.options"
range-key="label"
@change="onSelectChange($event, param.name, param.options)"
>
<view class="picker">
{{ getSelectLabel(param.name, param.options) || param.placeholder || `请选择${param.label}` }}
</view>
</picker>
</view>
</view>
<!-- 提交按钮 -->
<button class="submit-btn" @click="handleSubmit" :disabled="submitting">
{{ submitting ? '提交中...' : '提交任务' }}
</button>
</view>
</template>
<script>
import { getAiModels, createAiTask } from '@/api/ai'
import { chooseAndUploadImage, chooseAndUploadImages } from '@/utils/imageUpload'
export default {
name: 'AiTaskForm',
props: {
modelType: {
type: String,
default: ''
}
},
data() {
return {
models: [],
modelIndex: 0,
currentModel: null,
formData: {},
submitting: false
}
},
computed: {
inputParams() {
if (!this.currentModel || !this.currentModel.inputParams) {
return []
}
try {
return JSON.parse(this.currentModel.inputParams)
} catch (e) {
return []
}
}
},
mounted() {
this.loadModels()
},
methods: {
async loadModels() {
try {
const res = await getAiModels(this.modelType)
this.models = res.data || []
if (this.models.length > 0) {
this.currentModel = this.models[0]
this.initFormData()
}
} catch (error) {
uni.showToast({
title: '加载模型失败',
icon: 'none'
})
}
},
initFormData() {
const data = {}
this.inputParams.forEach(param => {
if (param.type === 'images') {
data[param.name] = []
} else if (param.type === 'number') {
data[param.name] = param.default || 0
} else {
data[param.name] = param.default || ''
}
})
this.formData = data
},
onModelChange(e) {
this.modelIndex = e.detail.value
this.currentModel = this.models[this.modelIndex]
this.initFormData()
},
async uploadSingleImage(paramName) {
try {
const url = await chooseAndUploadImage()
this.$set(this.formData, paramName, url)
} catch (error) {
console.error('上传失败', error)
}
},
async uploadMultipleImages(paramName, maxCount = 9) {
try {
const currentImages = this.formData[paramName] || []
const remainCount = maxCount - currentImages.length
const urls = await chooseAndUploadImages({ count: remainCount })
this.$set(this.formData, paramName, [...currentImages, ...urls])
} catch (error) {
console.error('上传失败', error)
}
},
removeImage(paramName) {
this.$set(this.formData, paramName, '')
},
removeImageFromList(paramName, index) {
const images = [...this.formData[paramName]]
images.splice(index, 1)
this.$set(this.formData, paramName, images)
},
previewImage(url) {
uni.previewImage({
urls: [url],
current: url
})
},
getSelectIndex(paramName, options) {
const value = this.formData[paramName]
return options.findIndex(opt => opt.value === value)
},
getSelectLabel(paramName, options) {
const value = this.formData[paramName]
const option = options.find(opt => opt.value === value)
return option ? option.label : ''
},
onSelectChange(e, paramName, options) {
const index = e.detail.value
this.$set(this.formData, paramName, options[index].value)
},
async handleSubmit() {
if (this.submitting) return
// 验证必填项
for (const param of this.inputParams) {
if (param.required) {
const value = this.formData[param.name]
if (!value || (Array.isArray(value) && value.length === 0)) {
uni.showToast({
title: `请填写${param.label}`,
icon: 'none'
})
return
}
}
}
try {
this.submitting = true
const taskData = {
modelId: this.currentModel.id,
inputParams: JSON.stringify(this.formData)
}
const res = await createAiTask(taskData)
uni.showToast({
title: '任务提交成功',
icon: 'success'
})
this.$emit('success', res.data)
// 重置表单
this.initFormData()
} catch (error) {
uni.showToast({
title: error.message || '提交失败',
icon: 'none'
})
} finally {
this.submitting = false
}
}
}
}
</script>
<style scoped>
.ai-task-form {
padding: 30rpx;
}
.form-item {
margin-bottom: 30rpx;
}
.label {
display: block;
font-size: 28rpx;
color: #333;
margin-bottom: 15rpx;
font-weight: 500;
}
.input,
.textarea,
.picker {
width: 100%;
padding: 20rpx;
border: 1px solid #e0e0e0;
border-radius: 8rpx;
font-size: 28rpx;
background: #fff;
}
.textarea {
min-height: 150rpx;
}
.picker {
color: #333;
}
/* 单图上传 */
.image-upload {
width: 100%;
}
.image-preview {
position: relative;
width: 200rpx;
height: 200rpx;
border-radius: 8rpx;
overflow: hidden;
}
.image-preview image {
width: 100%;
height: 100%;
}
.image-actions {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
background: rgba(0, 0, 0, 0.6);
}
.action-btn {
flex: 1;
text-align: center;
padding: 10rpx;
font-size: 24rpx;
color: #fff;
}
.action-btn.delete {
color: #ff4d4f;
}
.upload-btn {
width: 200rpx;
height: 200rpx;
border: 2px dashed #d9d9d9;
border-radius: 8rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #fafafa;
}
.upload-icon {
font-size: 60rpx;
color: #999;
}
.upload-text {
font-size: 24rpx;
color: #999;
margin-top: 10rpx;
}
/* 多图上传 */
.images-upload {
width: 100%;
}
.images-list {
display: flex;
flex-wrap: wrap;
gap: 15rpx;
}
.image-item {
position: relative;
width: 150rpx;
height: 150rpx;
border-radius: 8rpx;
overflow: hidden;
}
.image-item image {
width: 100%;
height: 100%;
}
.image-mask {
position: absolute;
top: 0;
right: 0;
width: 40rpx;
height: 40rpx;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
border-bottom-left-radius: 8rpx;
}
.delete-icon {
color: #fff;
font-size: 32rpx;
line-height: 1;
}
.upload-btn-small {
width: 150rpx;
height: 150rpx;
border: 2px dashed #d9d9d9;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
background: #fafafa;
}
.upload-btn-small .upload-icon {
font-size: 50rpx;
color: #999;
}
/* 提交按钮 */
.submit-btn {
width: 100%;
height: 88rpx;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border: none;
border-radius: 44rpx;
font-size: 32rpx;
font-weight: 500;
margin-top: 40rpx;
}
.submit-btn[disabled] {
opacity: 0.6;
}
</style>

View File

@@ -0,0 +1,44 @@
<template>
<button
class="base-button"
:class="[`base-button--${type}`, `base-button--${size}`, { 'base-button--disabled': disabled }]"
:disabled="disabled"
@click="handleClick"
>
<slot />
</button>
</template>
<script setup>
const props = defineProps({
type: { type: String, default: 'primary' },
size: { type: String, default: 'medium' },
disabled: { type: Boolean, default: false }
})
const emit = defineEmits(['click'])
const handleClick = () => {
if (!props.disabled) emit('click')
}
</script>
<style scoped>
.base-button {
border: none;
border-radius: 8px;
font-weight: 500;
transition: opacity 0.2s;
}
.base-button--primary { background-color: #1890ff; color: #fff; }
.base-button--success { background-color: #52c41a; color: #fff; }
.base-button--warning { background-color: #faad14; color: #fff; }
.base-button--danger { background-color: #ff4d4f; color: #fff; }
.base-button--small { padding: 8px 16px; font-size: 12px; }
.base-button--medium { padding: 12px 24px; font-size: 14px; }
.base-button--large { padding: 16px 32px; font-size: 16px; }
.base-button--disabled { opacity: 0.5; }
</style>

View File

@@ -0,0 +1,189 @@
<template>
<view class="category-bar" :style="{ backgroundColor: bgColor }">
<scroll-view
scroll-x
class="category-scroll"
:scroll-left="scrollLeft"
scroll-with-animation
:show-scrollbar="false"
:enhanced="true"
>
<view class="category-list">
<!-- 最热 -->
<view
class="category-item"
:class="{ active: activeType === 'hot' && !activeId }"
@click="handleTypeClick('hot')"
>
<text class="category-text" :style="getTextStyle('hot', null)">最热</text>
</view>
<!-- 最新 -->
<view
class="category-item"
:class="{ active: activeType === 'new' && !activeId }"
@click="handleTypeClick('new')"
>
<text class="category-text" :style="getTextStyle('new', null)">最新</text>
</view>
<!-- 分类列表 -->
<view
v-for="item in categories.filter(cat => cat.id !== 1 && cat.name !== '全部')"
:key="item.id"
class="category-item"
:class="{
active: activeId === item.id
}"
@click="handleCategoryClick(item)"
>
<text class="category-text" :style="getTextStyle(null, item.id)">{{ item.name }}</text>
</view>
<!-- 右侧占位防止被搜索按钮遮挡 -->
<view class="category-placeholder" />
</view>
</scroll-view>
<!-- 搜索区域 -->
<view class="search-area" @click="handleSearch">
<view class="search-gradient" />
<view class="search-btn">
<image class="search-icon" src="/static/icons/search.png" mode="aspectFit" />
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getCategoryList } from '@/api/category'
const props = defineProps({
bgColor: { type: String, default: 'transparent' },
textColor: { type: String, default: 'rgba(241, 245, 249, 1)' },
activeColor: { type: String, default: '#ffffff' }
})
const emit = defineEmits(['change', 'search'])
const categories = ref([])
const activeId = ref(null)
const activeType = ref('hot')
const scrollLeft = ref(0)
const getTextStyle = (type, id) => {
// 其他分类的激活判断
const isActive = (type && activeType.value === type && !activeId.value) ||
(id && activeId.value === id)
return {
color: isActive ? props.activeColor : props.textColor,
fontWeight: isActive ? '600' : '400'
}
}
const handleTypeClick = (type) => {
activeType.value = type
activeId.value = null
emit('change', { type, categoryId: null })
}
const handleCategoryClick = (item) => {
activeId.value = item.id
activeType.value = null
emit('change', { type: null, categoryId: item.id })
}
const handleSearch = () => {
emit('search')
}
const fetchCategories = async () => {
try {
const res = await getCategoryList()
console.log('分类数据:', res)
// request已处理成功时直接返回data.data
categories.value = res || []
console.log('categories.value:', categories.value)
} catch (e) {
console.error('获取分类失败', e)
}
}
onMounted(() => {
fetchCategories()
})
</script>
<style scoped>
.category-bar {
width: 100%;
padding: 12px 0;
position: relative;
}
.category-scroll {
width: 100%;
white-space: nowrap;
}
/* 隐藏滚动条 - 小程序兼容写法 */
.category-scroll {
scrollbar-width: none;
-ms-overflow-style: none;
}
.category-list {
display: inline-flex;
padding: 0 16px;
gap: 24px;
}
.category-item {
flex-shrink: 0;
padding: 4px 0;
}
.category-text {
font-size: 15px;
}
.category-item.active .category-text {
font-weight: 600;
}
.category-placeholder {
width: 50px;
flex-shrink: 0;
}
.search-area {
position: absolute;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
padding-right: 16px;
padding-left: 16px;
background: #09090b;
}
.search-gradient {
position: absolute;
left: -30px;
top: 0;
bottom: 0;
width: 30px;
background: linear-gradient(to right, transparent, #09090b);
}
.search-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.search-icon {
width: 22px;
height: 22px;
}
</style>

View File

@@ -0,0 +1,89 @@
<template>
<view class="custom-navbar" :style="navbarStyle">
<!-- 状态栏占位 -->
<view :style="{ height: statusBarHeight + 'px' }" />
<!-- 导航栏内容 -->
<view class="navbar-content" :style="{ height: navbarHeight + 'px' }">
<view class="navbar-left" @click="handleBack" v-if="showBack">
<image class="back-icon" src="/static/icons/Left (左).png" mode="aspectFit" />
</view>
<view class="navbar-title">
<text :style="{ color: textColor }">{{ title }}</text>
</view>
<view class="navbar-right">
<slot name="right" />
</view>
</view>
</view>
<!-- 占位元素 -->
<view :style="{ height: totalHeight + 'px' }" v-if="placeholder" />
</template>
<script setup>
import { computed } from 'vue'
import { useSafeArea } from '@/hooks/useSafeArea'
const props = defineProps({
title: { type: String, default: '' },
showBack: { type: Boolean, default: true },
bgColor: { type: String, default: '#ffffff' },
textColor: { type: String, default: '#333333' },
placeholder: { type: Boolean, default: true }
})
const { statusBarHeight } = useSafeArea()
const navbarHeight = 44
const totalHeight = computed(() => statusBarHeight.value + navbarHeight)
const navbarStyle = computed(() => ({
backgroundColor: props.bgColor,
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 999
}))
const handleBack = () => {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
} else {
uni.switchTab({ url: '/pages/index/index' })
}
}
</script>
<style scoped>
.navbar-content {
display: flex;
align-items: center;
padding: 0 12px;
}
.navbar-left {
width: 40px;
display: flex;
align-items: center;
}
.back-icon {
width: 24px;
height: 24px;
}
.navbar-title {
flex: 1;
text-align: center;
font-size: 16px;
font-weight: 500;
}
.navbar-right {
width: 40px;
display: flex;
align-items: center;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,411 @@
<template>
<view class="banner-container" v-if="banners.length > 0">
<swiper
class="banner-swiper"
:indicator-dots="false"
:autoplay="true"
:interval="4000"
:duration="500"
circular
@change="handleSwiperChange"
>
<swiper-item v-for="banner in banners" :key="banner.id" @click="handleClick(banner)">
<view class="banner-content">
<image class="banner-image" :src="banner.imageUrl" mode="aspectFill" />
<view class="banner-info" v-if="banner.title">
</view>
</view>
</swiper-item>
</swiper>
<!-- 黑色渐变遮盖层 -->
<view class="banner-overlay">
</view>
<!-- 自定义条形指示器 -->
<view class="custom-indicators" v-if="banners.length > 1">
<view
v-for="(banner, index) in banners"
:key="index"
class="indicator-bar"
:class="{ active: currentIndex === index }"
></view>
</view>
<!-- 功能卡片区域 -->
<view class="feature-cards">
<view
v-for="card in featureCards"
:key="card.id"
class="feature-card"
:style="cardBackgroundStyle"
@click="handleCardClick(card)"
>
<view class="card-icon">
<image v-if="card.icon" :src="card.icon" class="icon-image" mode="aspectFit" />
<view v-else class="icon-placeholder"></view>
</view>
<text class="card-title">{{ card.title }}</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { getBannerList } from '@/api/banner'
const props = defineProps({
position: { type: String, default: 'home' }
})
const banners = ref([])
const currentIndex = ref(0)
const dominantColors = ref([])
// 功能卡片数据
const featureCards = ref([
{
id: 1,
title: '一键成片',
icon: '/static/icons/video-on-ai-fill.png',
action: 'video-create'
},
{
id: 2,
title: '视频生成',
icon: '/static/icons/video-ai-fill.png',
action: 'video-generate'
},
{
id: 3,
title: '图片生成',
icon: '/static/icons/image-ai-fill.png',
action: 'image-generate'
}
])
// 计算当前banner的主体颜色
const currentDominantColor = computed(() => {
if (dominantColors.value[currentIndex.value]) {
return dominantColors.value[currentIndex.value]
}
return { r: 255, g: 255, b: 255 } // 默认白色
})
// 根据主体颜色生成卡片背景色
const cardBackgroundStyle = computed(() => {
const color = currentDominantColor.value
// 使用主体颜色的半透明版本作为背景
return {
background: `rgba(${color.r}, ${color.g}, ${color.b}, 0.25)`,
backdropFilter: 'blur(10px)',
WebkitBackdropFilter: 'blur(10px)',
border: `1px solid rgba(${color.r}, ${color.g}, ${color.b}, 0.3)`
}
})
// 提取图片主体颜色
const extractDominantColor = (imageUrl, index) => {
return new Promise((resolve) => {
// #ifdef H5
// H5环境使用canvas分析
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
canvas.width = 100
canvas.height = 100
ctx.drawImage(img, 0, 0, 100, 100)
try {
const imageData = ctx.getImageData(0, 0, 100, 100)
const data = imageData.data
// 简单的颜色分析算法
let r = 0, g = 0, b = 0
let pixelCount = 0
// 采样中心区域的像素
for (let i = 2500; i < 7500; i += 4) {
r += data[i]
g += data[i + 1]
b += data[i + 2]
pixelCount++
}
// 计算平均颜色
r = Math.floor(r / pixelCount)
g = Math.floor(g / pixelCount)
b = Math.floor(b / pixelCount)
// 增强颜色饱和度
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const diff = max - min
if (diff > 30) {
// 如果有明显的颜色差异,增强主色调
if (r === max) r = Math.min(255, r + 20)
if (g === max) g = Math.min(255, g + 20)
if (b === max) b = Math.min(255, b + 20)
}
resolve({ r, g, b })
} catch (e) {
console.warn('颜色提取失败,使用默认颜色', e)
resolve({ r: 255, g: 255, b: 255 })
}
}
img.onerror = () => {
console.warn('图片加载失败,使用默认颜色')
resolve({ r: 255, g: 255, b: 255 })
}
img.src = imageUrl
// #endif
// #ifdef MP-WEIXIN
// 小程序环境使用预设颜色方案
const presetColors = [
{ r: 139, g: 69, b: 19 }, // 棕色
{ r: 75, g: 0, b: 130 }, // 紫色
{ r: 255, g: 140, b: 0 }, // 橙色
{ r: 34, g: 139, b: 34 }, // 绿色
{ r: 220, g: 20, b: 60 } // 红色
]
const colorIndex = index % presetColors.length
resolve(presetColors[colorIndex])
// #endif
})
}
const handleClick = (item) => {
if (!item.linkUrl) return
// linkType: 0无 1内部页面 2外部链接 3小程序页面
if (item.linkType === 1 || item.linkType === 3) {
uni.navigateTo({ url: item.linkUrl })
} else if (item.linkType === 2) {
// #ifdef H5
window.open(item.linkUrl)
// #endif
}
}
const handleCardClick = (card) => {
console.log('点击功能卡片:', card)
// 根据不同的action跳转到不同页面
switch (card.action) {
case 'video-create':
uni.navigateTo({ url: '/pages/create/video' })
break
case 'video-generate':
// 跳转到造梦页面选中视频生成tab并展开输入框
// 因为是tabbar页面需要使用switchTab通过storage传递参数
uni.setStorageSync('dreamParams', JSON.stringify({
tab: 'video',
expand: true
}))
uni.switchTab({ url: '/pages/dream/index' })
break
case 'image-generate':
// 跳转到造梦页面选中图片生成tab并展开输入框
uni.setStorageSync('dreamParams', JSON.stringify({
tab: 'image',
expand: true
}))
uni.switchTab({ url: '/pages/dream/index' })
break
default:
uni.showToast({ title: '功能开发中', icon: 'none' })
}
}
const handleSwiperChange = (e) => {
currentIndex.value = e.detail.current
}
const fetchBanners = async () => {
try {
console.log('开始获取Bannerposition:', props.position)
const res = await getBannerList(props.position)
console.log('Banner API响应:', res)
banners.value = res || []
console.log('设置banners.value:', banners.value)
// 提取每张banner的主体颜色
if (banners.value.length > 0) {
const colors = await Promise.all(
banners.value.map((banner, index) =>
extractDominantColor(banner.imageUrl, index)
)
)
dominantColors.value = colors
console.log('提取的主体颜色:', colors)
}
} catch (e) {
console.error('获取Banner失败', e)
banners.value = []
}
}
onMounted(() => {
fetchBanners()
})
</script>
<style scoped>
.banner-container {
width: 100%;
padding: 0;
position: relative;
/* 添加状态栏安全区域 */
padding-top: constant(safe-area-inset-top);
padding-top: env(safe-area-inset-top);
}
.banner-swiper {
width: 100%;
height: 280px;
border-radius: 0;
overflow: hidden;
}
.banner-content {
width: 100%;
height: 100%;
position: relative;
}
.banner-image {
width: 100%;
height: 100%;
}
.banner-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
width: 100%;
height: 80px;
background: linear-gradient(180deg, transparent 0%, rgba(0, 0, 0, 0.8) 40%, rgba(0, 0, 0, 1) 100%);
pointer-events: none;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
}
.overlay-marker {
font-size: 24px;
opacity: 1;
color: #fff;
z-index: 1;
}
.banner-info {
position: absolute;
bottom: 110px;
left: 20px;
right: 180px;
z-index: 2;
}
.banner-title {
color: #ffffff;
font-size: 18px;
font-weight: 600;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
line-height: 1.4;
}
/* 自定义条形指示器 */
.custom-indicators {
position: absolute;
bottom: 85px;
right: 20px;
display: flex;
gap: 6px;
z-index: 4;
}
.indicator-bar {
width: 20px;
height: 3px;
background-color: rgba(255, 255, 255, 0.4);
border-radius: 2px;
transition: all 0.3s ease;
}
.indicator-bar.active {
background-color: #ffffff;
width: 24px;
}
/* 功能卡片区域 */
.feature-cards {
position: absolute;
bottom: -10px;
left: 20px;
right: 20px;
display: flex;
justify-content: space-between;
gap: 12px;
z-index: 3;
}
.feature-card {
flex: 1;
height: 80px;
/* 背景色通过内联样式动态设置 */
border-radius: 12px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.feature-card:active {
transform: scale(0.95);
opacity: 0.8;
}
.card-icon {
width: 32px;
height: 32px;
margin-bottom: 6px;
display: flex;
align-items: center;
justify-content: center;
}
.icon-image {
width: 100%;
height: 100%;
}
.icon-placeholder {
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.3);
border-radius: 6px;
border: 1px dashed rgba(255, 255, 255, 0.5);
}
.card-title {
color: #ffffff;
font-size: 13px;
font-weight: 500;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
text-align: center;
}
</style>

View File

@@ -0,0 +1,111 @@
<template>
<view class="header-placeholder" :style="{ height: totalHeight + 'px' }" />
<view class="header" :style="headerStyle">
<!-- 背景图 -->
<image class="header-bg" :src="bgImage" mode="aspectFill" v-if="bgImage" />
<!-- 状态栏占位 -->
<view :style="{ height: statusBarHeight + 'px' }" />
<!-- Header 内容 -->
<view class="header-content">
<view class="header-left">
<slot name="left" />
</view>
<view class="header-center">
<slot name="center">
<image
v-if="logo"
class="header-logo"
:src="logo"
mode="aspectFit"
/>
<text class="header-title" v-else-if="title">{{ title }}</text>
</slot>
</view>
<view class="header-right">
<slot name="right" />
</view>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue'
import { useSafeArea } from '@/hooks/useSafeArea'
const props = defineProps({
title: { type: String, default: '' },
logo: { type: String, default: '' },
bgColor: { type: String, default: '#09090b' },
bgImage: { type: String, default: 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/assets/headerback.png' },
textColor: { type: String, default: '#f1f5f9' }
})
const { statusBarHeight } = useSafeArea()
const headerHeight = 54
const totalHeight = computed(() => statusBarHeight.value + headerHeight)
const headerStyle = computed(() => ({
backgroundColor: props.bgColor
}))
</script>
<style scoped>
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 999;
overflow: hidden;
}
.header-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}
.header-content {
position: relative;
z-index: 1;
height: 54px;
display: flex;
align-items: center;
padding: 0 16px;
}
.header-left {
flex-shrink: 0;
}
.header-logo {
height: 32px;
width: 120px;
}
.header-center {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.header-right {
flex-shrink: 0;
}
.header-title {
font-family: 'PingFang SC', -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 18px;
font-weight: 600;
color: rgba(241, 245, 249, 1);
}
.header-placeholder {
width: 100%;
}
</style>

View File

@@ -0,0 +1,427 @@
<template>
<view v-if="visible" class="preview-overlay" @click="handleOverlayClick">
<view class="preview-container">
<!-- 返回按钮与微信胶囊对齐 -->
<view
class="back-btn"
:style="{ top: navButtonTop + 'px' }"
@click.stop="handleClose"
>
<image src="/static/icons/Left (左).png" class="back-icon" mode="aspectFit" />
</view>
<!-- 媒体内容 -->
<view class="media-wrapper" @click.stop>
<!-- 视频预览 -->
<video
v-if="currentMedia.isVideo"
:src="currentMedia.url"
class="preview-video"
controls
autoplay
object-fit="contain"
/>
<!-- 图片预览 -->
<swiper
v-else
class="preview-swiper"
:current="currentIndex"
@change="handleSwiperChange"
>
<swiper-item v-for="(item, index) in mediaList" :key="index">
<view class="swiper-item-wrapper">
<image
:src="item.url"
class="preview-image"
mode="aspectFit"
@click.stop
/>
</view>
</swiper-item>
</swiper>
</view>
<!-- 底部信息栏 -->
<view class="info-bar" @click.stop>
<view class="info-left">
<text class="model-name">{{ currentMedia.modelName }}</text>
<text class="create-time">{{ currentMedia.createTime }}</text>
</view>
<!-- 图片计数 -->
<view class="page-indicator" v-if="!currentMedia.isVideo && mediaList.length > 1">
<text class="indicator-text">{{ currentIndex + 1 }} / {{ mediaList.length }}</text>
</view>
<!-- 操作按钮 -->
<view class="action-buttons">
<view class="icon-btn" @click.stop="handleDownload">
<image src="/static/icons/To-bottom.png" class="btn-icon" mode="aspectFit" />
</view>
<view class="icon-btn" @click.stop="handleDelete">
<image src="/static/icons/del.png" class="btn-icon" mode="aspectFit" />
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
// 计算返回按钮的top位置与微信胶囊对齐
const navButtonTop = ref(20)
onMounted(() => {
// #ifdef MP-WEIXIN
try {
// 获取微信胶囊按钮位置信息
const menuButtonInfo = uni.getMenuButtonBoundingClientRect()
if (menuButtonInfo) {
// 返回按钮与胶囊按钮垂直居中对齐
// 胶囊top + (胶囊高度 - 按钮高度) / 2
navButtonTop.value = menuButtonInfo.top + (menuButtonInfo.height - 40) / 2
}
} catch (e) {
// 获取失败时使用默认值
const systemInfo = uni.getSystemInfoSync()
navButtonTop.value = (systemInfo.statusBarHeight || 20) + 6
}
// #endif
// #ifndef MP-WEIXIN
// 非微信小程序使用状态栏高度
try {
const systemInfo = uni.getSystemInfoSync()
navButtonTop.value = (systemInfo.statusBarHeight || 20) + 10
} catch (e) {
navButtonTop.value = 30
}
// #endif
})
const props = defineProps({
visible: {
type: Boolean,
default: false
},
task: {
type: Object,
default: null
},
taskList: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['close', 'download', 'delete'])
const currentIndex = ref(0)
// 当前媒体信息
const currentMedia = computed(() => {
if (!props.task) return {}
const isVideoTask = isVideo(props.task)
// 视频任务使用视频URL图片任务使用图片URL
const url = isVideoTask ? getVideoUrl(props.task) : getTaskImage(props.task)
return {
url: url,
isVideo: isVideoTask,
modelName: props.task.modelName || '未知模型',
createTime: formatTime(props.task.createdAt),
taskId: props.task.id
}
})
// 媒体列表(仅图片)
const mediaList = computed(() => {
if (!props.task || isVideo(props.task)) return []
// 获取同一日期的所有图片任务
const currentDate = formatDate(props.task.createdAt)
return props.taskList
.filter(t => {
const taskDate = formatDate(t.createdAt)
return taskDate === currentDate && !isVideo(t) && getTaskImage(t)
})
.map(t => ({
url: getTaskImage(t),
taskId: t.id,
modelName: t.modelName,
createTime: formatTime(t.createdAt)
}))
})
// 监听任务变化,更新当前索引
watch(() => props.task, (newTask) => {
if (newTask && !isVideo(newTask)) {
const index = mediaList.value.findIndex(item => item.taskId === newTask.id)
currentIndex.value = index >= 0 ? index : 0
}
}, { immediate: true })
// 格式化日期
const formatDate = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
// 格式化时间
const formatTime = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
const month = date.getMonth() + 1
const day = date.getDate()
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${month}${day}${hours}:${minutes}`
}
// 视频模型编码列表
const videoModelCodes = ['grok-video', 'tencent-sora2-video', 'tencent-aigc-video', 'sora2-video']
// 判断是否为视频
const isVideo = (task) => {
// 优先通过模型编码判断
if (task.modelCode && videoModelCodes.includes(task.modelCode)) {
return true
}
if (!task.outputResult) return false
try {
const result = JSON.parse(task.outputResult)
if (result.result && (result.result.endsWith('.mp4') || result.result.includes('.mp4') || result.result.includes('video'))) {
return true
}
return !!result.videoUrl
} catch (e) {
return false
}
}
// 获取视频URL与图片分开
const getVideoUrl = (task) => {
if (!task.outputResult) return null
try {
const result = JSON.parse(task.outputResult)
return result.result || result.videoUrl || result.url || null
} catch (e) {
return null
}
}
// 获取任务图片/视频
const getTaskImage = (task) => {
if (!task.outputResult) return null
try {
const result = JSON.parse(task.outputResult)
if (result.coverUrl) return result.coverUrl
if (result.result) return result.result
if (result.videoUrl) return result.videoUrl
if (result.imageUrl) return result.imageUrl
if (result.images && result.images.length > 0) return result.images[0]
if (result.url) return result.url
if (result.image) return result.image
} catch (e) {
console.error('解析任务结果失败:', e)
}
return null
}
// 轮播图切换
const handleSwiperChange = (e) => {
currentIndex.value = e.detail.current
}
// 关闭预览
const handleClose = () => {
emit('close')
}
// 点击遮罩关闭
const handleOverlayClick = () => {
handleClose()
}
// 下载
const handleDownload = () => {
const media = currentMedia.value
if (!currentMedia.value.isVideo && mediaList.value.length > 0) {
// 图片模式,下载当前图片
const currentItem = mediaList.value[currentIndex.value]
emit('download', currentItem.taskId)
} else {
// 视频模式
emit('download', media.taskId)
}
}
// 删除
const handleDelete = () => {
const media = currentMedia.value
if (!currentMedia.value.isVideo && mediaList.value.length > 0) {
// 图片模式,删除当前图片
const currentItem = mediaList.value[currentIndex.value]
emit('delete', currentItem.taskId)
} else {
// 视频模式
emit('delete', media.taskId)
}
}
</script>
<style scoped>
.preview-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.95);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.preview-container {
width: 100%;
height: 100%;
position: relative;
display: flex;
flex-direction: column;
}
/* 返回按钮top通过:style动态设置与微信胶囊对齐 */
.back-btn {
position: absolute;
top: 20px; /* 默认值,实际由:style覆盖 */
left: 16px;
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.back-icon {
width: 20px;
height: 20px;
}
/* 媒体容器 */
.media-wrapper {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
/* 视频预览 */
.preview-video {
width: 100%;
height: 100%;
}
/* 图片轮播 */
.preview-swiper {
width: 100%;
height: 100%;
}
.swiper-item-wrapper {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.preview-image {
width: 100%;
height: 100%;
}
/* 底部信息栏 */
.info-bar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 20px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.info-left {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.model-name {
color: #ffffff;
font-size: 15px;
font-weight: 500;
}
.create-time {
color: #a1a1aa;
font-size: 13px;
}
/* 页码指示器 */
.page-indicator {
padding: 6px 12px;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border-radius: 16px;
}
.indicator-text {
color: #ffffff;
font-size: 13px;
}
/* 操作按钮 */
.action-buttons {
display: flex;
gap: 12px;
}
.icon-btn {
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.btn-icon {
width: 20px;
height: 20px;
}
</style>

View File

@@ -0,0 +1,167 @@
<template>
<view class="progress-steps">
<view
v-for="(step, index) in steps"
:key="index"
class="step-wrapper"
>
<!-- 连接虚线第一个步骤前不显示 -->
<view v-if="index > 0" class="step-line" :class="{ active: index <= currentStep }"></view>
<view
class="step-item clickable"
:class="{
active: index <= currentStep
}"
@tap="handleStepClick(index)"
>
<view class="step-icon-wrapper">
<image :src="getStepIcon(index)" class="step-icon" mode="aspectFit" />
</view>
<text class="step-label">{{ getStepStatus(index) }}</text>
</view>
</view>
</view>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
const props = defineProps({
steps: {
type: Array,
default: () => [
{ label: '生成剧本', icon: '/static/icons/one.png' },
{ label: '项目设置', icon: '/static/icons/two.png' },
{ label: '创建角色', icon: '/static/icons/three.png' },
{ label: '生成分镜', icon: '/static/icons/four.png' },
{ label: '合成视频', icon: '/static/icons/five.png' }
]
},
currentStep: {
type: Number,
default: 0
},
completedStep: {
type: Number,
default: 0
}
})
const emit = defineEmits(['step-click'])
// 步骤图标映射:普通状态和激活状态
const stepIconMap = [
{ normal: '/static/icons/one.png', active: '/static/icons/one-a.png' },
{ normal: '/static/icons/two.png', active: '/static/icons/two-a.png' },
{ normal: '/static/icons/three.png', active: '/static/icons/three-o.png' },
{ normal: '/static/icons/four.png', active: '/static/icons/four-o.png' },
{ normal: '/static/icons/five.png', active: '/static/icons/five-o.png' }
]
const getStepIcon = (index) => {
const isActive = index <= props.currentStep
const iconConfig = stepIconMap[index]
if (iconConfig) {
return isActive ? iconConfig.active : iconConfig.normal
}
// 回退到步骤配置中的图标
return props.steps[index]?.icon || ''
}
const getStepStatus = (index) => {
return props.steps[index].label
}
const handleStepClick = (index) => {
// 允许点击任意步骤(由父组件决定是否跳转)
emit('step-click', index)
}
</script>
<style scoped>
.progress-steps {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 12px 8px;
background: #18181b;
border-radius: 12px;
}
.step-wrapper {
display: flex;
align-items: flex-start;
flex: 1;
}
.step-wrapper:first-child {
flex: 0 0 auto;
}
.step-line {
flex: 1;
height: 1px;
margin-top: 17px;
border-top: 1px dashed #3f3f46;
min-width: 8px;
}
.step-line.active {
border-top-color: #3ed0f5;
}
.step-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.step-icon-wrapper {
width: 34px;
height: 34px;
border-radius: 6px;
border: 0.5px solid transparent;
background: #000000;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
.step-icon {
width: 24px;
height: 24px;
opacity: 0.5;
transition: opacity 0.3s ease;
}
.step-label {
color: #71717a;
font-size: 12px;
text-align: center;
white-space: nowrap;
}
.step-item.active .step-icon-wrapper {
border-color: #3ed0f5;
background: #02151a;
}
.step-item.active .step-icon {
opacity: 1;
}
.step-item.active .step-label {
color: #e4e4e7;
}
.step-item.clickable {
cursor: pointer;
}
.step-item.clickable:active {
opacity: 0.7;
}
</style>

View File

@@ -0,0 +1,37 @@
<template>
<view class="safe-area-view" :style="containerStyle">
<slot />
</view>
</template>
<script setup>
import { computed } from 'vue'
import { useSafeArea } from '@/hooks/useSafeArea'
const props = defineProps({
// 是否适配顶部安全区域
top: { type: Boolean, default: true },
// 是否适配底部安全区域
bottom: { type: Boolean, default: true },
// 背景色
bgColor: { type: String, default: '#09090b' }
})
const { safeAreaInsets } = useSafeArea()
const containerStyle = computed(() => ({
paddingTop: props.top ? `${safeAreaInsets.value.top}px` : '0',
paddingBottom: props.bottom ? `${safeAreaInsets.value.bottom}px` : '0',
backgroundColor: props.bgColor,
minHeight: '100vh',
boxSizing: 'border-box'
}))
</script>
<style scoped>
.safe-area-view {
width: 100%;
display: flex;
flex-direction: column;
}
</style>

View File

@@ -0,0 +1,169 @@
<template>
<view class="tabbar" :style="tabbarStyle">
<view
class="tabbar-item"
v-for="(item, index) in tabs"
:key="index"
@click="handleSwitch(item, index)"
>
<image
class="tabbar-icon"
:src="current === index ? item.selectedIcon : item.icon"
mode="aspectFit"
/>
<text class="tabbar-text" :class="{ 'tabbar-text--active': current === index }">
{{ item.text }}
</text>
</view>
</view>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useSafeArea } from '@/hooks/useSafeArea'
import { useUserStore } from '@/store/modules/user'
import { getSubscribed, updateSubscribed } from '@/api/user'
const props = defineProps({
current: { type: Number, default: 0 },
tabs: {
type: Array,
default: () => [
{
pagePath: '/pages/inspiration/index',
text: '灵感',
icon: '/static/tabbar/inspiration.png',
selectedIcon: '/static/tabbar/inspiration-active.png'
},
{
pagePath: '/pages/dream/index',
text: '造梦',
icon: '/static/tabbar/dream.png',
selectedIcon: '/static/tabbar/dream-active.png'
},
{
pagePath: '/pages/assets/index',
text: '资产',
icon: '/static/tabbar/assets.png',
selectedIcon: '/static/tabbar/assets-active.png'
},
{
pagePath: '/pages/user/index',
text: '我的',
icon: '/static/tabbar/user.png',
selectedIcon: '/static/tabbar/user-active.png'
}
]
}
})
const emit = defineEmits(['change'])
const { safeAreaInsets } = useSafeArea()
const tabbarStyle = computed(() => ({
paddingBottom: `${safeAreaInsets.value.bottom}px`
}))
// 订阅消息模板ID
const SUBSCRIBE_TEMPLATE_ID = 'pyDi6nvC0sze6DBUAmZLm_AKz2WCfixchWql7DoA9OI'
// 请求订阅消息用户点击造梦tab时触发
const requestSubscribeMessage = async () => {
// #ifdef MP-WEIXIN
const userStore = useUserStore()
// 未登录不请求
if (!userStore.isLogin) return
try {
// 先查询后端订阅状态
const subscribed = await getSubscribed()
if (subscribed) {
console.log('用户已订阅,无需再次请求')
return
}
// 未订阅,请求订阅消息授权
uni.requestSubscribeMessage({
tmplIds: [SUBSCRIBE_TEMPLATE_ID],
success: async (res) => {
console.log('TabBar订阅消息结果:', res)
// 用户同意订阅,更新后端状态
if (res[SUBSCRIBE_TEMPLATE_ID] === 'accept') {
try {
await updateSubscribed(true)
console.log('订阅状态已同步到后端')
} catch (e) {
console.error('同步订阅状态失败:', e)
}
}
},
fail: (err) => {
console.log('TabBar订阅消息失败:', err)
}
})
} catch (e) {
console.error('查询订阅状态失败:', e)
}
// #endif
}
const handleSwitch = (item, index) => {
if (props.current === index) return
// 点击"造梦"tab时请求订阅消息授权
if (item.pagePath === '/pages/dream/index') {
requestSubscribeMessage()
}
emit('change', { item, index })
uni.switchTab({ url: item.pagePath })
}
</script>
<style scoped>
.tabbar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
width: 100%;
height: 44px;
display: grid;
grid-template-rows: repeat(1, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
box-shadow: 0px -0.5px 0px 0px rgba(24, 24, 27, 1);
background: rgba(9, 9, 11, 1);
z-index: 999;
}
.tabbar-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.tabbar-icon {
width: 22px;
height: 22px;
border: none;
background: transparent;
}
.tabbar-text {
font-family: 'PingFang SC', -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 11px;
font-weight: 400;
line-height: 13px;
color: rgba(161, 161, 170, 1);
margin-top: 2px;
text-align: center;
}
.tabbar-text--active {
color: rgba(241, 245, 249, 1);
}
</style>

View File

@@ -0,0 +1,747 @@
<template>
<view class="task-item">
<!-- 任务类型和提示词 -->
<view class="task-header">
<text class="task-type-and-prompt">
<text class="task-type">{{ taskTypeText }}</text>
<text class="task-divider">|</text>
<text class="task-prompt-text">{{ displayPromptText }}</text>
</text>
<text
v-if="isLongText"
class="expand-btn"
@tap.stop="toggleExpand"
>
{{ expanded ? '收起' : '展开' }}
</text>
</view>
<!-- 元数据标签 -->
<view class="task-meta">
<view class="user-info">
<image
class="meta-avatar"
:src="task.userAvatar || '/static/icons/Reference.png'"
mode="aspectFill"
/>
<text class="user-name">智能梦客</text>
</view>
<text class="meta-tag">{{ modelDisplayName }}</text>
<text class="meta-tag">{{ imageSize }}</text>
<text class="meta-tag">{{ aspectRatio }}</text>
<text v-if="publishStatusText" :class="['publish-status', publishStatusClass]">
{{ publishStatusText }}
</text>
</view>
<!-- 预览区域包含参考图和生成结果 -->
<view class="preview-container">
<!-- 生成结果区域 -->
<view class="result-section">
<text class="section-label">生成结果</text>
<view class="task-preview" @tap="handlePreviewClick">
<template v-if="task.status === 2">
<!-- 已完成 -->
<image
v-if="isImage"
class="preview-media"
:src="outputUrl"
mode="aspectFill"
/>
<video
v-else-if="pageVisible"
class="preview-media"
:src="outputUrl"
:autoplay="false"
:loop="false"
:muted="true"
:show-center-play-btn="false"
:controls="false"
:enable-progress-gesture="false"
object-fit="cover"
/>
<view v-else class="preview-media video-thumb"></view>
<view v-if="!isImage" class="play-icon">
<text class="play-text"></text>
</view>
</template>
<view v-else-if="task.status === 1" class="preview-loading" :key="`loading-${task.progress}`">
<!-- 生成中 - 使用CSS动画替代video释放原生video名额 -->
<view class="loading-anim"></view>
<view class="loading-overlay">
<text class="loading-text">生成中 {{ task.progress || 0 }}%</text>
</view>
</view>
<view v-else-if="task.status === 0" class="preview-loading" key="queue">
<!-- 队列中 - 使用CSS动画替代video -->
<view class="loading-anim"></view>
<view class="loading-overlay">
<text class="loading-text">{{ task.statusText || '队列中' }}</text>
</view>
</view>
<view v-else-if="task.status === 3" class="preview-error" key="error">
<text class="error-text">生成失败</text>
</view>
</view>
</view>
<!-- 参考图区域在生成结果右侧 -->
<view v-if="referenceImages.length > 0" class="reference-section">
<text class="section-label">参考图</text>
<view class="images-column">
<image
v-for="(img, idx) in displayReferenceImages"
:key="idx"
class="reference-image"
:src="img"
mode="aspectFill"
@tap.stop="previewReferenceImage(idx)"
/>
<view
v-if="hasMoreReferenceImages"
class="more-images-btn"
@tap.stop="showAllReferenceImages"
>
<text class="more-text">+{{ referenceImages.length - 2 }}</text>
<text class="more-label">更多</text>
</view>
</view>
</view>
</view>
<!-- 参考图弹窗 -->
<view v-if="showReferenceModal" class="modal-overlay" @tap="closeReferenceModal">
<view class="modal-content" @tap.stop>
<view class="modal-header">
<text class="modal-title">参考图 ({{ referenceImages.length }})</text>
<view class="modal-close" @tap="closeReferenceModal">
<text class="close-icon">×</text>
</view>
</view>
<scroll-view class="modal-body" scroll-y>
<view class="modal-images">
<image
v-for="(img, idx) in referenceImages"
:key="idx"
class="modal-image"
:src="img"
mode="aspectFill"
@tap="previewReferenceImage(idx)"
/>
</view>
</scroll-view>
</view>
</view>
<!-- 操作按钮 -->
<view class="task-actions">
<view class="action-btn" @tap.stop="handleDelete">
<image class="action-icon" src="/static/icons/del.png" mode="aspectFit" />
<text class="action-text">删除</text>
</view>
<view class="action-btn" @tap.stop="handleEdit">
<image class="action-icon" src="/static/icons/refresh-line.png" mode="aspectFit" />
<text class="action-text">再次编辑</text>
</view>
<view
v-if="task.status === 2 && (!task.publishStatus || task.publishStatus === 0 || task.publishStatus === 3)"
class="action-btn"
@tap.stop="handlePublish"
>
<image class="action-icon" src="/static/icons/navigation-03.png" mode="aspectFit" />
<text class="action-text">发布</text>
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
const props = defineProps({
task: {
type: Object,
required: true
},
pageVisible: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['delete', 'edit', 'publish', 'preview'])
const expanded = ref(false)
const showReferenceModal = ref(false)
// 是否是图片任务
const isImage = computed(() => {
const code = props.task.modelCode
return code === 'nanobanana-image' || code === 'seedream-4-5' || code === 'video-image-gen' || code === 'tencent-hunyuan-image3.0'
})
// 任务类型文本
const taskTypeText = computed(() => {
return isImage.value ? '图片生成' : '视频生成'
})
// 模型显示名称
const modelDisplayName = computed(() => {
const code = props.task.modelCode
const nameMap = {
'nanobanana-image': 'NanoBanana',
'seedream-4-5': 'Seedream 4.5',
'video-image-gen': 'Seedream 4.5',
'tencent-aigc-video': 'GV 3.1',
'grok-video': 'Grok',
'tencent-hunyuan-image3.0': '混元生图'
}
return nameMap[code] || (isImage.value ? '图片生成' : '视频生成')
})
// 图片尺寸
const imageSize = computed(() => {
try {
const params = JSON.parse(props.task.inputParams || '{}')
return params.imageSize || params.size || '2K'
} catch (e) {
return '2K'
}
})
// 提示词文本
const promptText = computed(() => {
try {
const params = JSON.parse(props.task.inputParams || '{}')
return params.prompt || '未知提示词'
} catch (e) {
return '未知提示词'
}
})
// 发布状态文本
const publishStatusText = computed(() => {
const status = props.task.publishStatus
if (status === 0) return '' // 未发布不显示
if (status === 1) return '审核中'
if (status === 2) return '已发布'
if (status === 3) return '审核未通过'
return ''
})
// 发布状态样式类
const publishStatusClass = computed(() => {
const status = props.task.publishStatus
if (status === 1) return 'status-reviewing'
if (status === 2) return 'status-published'
if (status === 3) return 'status-rejected'
return ''
})
// 是否是长文本超过150字
const isLongText = computed(() => {
return promptText.value.length > 150
})
// 显示的提示词文本(根据展开状态决定是否截断)
const displayPromptText = computed(() => {
if (!isLongText.value || expanded.value) {
return promptText.value
}
return promptText.value.substring(0, 150) + '...'
})
// 宽高比
const aspectRatio = computed(() => {
try {
const params = JSON.parse(props.task.inputParams || '{}')
return params.aspectRatio || params.size || '9:16'
} catch (e) {
return '9:16'
}
})
// 参考图列表(支持多张)
const referenceImages = computed(() => {
try {
const params = JSON.parse(props.task.inputParams || '{}')
const images = []
// 支持单张或多张图片
if (params.img_url) {
if (Array.isArray(params.img_url)) {
// 多张图片数组
images.push(...params.img_url)
} else {
// 单张图片字符串
images.push(params.img_url)
}
} else if (params.url && typeof params.url === 'string' && params.url.startsWith('http')) {
images.push(params.url)
}
// 支持多张图片数组
if (params.images && Array.isArray(params.images)) {
images.push(...params.images)
}
return images
} catch (e) {
return []
}
})
// 显示的参考图最多2张
const displayReferenceImages = computed(() => {
return referenceImages.value.slice(0, 2)
})
// 是否有更多参考图
const hasMoreReferenceImages = computed(() => {
return referenceImages.value.length > 2
})
// 输出URL
const outputUrl = computed(() => {
if (!props.task.outputResult) return ''
try {
const result = JSON.parse(props.task.outputResult)
// 优先使用 result 字段(新格式),然后尝试其他字段
return result.result || result.url || result.videoUrl || result.imageUrl || ''
} catch (e) {
// 如果解析失败可能直接就是URL字符串
return props.task.outputResult || ''
}
})
// 视频封面图优先用coverUrl否则COS截帧取首帧
const coverUrl = computed(() => {
if (!props.task.outputResult) return ''
try {
const result = JSON.parse(props.task.outputResult)
// 优先用后端返回的封面图
if (result.coverUrl) return result.coverUrl
// 对所有视频URL尝试COS截帧取首帧
const videoUrl = result.result || result.videoUrl || result.url || ''
if (videoUrl && videoUrl.startsWith('http')) {
const sep = videoUrl.includes('?') ? '&' : '?'
return videoUrl + sep + 'ci-process=snapshot&time=1&format=jpg'
}
return ''
} catch (e) {
return ''
}
})
// 切换展开/收起
const toggleExpand = () => {
expanded.value = !expanded.value
}
// 处理删除
const handleDelete = () => {
emit('delete', props.task)
}
// 处理编辑
const handleEdit = () => {
emit('edit', props.task)
}
// 处理发布
const handlePublish = () => {
emit('publish', props.task)
}
// 处理预览点击 - 仅已完成任务跳转到详情页
const handlePreviewClick = () => {
if (props.task.status === 2) {
// 已完成的任务,跳转到造梦详情页
uni.navigateTo({ url: `/pages/dream/detail?taskNo=${props.task.taskNo}` })
}
// 生成中或队列中的任务不跳转
}
// 显示所有参考图
const showAllReferenceImages = () => {
showReferenceModal.value = true
}
// 关闭参考图弹窗
const closeReferenceModal = () => {
showReferenceModal.value = false
}
// 预览参考图
const previewReferenceImage = (index) => {
uni.previewImage({
urls: referenceImages.value,
current: index
})
}
</script>
<style scoped>
.task-item {
padding: 16px;
border-bottom: 1px solid #1a1a1a;
margin-bottom: 8px;
}
.task-header {
margin-bottom: 12px;
}
.task-type-and-prompt {
color: #fff;
font-size: 15px;
line-height: 1.6;
word-wrap: break-word;
word-break: break-all;
white-space: pre-wrap;
display: block;
min-height: 24px;
}
.task-type {
color: #52525B;
font-size: 15px;
}
.task-divider {
color: #333;
font-size: 13px;
margin: 0 8px;
}
.task-prompt-text {
color: #fff;
font-size: 15px;
}
.expand-btn {
color: #6366f1;
font-size: 13px;
margin-top: 8px;
display: block;
cursor: pointer;
padding: 4px 0;
}
.task-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.user-info {
display: flex;
align-items: center;
gap: 6px;
background-color: #1a1a1a;
padding: 4px 8px;
border-radius: 4px;
}
.meta-avatar {
width: 20px;
height: 20px;
border-radius: 4px;
}
.user-name {
color: #666;
font-size: 12px;
}
.meta-tag {
color: #666;
font-size: 12px;
background-color: #1a1a1a;
padding: 4px 8px;
border-radius: 4px;
}
.publish-status {
font-size: 12px;
padding: 4px 8px;
border-radius: 4px;
}
.status-reviewing {
color: #fbbf24;
background-color: rgba(251, 191, 36, 0.1);
}
.status-published {
color: #10b981;
background-color: rgba(16, 185, 129, 0.1);
}
.status-rejected {
color: #ef4444;
background-color: rgba(239, 68, 68, 0.1);
}
.preview-container {
display: flex;
gap: 12px;
margin-bottom: 12px;
align-items: flex-end;
}
.result-section {
display: flex;
flex-direction: column;
}
.reference-section {
display: flex;
flex-direction: column;
justify-content: flex-end;
margin-bottom: 6px;
}
.section-label {
color: #666;
font-size: 12px;
margin-bottom: 6px;
display: block;
height: 18px;
line-height: 18px;
}
.images-column {
display: flex;
flex-direction: row;
gap: 8px;
}
.reference-image {
width: 70px;
height: 70px;
border-radius: 8px;
background-color: #0a0a0a;
}
.more-images-btn {
width: 70px;
height: 70px;
border-radius: 8px;
background-color: #1a1a1a;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
}
.more-text {
color: #fff;
font-size: 14px;
font-weight: 600;
}
.more-label {
color: #666;
font-size: 10px;
}
.task-preview {
width: 150px;
height: 200px;
border-radius: 12px;
overflow: hidden;
margin-bottom: 12px;
position: relative;
background-color: #0a0a0a;
transition: all 0.3s ease;
}
.preview-media {
width: 100%;
height: 100%;
object-fit: cover;
transition: opacity 0.3s ease;
}
.preview-loading,
.preview-error {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
transition: all 0.3s ease;
}
.loading-anim {
width: 100%;
height: 100%;
background: linear-gradient(110deg, #0a0a0a 30%, #1a1a2e 50%, #0a0a0a 70%);
background-size: 200% 100%;
animation: shimmer 2s ease-in-out infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.video-thumb {
background-color: #0a0a0a;
display: flex;
align-items: center;
justify-content: center;
}
.video-thumb-img {
width: 100%;
height: 100%;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
transition: all 0.3s ease;
}
.loading-text,
.error-text {
color: #fff;
font-size: 14px;
font-weight: 500;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
transition: all 0.3s ease;
}
.play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 48px;
height: 48px;
background-color: rgba(255, 255, 255, 0.9);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.play-text {
color: #000;
font-size: 20px;
margin-left: 3px;
}
.task-actions {
display: flex;
gap: 8px;
}
.action-btn {
height: 44px;
background-color: #1a1a1a;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 12px;
}
.action-icon {
width: 18px;
height: 18px;
}
.action-text {
color: #fff;
font-size: 14px;
}
/* 参考图弹窗 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.8);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.modal-content {
width: 90%;
max-width: 500px;
max-height: 80vh;
background-color: #1a1a1a;
border-radius: 16px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #2a2a2a;
}
.modal-title {
color: #fff;
font-size: 16px;
font-weight: 600;
}
.modal-close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.close-icon {
color: #666;
font-size: 32px;
line-height: 1;
}
.modal-body {
flex: 1;
padding: 16px;
}
.modal-images {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.modal-image {
width: 100%;
aspect-ratio: 1;
border-radius: 8px;
background-color: #0a0a0a;
}
</style>

View File

@@ -0,0 +1,222 @@
<template>
<view class="work-card" hover-class="none" @click="handleClick">
<!-- 视频/图片内容 -->
<view class="content-wrapper" hover-class="none">
<!-- 视频作品页面可见时用video显示首帧隐藏时卸载释放名额 -->
<video
v-if="work.contentType === 2 && pageVisible"
class="content-video"
:src="work.contentUrl"
:autoplay="false"
:loop="false"
:muted="true"
:show-play-btn="false"
:show-center-play-btn="false"
:controls="false"
:enable-progress-gesture="false"
object-fit="cover"
/>
<view
v-else-if="work.contentType === 2"
class="content-video video-placeholder"
></view>
<image
v-else
class="content-image"
:src="work.contentUrl"
mode="widthFix"
lazy-load
/>
<!-- 视频类型标识 - 右上角 -->
<view class="video-badge" v-if="work.contentType === 2">
<image class="video-icon" src="/static/icons/video.png" mode="aspectFit" />
</view>
</view>
<!-- 信息区域 -->
<view class="info">
<!-- 标题 -->
<text class="title">{{ truncateTitle(work.title) }}</text>
<!-- 底部作者和点赞 -->
<view class="bottom">
<view class="author">
<image class="avatar" :src="work.avatar || '/static/images/default-avatar.png'" mode="aspectFill" />
<text class="nickname">{{ work.nickname || '匿名用户' }}</text>
</view>
<view class="like" @click.stop="handleLike">
<image
class="like-icon"
:src="work.liked ? '/static/icons/heart-filled.png' : '/static/icons/heart.png'"
mode="aspectFit"
/>
<text class="like-count">{{ formatCount(work.likeCount) }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { toggleLike } from '@/api/work'
import { checkLogin } from '@/utils/auth'
import { useUserStore } from '@/store/modules/user'
const props = defineProps({
work: {
type: Object,
required: true
},
pageVisible: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['click', 'like-change'])
const userStore = useUserStore()
// 截断标题最多14个字
const truncateTitle = (title) => {
if (!title) return ''
return title.length > 14 ? title.slice(0, 14) + '...' : title
}
const formatCount = (count) => {
if (!count) return '0'
if (count >= 10000) {
return (count / 10000).toFixed(1) + 'w'
}
if (count >= 1000) {
return (count / 1000).toFixed(1) + 'k'
}
return count.toString()
}
const handleClick = () => {
emit('click', props.work)
}
const handleLike = async () => {
// 检查登录
if (!checkLogin()) return
try {
const result = await toggleLike(props.work.id)
emit('like-change', { id: props.work.id, liked: result.liked, likeCount: result.likeCount })
} catch (e) {
console.error('点赞操作失败', e)
}
}
</script>
<style scoped>
.work-card {
border-radius: 12px;
overflow: hidden;
margin-bottom: 12px;
-webkit-transform: translateZ(0);
transform: translateZ(0);
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
}
.content-wrapper {
position: relative;
width: 100%;
border-radius: 12px;
overflow: hidden;
-webkit-transform: translateZ(0);
transform: translateZ(0);
}
.content-video {
width: 100%;
height: 200px;
display: block;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
}
.video-placeholder {
background-color: #1a1a1a;
}
.content-image {
width: 100%;
display: block;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
will-change: transform;
}
.video-badge {
position: absolute;
right: 8px;
top: 8px;
}
.video-icon {
width: 24px;
height: 24px;
}
.info {
padding: 8px 4px 10px;
}
.title {
font-size: 14px;
color: #f1f5f9;
line-height: 1.4;
}
.bottom {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
}
.author {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
.avatar {
width: 20px;
height: 20px;
border-radius: 50%;
flex-shrink: 0;
}
.nickname {
font-size: 12px;
color: #71717a;
margin-left: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.like {
display: flex;
align-items: center;
flex-shrink: 0;
padding: 4px 0 4px 8px;
}
.like-icon {
width: 16px;
height: 16px;
}
.like-count {
font-size: 12px;
color: #71717a;
margin-left: 4px;
}
</style>

View File

@@ -0,0 +1,317 @@
<template>
<view class="work-list">
<!-- 骨架屏 -->
<view v-if="isLoading && workList.length === 0" class="skeleton-wrapper">
<view class="waterfall">
<view class="column">
<view v-for="i in 3" :key="'l'+i" class="skeleton-card">
<view class="skeleton-image"></view>
<view class="skeleton-info">
<view class="skeleton-title"></view>
<view class="skeleton-bottom">
<view class="skeleton-avatar"></view>
<view class="skeleton-name"></view>
</view>
</view>
</view>
</view>
<view class="column">
<view v-for="i in 3" :key="'r'+i" class="skeleton-card" :class="{'skeleton-short': i === 1}">
<view class="skeleton-image"></view>
<view class="skeleton-info">
<view class="skeleton-title"></view>
<view class="skeleton-bottom">
<view class="skeleton-avatar"></view>
<view class="skeleton-name"></view>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 瀑布流双列布局 -->
<view v-else class="waterfall" hover-class="none">
<view class="column" hover-class="none">
<WorkCard
v-for="item in leftList"
:key="item.id"
:work="item"
:page-visible="pageVisible"
@click="handleWorkClick"
@like-change="handleLikeChange"
/>
</view>
<view class="column" hover-class="none">
<WorkCard
v-for="item in rightList"
:key="item.id"
:work="item"
:page-visible="pageVisible"
@click="handleWorkClick"
@like-change="handleLikeChange"
/>
</view>
</view>
<!-- 加载状态 -->
<view class="load-status">
<view v-if="isLoading && workList.length > 0" class="loading">
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="isFinished && workList.length > 0" class="no-more">
<text class="no-more-text">没有更多了</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { getWorkList } from '@/api/work'
import WorkCard from '@/components/WorkCard/index.vue'
const props = defineProps({
// 自动加载模式的参数
sortType: { type: String, default: 'hot' },
categoryId: { type: [Number, String], default: null },
// 外部数据模式的参数
works: { type: Array, default: null },
loading: { type: Boolean, default: false },
finished: { type: Boolean, default: false },
// 页面可见性控制WorkCard中video组件的挂载/卸载
pageVisible: { type: Boolean, default: true }
})
const emit = defineEmits(['click', 'item-click', 'like-change', 'load-more'])
// 内部数据(自动加载模式)
const internalList = ref([])
const internalLoading = ref(false)
const internalHasMore = ref(true)
const pageNum = ref(1)
const pageSize = 10
const MAX_LIST_SIZE = 100 // 列表最大条数,超出后裁剪头部释放内存
// 判断是否使用外部数据模式
const isExternalMode = computed(() => props.works !== null)
// 统一的数据源
const workList = computed(() => isExternalMode.value ? props.works : internalList.value)
const isLoading = computed(() => isExternalMode.value ? props.loading : internalLoading.value)
const isFinished = computed(() => isExternalMode.value ? props.finished : !internalHasMore.value)
// 瀑布流分列
const leftList = computed(() => workList.value.filter((_, i) => i % 2 === 0))
const rightList = computed(() => workList.value.filter((_, i) => i % 2 === 1))
// 加载数据(自动加载模式)
const loadData = async (isRefresh = false) => {
if (isExternalMode.value) return
if (internalLoading.value) return
if (!isRefresh && !internalHasMore.value) return
internalLoading.value = true
try {
const params = {
sortType: props.sortType || 'hot',
pageNum: isRefresh ? 1 : pageNum.value,
pageSize
}
// 只有当 categoryId 存在且不为 null/undefined 时才添加
if (props.categoryId != null && props.categoryId !== '' && props.categoryId !== 'null' && props.categoryId !== 'undefined') {
params.categoryId = props.categoryId
}
const res = await getWorkList(params)
if (isRefresh) {
internalList.value = res?.list || []
pageNum.value = 1
} else {
internalList.value = [...internalList.value, ...(res?.list || [])]
}
// 裁剪超出上限的头部数据,防止内存溢出
if (internalList.value.length > MAX_LIST_SIZE) {
internalList.value = internalList.value.slice(-MAX_LIST_SIZE)
}
internalHasMore.value = res?.hasNext ?? false
pageNum.value++
} catch (e) {
console.error('加载作品失败', e)
} finally {
internalLoading.value = false
}
}
// 刷新
const refresh = () => {
if (isExternalMode.value) return
internalHasMore.value = true
loadData(true)
}
// 加载更多
const loadMore = () => {
if (isExternalMode.value) {
emit('load-more')
} else if (!internalLoading.value && internalHasMore.value) {
loadData()
}
}
// 监听筛选条件变化(自动加载模式)
watch(() => [props.sortType, props.categoryId], () => {
if (!isExternalMode.value) {
refresh()
}
}, { immediate: false, deep: true })
// 处理点击
const handleWorkClick = (work) => {
emit('click', work)
emit('item-click', work)
}
// 处理点赞变化
const handleLikeChange = (data) => {
emit('like-change', data)
// 自动加载模式下更新内部数据
if (!isExternalMode.value) {
const item = internalList.value.find(w => w.id === data.id)
if (item) {
item.liked = data.liked
item.likeCount = Math.max(0, data.likeCount)
}
}
}
// 监听页面滚动到底部
const onReachBottom = () => {
loadMore()
}
// 暴露方法
defineExpose({ refresh, loadMore })
onMounted(() => {
if (!isExternalMode.value) {
loadData(true)
}
uni.$on('reachBottom', onReachBottom)
})
onUnmounted(() => {
uni.$off('reachBottom', onReachBottom)
// 释放列表数据内存
internalList.value = []
})
</script>
<style scoped>
.work-list {
padding: 0;
}
.waterfall {
display: flex;
gap: 10px;
-webkit-transform: translateZ(0);
transform: translateZ(0);
}
.column {
flex: 1;
display: flex;
flex-direction: column;
-webkit-transform: translateZ(0);
transform: translateZ(0);
}
/* 骨架屏样式 */
.skeleton-wrapper {
width: 100%;
}
.skeleton-card {
background: #18181b;
border-radius: 12px;
overflow: hidden;
margin-bottom: 12px;
}
.skeleton-image {
width: 100%;
height: 200px;
background: linear-gradient(90deg, #27272a 25%, #3f3f46 50%, #27272a 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-short .skeleton-image {
height: 150px;
}
.skeleton-info {
padding: 10px 12px 12px;
}
.skeleton-title {
height: 16px;
width: 80%;
background: linear-gradient(90deg, #27272a 25%, #3f3f46 50%, #27272a 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
.skeleton-bottom {
display: flex;
align-items: center;
margin-top: 10px;
}
.skeleton-avatar {
width: 22px;
height: 22px;
border-radius: 50%;
background: linear-gradient(90deg, #27272a 25%, #3f3f46 50%, #27272a 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-name {
height: 12px;
width: 60px;
margin-left: 6px;
background: linear-gradient(90deg, #27272a 25%, #3f3f46 50%, #27272a 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* 加载状态 */
.load-status {
padding: 20px 0;
text-align: center;
}
.loading-text,
.no-more-text {
font-size: 13px;
color: #71717a;
}
</style>

33
src/config/index.js Normal file
View File

@@ -0,0 +1,33 @@
/**
* 应用配置
*/
// 环境判断
const isDev = process.env.NODE_ENV === 'development'
// API基础地址后端已配置context-path: /api
const API_BASE_URL = {
development: 'https://api.1818ai.com/api', // 开发环境也使用线上后端
// development: 'http://127.0.0.1:8080/api',
// production: 'https://api.1818ai.com/api'
}
const config = {
// API地址
baseUrl: isDev ? API_BASE_URL.development : API_BASE_URL.production,
// 请求超时时间AI生成需要较长时间设置120秒
timeout: 120000,
// 微信AppID
wxAppId: 'wxe09413e19ac0c02c'
}
// 获取API基础地址
export const getBaseUrl = () => config.baseUrl
// 调试信息
console.log('当前环境:', process.env.NODE_ENV)
console.log('API地址:', config.baseUrl)
export default config

26
src/hooks/useLoading.js Normal file
View File

@@ -0,0 +1,26 @@
import { ref } from 'vue'
export function useLoading(initValue = false) {
const loading = ref(initValue)
const startLoading = (title = '加载中...') => {
loading.value = true
uni.showLoading({ title, mask: true })
}
const stopLoading = () => {
loading.value = false
uni.hideLoading()
}
const withLoading = async (fn, title) => {
try {
startLoading(title)
return await fn()
} finally {
stopLoading()
}
}
return { loading, startLoading, stopLoading, withLoading }
}

83
src/hooks/useSafeArea.js Normal file
View File

@@ -0,0 +1,83 @@
import { ref, onMounted } from 'vue'
// 安全区域信息
const safeAreaInsets = ref({
top: 0,
bottom: 0,
left: 0,
right: 0
})
// 状态栏高度
const statusBarHeight = ref(0)
// 是否已初始化
let initialized = false
// 初始化安全区域
const initSafeArea = () => {
if (initialized) return
try {
// 使用新 API
const windowInfo = uni.getWindowInfo()
const deviceInfo = uni.getDeviceInfo()
statusBarHeight.value = windowInfo.statusBarHeight || 0
if (windowInfo.safeAreaInsets) {
safeAreaInsets.value = {
top: windowInfo.safeAreaInsets.top || 0,
bottom: windowInfo.safeAreaInsets.bottom || 0,
left: windowInfo.safeAreaInsets.left || 0,
right: windowInfo.safeAreaInsets.right || 0
}
} else if (windowInfo.safeArea) {
safeAreaInsets.value = {
top: windowInfo.safeArea.top || 0,
bottom: windowInfo.screenHeight - windowInfo.safeArea.bottom || 0,
left: windowInfo.safeArea.left || 0,
right: windowInfo.screenWidth - windowInfo.safeArea.right || 0
}
}
} catch (e) {
// 兼容旧版本,降级使用 getSystemInfoSync
const systemInfo = uni.getSystemInfoSync()
statusBarHeight.value = systemInfo.statusBarHeight || 0
if (systemInfo.safeAreaInsets) {
safeAreaInsets.value = {
top: systemInfo.safeAreaInsets.top || 0,
bottom: systemInfo.safeAreaInsets.bottom || 0,
left: systemInfo.safeAreaInsets.left || 0,
right: systemInfo.safeAreaInsets.right || 0
}
} else if (systemInfo.safeArea) {
safeAreaInsets.value = {
top: systemInfo.safeArea.top || 0,
bottom: systemInfo.screenHeight - systemInfo.safeArea.bottom || 0,
left: systemInfo.safeArea.left || 0,
right: systemInfo.screenWidth - systemInfo.safeArea.right || 0
}
}
}
initialized = true
}
export function useSafeArea() {
onMounted(() => {
initSafeArea()
})
// 立即初始化
initSafeArea()
return {
safeAreaInsets,
statusBarHeight
}
}
// 导出初始化方法供全局使用
export { initSafeArea, safeAreaInsets, statusBarHeight }

29
src/main.js Normal file
View File

@@ -0,0 +1,29 @@
import { createSSRApp } from 'vue'
import App from './App.vue'
import { setupStore } from './store'
import globalShareMixin from './mixins/globalShareMixin'
import './styles/tailwind.css'
import './styles/index.css'
// 动态导入 uview-plus 以避免构建时错误
let uviewPlus = null
try {
uviewPlus = require('uview-plus')
} catch (e) {
console.warn('uview-plus 加载失败:', e)
}
export function createApp() {
const app = createSSRApp(App)
setupStore(app)
// 全局混入分享功能 - 确保所有页面都能使用微信分享
app.mixin(globalShareMixin)
// 只有在 uview-plus 成功加载时才使用
if (uviewPlus) {
app.use(uviewPlus.default || uviewPlus)
}
return { app }
}

41
src/manifest.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "1818AIGC",
"appid": "",
"description": "1818AIGC-创意即刻生成",
"versionName": "1.0.0",
"versionCode": "100",
"transformPx": false,
"h5": {
"title": "UniApp Enterprise",
"router": {
"mode": "hash"
}
},
"mp-weixin": {
"appid": "wxe09413e19ac0c02c",
"setting": {
"urlCheck": false,
"es6": true,
"minified": true,
"postcss": true
},
"usingComponents": true,
"darkmode": false,
"window": {
"navigationBarTextStyle": "white",
"navigationBarBackgroundColor": "#09090b",
"backgroundColorTop": "#09090b",
"backgroundColorBottom": "#09090b"
},
"networkTimeout": {
"request": 30000,
"uploadFile": 30000,
"downloadFile": 30000
},
"permission": {
"scope.userLocation": {
"desc": "您的位置信息将用于小程序位置接口的效果展示"
}
}
}
}

View File

@@ -0,0 +1,314 @@
/**
* 全局分享混入
* 确保所有页面都能正常使用微信小程序的分享功能
*
* 使用方法:在 main.js 中全局注册此混入
*/
import { useUserStore } from '@/store/modules/user'
import { generateSharePath, generateShareQuery } from '@/utils/navigation'
export default {
// 页面分享给好友
onShareAppMessage(shareInfo) {
console.log('=== 全局混入: 分享给好友触发 ===')
console.log('分享信息:', shareInfo)
try {
// 获取当前页面信息
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const route = currentPage.route
const options = currentPage.options
console.log('当前页面路由:', route)
console.log('当前页面参数:', options)
// 获取用户信息
let userStore = null
try {
const userStoreData = uni.getStorageSync('user-store')
let userInfo = null
let isLogin = false
if (userStoreData) {
const parsedData = JSON.parse(userStoreData)
userInfo = parsedData.userInfo
isLogin = !!parsedData.token && !!userInfo
}
userStore = {
isLogin,
userInfo: userInfo || null
}
console.log('用户登录状态:', isLogin)
console.log('用户邀请码:', userInfo?.inviteCode)
} catch (e) {
console.log('获取用户信息失败:', e)
userStore = { isLogin: false, userInfo: null }
}
// 尝试从页面实例获取自定义分享配置
let shareConfig = null
// 如果页面有自己的 getShareConfig 方法,优先使用
if (currentPage.$vm && typeof currentPage.$vm.getShareConfig === 'function') {
console.log('页面提供了自定义分享配置方法')
shareConfig = currentPage.$vm.getShareConfig()
}
// 否则根据路由生成默认配置
else {
console.log('使用路由默认分享配置')
shareConfig = getShareConfigByRoute(route, options, currentPage, userStore)
}
console.log('最终分享配置:', shareConfig)
// 生成带推广码的分享路径
const finalPath = generateSharePath(shareConfig.path, userStore)
console.log('最终分享路径:', finalPath)
const result = {
title: shareConfig.title,
path: finalPath,
imageUrl: shareConfig.imageUrl
}
console.log('=== 返回分享结果 ===', result)
return result
} catch (error) {
console.error('=== 分享处理出错 ===', error)
// 返回默认配置
return {
title: 'AI创作神器 - 一键生成精美图片和视频',
path: '/pages/inspiration/index',
imageUrl: 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
}
}
},
// 页面分享到朋友圈
onShareTimeline() {
console.log('=== 全局混入: 分享到朋友圈触发 ===')
try {
// 获取当前页面信息
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const route = currentPage.route
const options = currentPage.options
console.log('当前页面路由:', route)
console.log('当前页面参数:', options)
// 获取用户信息
let userStore = null
try {
const userStoreData = uni.getStorageSync('user-store')
let userInfo = null
let isLogin = false
if (userStoreData) {
const parsedData = JSON.parse(userStoreData)
userInfo = parsedData.userInfo
isLogin = !!parsedData.token && !!userInfo
}
userStore = {
isLogin,
userInfo: userInfo || null
}
console.log('用户登录状态:', isLogin)
console.log('用户邀请码:', userInfo?.inviteCode)
} catch (e) {
console.log('获取用户信息失败:', e)
userStore = { isLogin: false, userInfo: null }
}
// 尝试从页面实例获取自定义分享配置
let shareConfig = null
if (currentPage.$vm && typeof currentPage.$vm.getShareConfig === 'function') {
console.log('页面提供了自定义分享配置方法')
shareConfig = currentPage.$vm.getShareConfig()
} else {
console.log('使用路由默认分享配置')
shareConfig = getShareConfigByRoute(route, options, currentPage, userStore)
}
console.log('朋友圈分享配置:', shareConfig)
// 生成带推广码的分享query
const baseQuery = shareConfig.query || ''
const finalQuery = generateShareQuery(baseQuery, userStore)
console.log('朋友圈最终query:', finalQuery)
const result = {
title: shareConfig.title,
query: finalQuery,
imageUrl: shareConfig.imageUrl
}
console.log('=== 返回朋友圈分享结果 ===', result)
return result
} catch (error) {
console.error('=== 朋友圈分享出错 ===', error)
return {
title: 'AI创作神器 - 一键生成精美图片和视频',
query: '',
imageUrl: 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
}
}
}
}
/**
* 根据页面路由生成分享配置
*/
function getShareConfigByRoute(route, options = {}, currentPage, userStore) {
const defaultConfig = {
title: 'AI创作神器 - 一键生成精美图片和视频',
path: '/pages/inspiration/index',
query: '',
imageUrl: 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
}
// 移除 pages/ 前缀进行匹配
const normalizedRoute = route.replace(/^pages\//, '')
switch (normalizedRoute) {
case 'dream/detail':
const taskNo = options.taskNo || ''
let shareImageUrl = defaultConfig.imageUrl
// 尝试从页面数据获取任务信息
try {
if (currentPage.$vm && currentPage.$vm.taskData) {
const taskData = currentPage.$vm.taskData
if (taskData.outputResult) {
const result = typeof taskData.outputResult === 'string'
? JSON.parse(taskData.outputResult)
: taskData.outputResult
const outputUrl = result.result || result.url || result.videoUrl || result.imageUrl || ''
const isVideo = outputUrl && (
outputUrl.includes('.mp4') ||
outputUrl.includes('.mov') ||
outputUrl.includes('video')
)
if (!isVideo && outputUrl) {
shareImageUrl = outputUrl
}
}
}
} catch (e) {
console.log('获取造梦任务信息失败')
}
return {
title: '看看我用AI生成的作品!',
path: `/pages/dream/detail?taskNo=${taskNo}`,
query: `taskNo=${taskNo}`,
imageUrl: shareImageUrl
}
case 'work/detail':
const workId = options.id || ''
let workTitle = 'AI创作作品分享'
let workImageUrl = defaultConfig.imageUrl
try {
if (currentPage.$vm && currentPage.$vm.currentWork) {
const work = currentPage.$vm.currentWork
if (work) {
workTitle = work.prompt || work.description || '看看我的AI创作作品!'
if (work.contentType === 2) {
workImageUrl = defaultConfig.imageUrl
} else if (work.contentUrl) {
workImageUrl = work.contentUrl
}
}
}
} catch (e) {
console.log('获取作品信息失败')
}
return {
title: workTitle,
path: `/pages/work/detail?id=${workId}`,
query: `id=${workId}`,
imageUrl: workImageUrl
}
case 'invite/index':
return {
title: '邀好友赢500积分 - 快来体验AI创作神器!',
path: '/pages/inspiration/index',
query: '',
imageUrl: defaultConfig.imageUrl
}
case 'ai/detail':
const aiModelId = options.id || ''
let aiModelName = 'AI功能'
let aiModelIcon = defaultConfig.imageUrl
try {
if (currentPage.$vm && currentPage.$vm.model) {
const model = currentPage.$vm.model
aiModelName = model.name || aiModelName
aiModelIcon = model.icon || aiModelIcon
}
} catch (e) {
console.log('获取AI模型信息失败')
}
return {
title: `${aiModelName} - 强大的AI创作工具`,
path: `/pages/ai/detail?id=${aiModelId}`,
query: `id=${aiModelId}`,
imageUrl: aiModelIcon
}
case 'dream/index':
return {
title: 'AI造梦 - 释放无限想象',
path: '/pages/dream/index',
query: '',
imageUrl: defaultConfig.imageUrl
}
case 'assets/index':
return {
title: '我的AI创作资产',
path: '/pages/assets/index',
query: '',
imageUrl: defaultConfig.imageUrl
}
case 'user/index':
return {
title: 'AI创作作品集',
path: '/pages/inspiration/index',
query: '',
imageUrl: defaultConfig.imageUrl
}
case 'inspiration/index':
return {
title: 'AI创作神器 - 发现精彩作品,释放创意灵感',
path: '/pages/inspiration/index',
query: '',
imageUrl: defaultConfig.imageUrl
}
default:
console.log('未匹配到特定页面配置,使用默认配置')
return defaultConfig
}
}

199
src/mixins/shareMixin.js Normal file
View File

@@ -0,0 +1,199 @@
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { useUserStore } from '@/store/modules/user'
import { generateSharePath, generateShareQuery } from '@/utils/navigation'
/**
* 全局分享混入
* 为所有页面提供统一的分享功能
*/
export function useShareMixin(options = {}) {
const userStore = useUserStore()
// 启用分享菜单
// #ifdef MP-WEIXIN
uni.showShareMenu({
withShareTicket: true,
menus: ['shareAppMessage', 'shareTimeline']
})
// #endif
// 默认分享配置
const defaultConfig = {
title: 'AI创作神器 - 一键生成精美图片和视频',
imageUrl: 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png',
path: '/pages/inspiration/index', // 默认分享到首页
query: ''
}
// 合并配置
const config = { ...defaultConfig, ...options }
// 分享给好友
onShareAppMessage(() => {
console.log('=== 微信分享给好友触发 ===')
console.log('onShareAppMessage 被调用')
const shareConfig = typeof config.getShareConfig === 'function'
? config.getShareConfig()
: config
console.log('分享配置:', shareConfig)
const basePath = shareConfig.path || defaultConfig.path
const finalPath = generateSharePath(basePath, userStore)
console.log('=== 分享链接生成 ===')
console.log('原始路径:', basePath)
console.log('用户登录状态:', userStore.isLogin)
console.log('用户信息完整对象:', JSON.stringify(userStore.userInfo, null, 2))
console.log('用户邀请码:', userStore.userInfo?.inviteCode)
console.log('用户ID:', userStore.userInfo?.userId)
console.log('用户昵称:', userStore.userInfo?.nickname)
console.log('最终分享路径:', finalPath)
const shareResult = {
title: shareConfig.title || defaultConfig.title,
path: finalPath,
imageUrl: shareConfig.imageUrl || defaultConfig.imageUrl
}
console.log('=== 最终分享结果 ===')
console.log('分享标题:', shareResult.title)
console.log('分享路径:', shareResult.path)
console.log('分享图片:', shareResult.imageUrl)
console.log('=== 分享结果返回给微信 ===')
return shareResult
})
// 分享到朋友圈
onShareTimeline(() => {
console.log('=== 微信分享到朋友圈触发 ===')
const shareConfig = typeof config.getShareConfig === 'function'
? config.getShareConfig()
: config
console.log('朋友圈分享配置:', shareConfig)
const baseQuery = shareConfig.query || ''
const finalQuery = generateShareQuery(baseQuery, userStore)
console.log('=== 朋友圈分享链接生成 ===')
console.log('原始query:', baseQuery)
console.log('用户登录状态:', userStore.isLogin)
console.log('用户邀请码:', userStore.userInfo?.inviteCode)
console.log('最终分享query:', finalQuery)
const shareResult = {
title: shareConfig.title || defaultConfig.title,
query: finalQuery,
imageUrl: shareConfig.imageUrl || defaultConfig.imageUrl
}
console.log('=== 朋友圈最终分享结果 ===')
console.log('分享标题:', shareResult.title)
console.log('分享query:', shareResult.query)
console.log('分享图片:', shareResult.imageUrl)
return shareResult
})
}
/**
* 页面特定的分享配置生成器
*/
export const shareConfigs = {
// 作品详情页分享配置
workDetail: (work) => ({
title: work?.prompt || work?.description || 'AI创作作品分享',
path: `/pages/work/detail?id=${work?.id}`,
query: `id=${work?.id}`,
imageUrl: work?.contentUrl || 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
}),
// 造梦详情页分享配置
dreamDetail: (taskData, taskNo) => {
// 确保taskNo有效
if (!taskNo) {
console.warn('分享配置taskNo为空')
return {
title: 'AI创作神器 - 一键生成精美图片和视频',
path: '/pages/inspiration/index',
query: '',
imageUrl: 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
}
}
// 获取输出图片URL - 参考广场作品详情页的处理方式
let outputUrl = ''
let isVideo = false
if (taskData?.outputResult) {
try {
const result = JSON.parse(taskData.outputResult)
outputUrl = result.result || result.url || result.videoUrl || result.imageUrl || ''
// 判断是否为视频文件 - 与广场保持一致的判断逻辑
if (outputUrl && (outputUrl.includes('.mp4') || outputUrl.includes('.mov') || outputUrl.includes('video') || outputUrl.includes('.avi') || outputUrl.includes('.mkv'))) {
isVideo = true
}
} catch (e) {
outputUrl = taskData.outputResult || ''
if (outputUrl && (outputUrl.includes('.mp4') || outputUrl.includes('.mov') || outputUrl.includes('video') || outputUrl.includes('.avi') || outputUrl.includes('.mkv'))) {
isVideo = true
}
}
}
// 视频作品使用默认图片,图片作品使用作品图片(与广场保持完全一致)
const shareImageUrl = (isVideo || !outputUrl)
? 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
: outputUrl
console.log('=== dreamDetail 分享配置生成 ===')
console.log('taskNo:', taskNo)
console.log('outputUrl:', outputUrl)
console.log('isVideo:', isVideo)
console.log('shareImageUrl:', shareImageUrl)
return {
title: '看看我用AI生成的作品',
path: `/pages/dream/detail?taskNo=${taskNo}`,
query: `taskNo=${taskNo}`,
imageUrl: shareImageUrl
}
},
// 邀请页面分享配置
invite: (userStore) => ({
title: '快来体验AI创作神器一起创造精美作品',
path: '/pages/inspiration/index',
query: '',
imageUrl: 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
}),
// AI模型详情页分享配置
aiDetail: (model) => ({
title: `${model?.name || 'AI功能'} - 强大的AI创作工具`,
path: `/pages/ai/detail?id=${model?.id}`,
query: `id=${model?.id}`,
imageUrl: model?.icon || 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
}),
// 用户页面分享配置
userProfile: (profile) => ({
title: `${profile?.nickname || '用户'}的AI创作作品集`,
path: '/pages/inspiration/index',
query: '',
imageUrl: profile?.avatar || 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
}),
// 首页分享配置
home: () => ({
title: 'AI创作神器 - 一键生成精美图片和视频',
path: '/pages/inspiration/index',
query: '',
imageUrl: 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
})
}

219
src/pages.json Normal file
View File

@@ -0,0 +1,219 @@
{
"easycom": {
"autoscan": true,
"custom": {
"^u--(.*)": "uview-plus/components/u-$1/u-$1.vue",
"^up-(.*)": "uview-plus/components/u-$1/u-$1.vue",
"^u-([^-].*)": "uview-plus/components/u-$1/u-$1.vue",
"^Custom(.*)": "@/components/Custom$1/index.vue",
"^SafeAreaView": "@/components/SafeAreaView/index.vue",
"^TabBar": "@/components/TabBar/index.vue",
"^HomeHeader": "@/components/HomeHeader/index.vue",
"^CategoryBar": "@/components/CategoryBar/index.vue",
"^WorkCard": "@/components/WorkCard/index.vue",
"^WorkList": "@/components/WorkList/index.vue",
"^TaskItem": "@/components/TaskItem/index.vue"
}
},
"pages": [
{
"path": "pages/inspiration/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/work/detail",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/work/publish",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/login/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/dream/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/dream/detail",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/dream/create",
"style": {
"navigationStyle": "custom",
"app-plus": {
"softinputMode": "adjustResize"
}
}
},
{
"path": "pages/assets/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/user/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/profile/edit",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/settings/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/agreement/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/agreement/payment",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/invite/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/points/subscribe",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/ai/models",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/ai/detail",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/ai/task",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/create/video",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/create/video-create",
"style": {
"navigationStyle": "custom",
"app-plus": {
"softinputMode": "adjustResize"
}
}
},
{
"path": "pages/create/video-create-settings",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/create/video-create-character",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/create/video-create-character-edit",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/create/video-create-storyboard",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/create/video-create-storyboard-result",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/create/video-compose",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/search/index",
"style": {
"navigationStyle": "custom"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "white",
"navigationBarTitleText": "UniApp",
"navigationBarBackgroundColor": "#09090b",
"backgroundColor": "#09090b"
},
"tabBar": {
"custom": true,
"color": "#a1a1aa",
"selectedColor": "#f1f5f9",
"borderStyle": "black",
"backgroundColor": "#09090b",
"list": [
{
"pagePath": "pages/inspiration/index",
"text": "灵感"
},
{
"pagePath": "pages/dream/index",
"text": "造梦"
},
{
"pagePath": "pages/assets/index",
"text": "资产"
},
{
"pagePath": "pages/user/index",
"text": "我的"
}
]
}
}

View File

@@ -0,0 +1,182 @@
<template>
<view class="agreement-page">
<!-- 顶部导航 -->
<view class="nav-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-back" @click="handleBack">
<image class="back-icon" src="/static/icons/Left (左).png" mode="aspectFit" />
</view>
<text class="nav-title">用户协议</text>
<view class="nav-placeholder"></view>
</view>
<!-- 内容区域 -->
<scroll-view
class="content"
scroll-y
:style="{ marginTop: (statusBarHeight + 44) + 'px' }"
>
<view class="agreement-content">
<text class="title">1818AI 用户服务协议</text>
<text class="update-time">更新日期2024年1月1日</text>
<text class="section-title">服务条款的确认和接纳</text>
<text class="section-content">
欢迎使用1818AI服务在使用本服务之前请您仔细阅读本协议的全部内容如果您不同意本协议的任何内容请不要使用本服务当您使用本服务时即表示您已充分阅读理解并接受本协议的全部内容
</text>
<text class="section-title">服务内容</text>
<text class="section-content">
1818AI是一款AI创作平台为用户提供AI图片生成AI视频生成等创作服务具体服务内容以平台实际提供为准
</text>
<text class="section-title">用户注册</text>
<text class="section-content">
1. 用户需通过微信授权登录使用本服务
2. 用户应提供真实准确的个人信息
3. 用户应妥善保管账号信息对账号下的所有行为负责
</text>
<text class="section-title">用户行为规范</text>
<text class="section-content">
用户在使用本服务时不得
1. 发布违反法律法规的内容
2. 发布侵犯他人知识产权的内容
3. 发布色情暴力恐怖等不良内容
4. 利用本服务从事任何违法活动
5. 干扰或破坏本服务的正常运行
</text>
<text class="section-title">知识产权</text>
<text class="section-content">
1. 用户使用本服务生成的内容其知识产权归用户所有
2. 用户授权平台在服务范围内使用展示用户生成的内容
3. 平台的商标标识技术等知识产权归平台所有
</text>
<text class="section-title">隐私保护</text>
<text class="section-content">
我们重视用户隐私保护具体隐私政策请参阅隐私政策
</text>
<text class="section-title">免责声明</text>
<text class="section-content">
1. 因不可抗力导致的服务中断平台不承担责任
2. 用户因违反本协议导致的损失由用户自行承担
3. AI生成内容仅供参考平台不对其准确性负责
</text>
<text class="section-title">协议修改</text>
<text class="section-content">
平台有权根据需要修改本协议修改后的协议将在平台公布如您继续使用本服务即表示您接受修改后的协议
</text>
<text class="section-title">联系我们</text>
<text class="section-content">
如有任何问题请通过平台内的反馈功能联系我们
</text>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const statusBarHeight = ref(0)
onMounted(() => {
const sysInfo = uni.getSystemInfoSync()
statusBarHeight.value = sysInfo.statusBarHeight || 0
})
const handleBack = () => {
uni.navigateBack()
}
</script>
<style scoped>
.agreement-page {
min-height: 100vh;
background-color: #09090b;
}
.nav-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
background: #09090b;
z-index: 100;
}
.nav-back {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.back-icon {
width: 24px;
height: 24px;
}
.nav-title {
font-size: 17px;
font-weight: 600;
color: #ffffff;
}
.nav-placeholder {
width: 40px;
}
.content {
height: calc(100vh - 44px);
padding: 20px 16px;
box-sizing: border-box;
}
.agreement-content {
padding-bottom: 40px;
}
.title {
display: block;
font-size: 20px;
font-weight: 600;
color: #f4f4f5;
text-align: center;
margin-bottom: 8px;
}
.update-time {
display: block;
font-size: 13px;
color: #71717a;
text-align: center;
margin-bottom: 24px;
}
.section-title {
display: block;
font-size: 16px;
font-weight: 600;
color: #f4f4f5;
margin-top: 20px;
margin-bottom: 10px;
}
.section-content {
display: block;
font-size: 14px;
color: #a1a1aa;
line-height: 1.8;
}
</style>

View File

@@ -0,0 +1,192 @@
<template>
<view class="agreement-page">
<!-- 顶部导航 -->
<view class="nav-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-back" @click="handleBack">
<image class="back-icon" src="/static/icons/Left (左).png" mode="aspectFit" />
</view>
<text class="nav-title">付费服务协议</text>
<view class="nav-placeholder"></view>
</view>
<!-- 内容区域 -->
<scroll-view
class="content"
scroll-y
:style="{ marginTop: (statusBarHeight + 44) + 'px' }"
>
<view class="agreement-content">
<text class="title">1818AI 付费服务协议</text>
<text class="update-time">更新日期2025年1月1日</text>
<text class="section-title">服务条款的确认和接纳</text>
<text class="section-content">
欢迎使用1818AI付费服务在购买本平台任何付费服务之前请您仔细阅读本协议的全部内容当您完成支付时即表示您已充分阅读理解并接受本协议的全部内容
</text>
<text class="section-title">付费服务内容</text>
<text class="section-content">
1. 积分充值用户可通过支付购买平台积分用于消费AI生成服务
2. 积分使用积分可用于AI图片生成AI视频生成等平台提供的AI创作服务
3. 赠送积分部分套餐包含赠送积分赠送积分与购买积分享有同等使用权益
4. 服务内容以平台实际提供为准平台有权根据运营情况调整服务内容
</text>
<text class="section-title">积分规则</text>
<text class="section-content">
1. 积分有效期购买的积分自购买之日起一年内有效过期未使用的积分将自动清零
2. 积分消耗不同AI服务消耗的积分数量不同具体以服务页面显示为准
3. 积分不可转让积分仅限账户本人使用不可转让赠送或交易
</text>
<text class="section-title">支付与退款</text>
<text class="section-content">
1. 支付方式平台支持微信支付等支付方式具体以支付页面显示为准
2. 支付确认支付成功后积分将即时到账
3. 退款政策由于虚拟商品的特殊性积分一经购买原则上不予退款
4. 异常处理如遇支付异常如扣款未到账请联系客服处理
</text>
<text class="section-title">用户权益</text>
<text class="section-content">
1. 用户有权使用购买的积分享受平台提供的AI创作服务
2. 用户有权查看积分消费明细和剩余积分
3. 用户使用AI服务生成的内容其知识产权归用户所有
4. 用户有权就付费服务相关问题向平台客服咨询
</text>
<text class="section-title">平台权利</text>
<text class="section-content">
1. 平台有权根据运营需要调整积分套餐价格和内容
2. 平台有权对违规账户采取限制措施包括但不限于冻结积分封禁账户
3. 平台有权根据法律法规要求配合有关部门的调查
</text>
<text class="section-title">免责声明</text>
<text class="section-content">
1. 因不可抗力如自然灾害政策变化等导致的服务中断或终止平台不承担责任
2. 因用户自身原因如账户被盗操作失误等导致的积分损失平台不承担责任
3. AI生成内容仅供参考平台不对其准确性完整性合法性负责
4. 用户因违规使用服务导致的任何法律责任由用户自行承担
</text>
<text class="section-title">协议修改</text>
<text class="section-content">
平台有权根据业务发展需要修改本协议修改后的协议将在平台公布如您继续使用付费服务即表示您接受修改后的协议
</text>
<text class="section-title">争议解决</text>
<text class="section-content">
本协议的解释效力及争议的解决均适用中华人民共和国法律如发生争议双方应友好协商解决协商不成的任何一方均可向平台所在地人民法院提起诉讼
</text>
<text class="section-title">联系我们</text>
<text class="section-content">
如对本协议或付费服务有任何疑问请通过平台内的"意见反馈"功能联系我们我们将尽快为您处理
</text>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const statusBarHeight = ref(0)
onMounted(() => {
const sysInfo = uni.getSystemInfoSync()
statusBarHeight.value = sysInfo.statusBarHeight || 0
})
const handleBack = () => {
uni.navigateBack()
}
</script>
<style scoped>
.agreement-page {
min-height: 100vh;
background-color: #09090b;
}
.nav-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
background: #09090b;
z-index: 100;
}
.nav-back {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.back-icon {
width: 24px;
height: 24px;
}
.nav-title {
font-size: 17px;
font-weight: 600;
color: #ffffff;
}
.nav-placeholder {
width: 40px;
}
.content {
height: calc(100vh - 44px);
padding: 20px 16px;
box-sizing: border-box;
}
.agreement-content {
padding-bottom: 40px;
}
.title {
display: block;
font-size: 20px;
font-weight: 600;
color: #f4f4f5;
text-align: center;
margin-bottom: 8px;
}
.update-time {
display: block;
font-size: 13px;
color: #71717a;
text-align: center;
margin-bottom: 24px;
}
.section-title {
display: block;
font-size: 16px;
font-weight: 600;
color: #f4f4f5;
margin-top: 20px;
margin-bottom: 10px;
}
.section-content {
display: block;
font-size: 14px;
color: #a1a1aa;
line-height: 1.8;
}
</style>

40
src/pages/ai/create.vue Normal file
View File

@@ -0,0 +1,40 @@
<template>
<view class="create-task-page">
<AiTaskForm @success="onTaskCreated" />
</view>
</template>
<script>
import AiTaskForm from '@/components/AiTaskForm/index.vue'
export default {
components: {
AiTaskForm
},
methods: {
onTaskCreated(taskNo) {
// 任务创建成功后的处理
uni.showModal({
title: '提示',
content: '任务已提交,是否查看任务详情?',
success: (res) => {
if (res.confirm) {
uni.navigateTo({
url: `/pages/ai/task?taskNo=${taskNo}`
})
} else {
uni.navigateBack()
}
}
})
}
}
}
</script>
<style scoped>
.create-task-page {
min-height: 100vh;
background: #f5f5f5;
}
</style>

1250
src/pages/ai/detail.vue Normal file

File diff suppressed because it is too large Load Diff

241
src/pages/ai/models.vue Normal file
View File

@@ -0,0 +1,241 @@
<template>
<SafeAreaView>
<view class="page-container">
<!-- 顶部导航 -->
<view class="nav-header">
<view class="nav-back" @click="goBack">
<image src="/static/icons/arrow-left.png" class="back-icon" mode="aspectFit" />
</view>
<text class="nav-title">AI功能</text>
<view class="nav-placeholder"></view>
</view>
<!-- 模型列表 -->
<scroll-view class="model-list" scroll-y>
<view class="model-grid">
<view
v-for="model in models"
:key="model.id"
class="model-card"
@click="goToDetail(model)"
>
<view class="model-cover">
<image
v-if="model.coverImage"
:src="model.coverImage"
class="cover-image"
mode="aspectFill"
/>
<view v-else class="cover-placeholder">
<image
v-if="model.icon"
:src="model.icon"
class="icon-large"
mode="aspectFit"
/>
<text v-else class="icon-text">{{ model.name.substring(0, 1) }}</text>
</view>
</view>
<view class="model-info">
<text class="model-name">{{ model.name }}</text>
<text class="model-desc">{{ model.description || '暂无描述' }}</text>
<view class="model-meta">
<text class="model-points">{{ model.pointsCost }} 积分</text>
</view>
</view>
</view>
</view>
<view v-if="loading" class="loading-tip">
<text>加载中...</text>
</view>
<view v-else-if="models.length === 0" class="empty-tip">
<text>暂无可用的AI功能</text>
</view>
</scroll-view>
</view>
</SafeAreaView>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getAiModels } from '@/api/ai'
import { useUserStore } from '@/store/modules/user'
import { handleShareCode } from '@/utils/navigation'
const models = ref([])
const loading = ref(false)
const goBack = () => {
uni.navigateBack()
}
const goToDetail = (model) => {
// 检查登录状态
const userStore = useUserStore()
if (!userStore.isLogin) {
uni.showToast({ title: '请登录', icon: 'none' })
setTimeout(() => {
uni.navigateTo({ url: '/pages/login/index' })
}, 500)
return
}
uni.navigateTo({ url: `/pages/ai/detail?id=${model.id}&code=${model.code}` })
}
const loadModels = async () => {
loading.value = true
try {
const res = await getAiModels()
models.value = res || []
} catch (e) {
console.error('加载模型列表失败', e)
} finally {
loading.value = false
}
}
// 处理页面参数(分享码)
onLoad((options) => {
console.log('=== AI模型页面加载 ===')
console.log('页面参数:', options)
// 处理分享码逻辑
const shareResult = handleShareCode(options)
console.log('AI模型页面分享码处理结果:', shareResult)
})
onMounted(() => {
loadModels()
})
</script>
<style scoped>
.page-container {
min-height: 100vh;
background-color: #09090b;
display: flex;
flex-direction: column;
}
.nav-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background-color: #09090b;
}
.nav-back {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.back-icon {
width: 24px;
height: 24px;
}
.nav-title {
color: #ffffff;
font-size: 18px;
font-weight: 600;
}
.nav-placeholder {
width: 40px;
}
.model-list {
flex: 1;
padding: 16px;
}
.model-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.model-card {
width: calc(50% - 6px);
background: #18181b;
border-radius: 12px;
overflow: hidden;
}
.model-cover {
width: 100%;
height: 120px;
background: #27272a;
}
.cover-image {
width: 100%;
height: 100%;
}
.cover-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #3f3f46 0%, #27272a 100%);
}
.icon-large {
width: 48px;
height: 48px;
}
.icon-text {
font-size: 32px;
color: #ffffff;
font-weight: 600;
}
.model-info {
padding: 12px;
}
.model-name {
color: #ffffff;
font-size: 14px;
font-weight: 500;
display: block;
margin-bottom: 4px;
}
.model-desc {
color: #a1a1aa;
font-size: 12px;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 8px;
}
.model-meta {
display: flex;
align-items: center;
}
.model-points {
color: #fbbf24;
font-size: 12px;
}
.loading-tip,
.empty-tip {
text-align: center;
padding: 40px 0;
color: #71717a;
font-size: 14px;
}
</style>

480
src/pages/ai/task.vue Normal file
View File

@@ -0,0 +1,480 @@
<template>
<SafeAreaView>
<view class="page-container">
<!-- 顶部导航 -->
<view class="nav-header">
<view class="nav-back" @click="goBack">
<image src="/static/icons/arrow-left.png" class="back-icon" mode="aspectFit" />
</view>
<text class="nav-title">任务详情</text>
<view class="nav-placeholder"></view>
</view>
<scroll-view class="content-scroll" scroll-y v-if="taskLoaded">
<!-- 任务状态 -->
<view class="status-section">
<!-- 处理中显示loading动画 -->
<view v-if="taskStatus === 0 || taskStatus === 1" class="loading-animation">
<view class="spinner"></view>
</view>
<!-- 其他状态显示图标 -->
<view v-else class="status-icon" :class="statusClass">
<text v-if="taskStatus === 2"></text>
<text v-else-if="taskStatus === 3"></text>
<text v-else></text>
</view>
<text class="status-text">{{ statusText }}</text>
<!-- 处理中提示用户可以离开 -->
<view v-if="taskStatus === 0 || taskStatus === 1" class="leave-tip">
<text class="tip-text">您可以离开此页面任务完成后将通过微信消息通知您</text>
<text class="tip-sub">也可在我的-我的资产中查看结果</text>
</view>
</view>
<!-- 结果展示 -->
<view class="result-section" v-if="taskStatus === 2 && resultData">
<view class="section-title">生成结果</view>
<!-- 图片结果 -->
<view v-if="resultData.result && isImageUrl(resultData.result)" class="result-image">
<image :src="resultData.result" mode="widthFix" @click="previewImage(resultData.result)" />
</view>
<!-- 视频结果仅在页面可见时挂载 -->
<view v-else-if="resultData.result && isVideoUrl(resultData.result)" class="result-video">
<video v-if="pageVisible" :src="resultData.result" controls></video>
<view v-else style="width:100%;height:200px;background:#000;"></view>
</view>
<!-- 文本结果 -->
<view v-else-if="resultData.result" class="result-text">
<text>{{ resultData.result }}</text>
</view>
</view>
<!-- 错误信息 -->
<view class="error-section" v-if="taskStatus === 3">
<view class="section-title">错误信息</view>
<view class="error-content">
<text>{{ taskErrorMessage || '任务执行失败' }}</text>
</view>
</view>
<!-- 任务信息 -->
<view class="info-section">
<view class="section-title">任务信息</view>
<view class="info-list">
<view class="info-item">
<text class="info-label">任务名称</text>
<text class="info-value">{{ taskModelName || '生成任务' }}</text>
</view>
<view class="info-item">
<text class="info-label">任务编号</text>
<text class="info-value">{{ taskNo }}</text>
</view>
<view class="info-item">
<text class="info-label">消耗积分</text>
<text class="info-value">{{ taskPointsCost }}</text>
</view>
<view class="info-item">
<text class="info-label">创建时间</text>
<text class="info-value">{{ taskCreatedAt }}</text>
</view>
<view class="info-item" v-if="taskDuration">
<text class="info-label">耗时</text>
<text class="info-value">{{ formatDuration(taskDuration) }}</text>
</view>
</view>
</view>
</scroll-view>
<!-- 加载中 -->
<view v-if="loading" class="loading-container">
<text class="loading-text">加载中...</text>
</view>
</view>
</SafeAreaView>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { onLoad, onUnload, onShow, onHide } from '@dcloudio/uni-app'
import { getAiTaskByNo } from '@/api/ai'
import { handleShareCode } from '@/utils/navigation'
// 使用独立的ref避免整体更新导致闪烁
const taskLoaded = ref(false)
const taskId = ref(null)
const taskNo = ref('')
const taskStatus = ref(0)
const taskModelName = ref('')
const taskPointsCost = ref(0)
const taskCreatedAt = ref('')
const taskDuration = ref(0)
const taskErrorMessage = ref('')
const taskOutputResult = ref('')
const loading = ref(false)
const pageVisible = ref(true) // 控制video是否挂载页面隐藏时卸载释放原生video名额
let pollTimer = null
onHide(() => {
pageVisible.value = false
})
onShow(() => {
pageVisible.value = true
})
onLoad((options) => {
console.log('=== AI任务页面加载 ===')
console.log('页面参数:', options)
// 处理分享码逻辑
const shareResult = handleShareCode(options)
console.log('AI任务页面分享码处理结果:', shareResult)
taskNo.value = options.taskNo
loadTask()
})
const goBack = () => {
uni.navigateBack()
}
const statusClass = computed(() => {
switch (taskStatus.value) {
case 0: return 'queued'
case 1: return 'processing'
case 2: return 'success'
case 3: return 'failed'
default: return 'cancelled'
}
})
const statusText = computed(() => {
switch (taskStatus.value) {
case 0: return '排队中'
case 1: return '处理中'
case 2: return '已完成'
case 3: return '失败'
case 4: return '已取消'
default: return '未知'
}
})
const resultData = computed(() => {
if (!taskOutputResult.value) return null
try {
return JSON.parse(taskOutputResult.value)
} catch (e) {
return { result: taskOutputResult.value }
}
})
const isImageUrl = (url) => {
if (!url) return false
return /\.(jpg|jpeg|png|gif|webp)(\?.*)?$/i.test(url)
}
const isVideoUrl = (url) => {
if (!url) return false
return /\.(mp4|webm|mov)(\?.*)?$/i.test(url)
}
const formatDuration = (ms) => {
if (ms < 1000) return `${ms}ms`
const seconds = Math.floor(ms / 1000)
if (seconds < 60) return `${seconds}`
const minutes = Math.floor(seconds / 60)
const remainSeconds = seconds % 60
return `${minutes}${remainSeconds}`
}
const previewImage = (url) => {
uni.previewImage({ urls: [url] })
}
// 更新任务数据,只更新变化的字段
const updateTaskData = (res) => {
if (res.id) taskId.value = res.id
if (res.status !== undefined) taskStatus.value = res.status
if (res.modelName) taskModelName.value = res.modelName
if (res.pointsCost !== undefined) taskPointsCost.value = res.pointsCost
if (res.createdAt) taskCreatedAt.value = res.createdAt
if (res.duration !== undefined) taskDuration.value = res.duration
if (res.errorMessage) taskErrorMessage.value = res.errorMessage
if (res.outputResult) taskOutputResult.value = res.outputResult
}
const loadTask = async () => {
if (!taskNo.value) return
loading.value = true
try {
const res = await getAiTaskByNo(taskNo.value)
updateTaskData(res)
taskLoaded.value = true
// 如果任务还在处理中,开启轮询
if (res.status === 0 || res.status === 1) {
startPolling()
}
} catch (e) {
console.error('加载任务详情失败', e)
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const startPolling = () => {
if (pollTimer) return
// 每10秒轮询一次降低内存与请求压力
pollTimer = setInterval(async () => {
try {
const res = await getAiTaskByNo(taskNo.value)
updateTaskData(res)
// 任务完成或失败,停止轮询并提示
if (res.status >= 2) {
stopPolling()
if (res.status === 2) {
uni.showToast({ title: '任务已完成', icon: 'success' })
}
}
} catch (e) {
console.error('轮询任务状态失败', e)
}
}, 10000)
}
const stopPolling = () => {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
onMounted(() => {
// 页面加载完成
})
onUnmounted(() => {
stopPolling()
})
// 退出页面时销毁轮询定时器,防止内存泄漏
onUnload(() => {
stopPolling()
})
</script>
<style scoped>
.page-container {
min-height: 100vh;
background-color: #09090b;
display: flex;
flex-direction: column;
}
.nav-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background-color: #09090b;
}
.nav-back {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.back-icon {
width: 24px;
height: 24px;
}
.nav-title {
color: #ffffff;
font-size: 18px;
font-weight: 600;
}
.nav-placeholder {
width: 40px;
}
.content-scroll {
flex: 1;
padding: 16px;
}
.status-section {
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 0;
}
/* Loading动画 - 简洁旋转圆圈 */
.loading-animation {
width: 80px;
height: 80px;
margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.spinner {
width: 48px;
height: 48px;
border: 3px solid rgba(99, 102, 241, 0.2);
border-top-color: #6366f1;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.status-icon {
width: 64px;
height: 64px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
margin-bottom: 16px;
}
.status-icon.queued {
background: rgba(251, 191, 36, 0.2);
}
.status-icon.processing {
background: rgba(59, 130, 246, 0.2);
}
.status-icon.success {
background: rgba(34, 197, 94, 0.2);
}
.status-icon.failed {
background: rgba(239, 68, 68, 0.2);
}
.status-text {
color: #ffffff;
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
}
.leave-tip {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 16px;
padding: 16px;
background: rgba(99, 102, 241, 0.1);
border-radius: 12px;
border: 1px solid rgba(99, 102, 241, 0.2);
}
.tip-text {
color: #a5b4fc;
font-size: 14px;
text-align: center;
line-height: 1.5;
}
.tip-sub {
color: #71717a;
font-size: 12px;
margin-top: 8px;
}
.result-section,
.error-section,
.info-section {
background: #18181b;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
}
.section-title {
color: #ffffff;
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
}
.result-image image {
width: 100%;
border-radius: 8px;
}
.result-video video {
width: 100%;
border-radius: 8px;
}
.result-text {
color: #e4e4e7;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
}
.error-content {
color: #ef4444;
font-size: 14px;
line-height: 1.5;
}
.info-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.info-label {
color: #71717a;
font-size: 14px;
}
.info-value {
color: #e4e4e7;
font-size: 14px;
}
.loading-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.loading-text {
color: #71717a;
font-size: 14px;
}
</style>

1020
src/pages/assets/index.vue Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,809 @@
<template>
<view class="page-container">
<!-- 顶部导航栏 -->
<view class="custom-navbar">
<view class="navbar-content">
<view class="back-btn" @tap="handleBack">
<image src="/static/icons/Left (左).png" class="back-icon" mode="aspectFit" />
</view>
</view>
</view>
<!-- 进度条 -->
<view class="progress-bar-section">
<ProgressSteps
:current-step="currentStep"
:completed-step="completedStep"
@step-click="handleStepClick"
/>
</view>
<!-- 角色设计标题栏 -->
<view class="section-header">
<text class="section-title">角色设计</text>
<view class="header-actions">
<view class="action-btn" @tap="handleAddCharacter">
<text class="action-btn-text">新增角色</text>
</view>
<view class="action-btn" @tap="handleBatchGenerate">
<image src="/static/icons/piliangshengcheng.png" class="action-icon" mode="aspectFit" />
<text class="action-btn-text">批量生成形象</text>
</view>
</view>
</view>
<!-- 角色列表 -->
<scroll-view class="main-content" scroll-y>
<view class="character-grid">
<view
v-for="(character, index) in characters"
:key="index"
class="character-card"
@tap="handleEditCharacter(index)"
>
<!-- 角色图片区域 -->
<view
class="character-image"
:style="character.image ? { backgroundImage: 'url(' + character.image + ')' } : {}"
@tap.stop="character.image && handlePreviewImage(character.image)"
>
<!-- 加载状态优先显示后端状态其次显示本地状态 -->
<view v-if="character.imageStatus === 1 || isCharacterGenerating(index)" class="loading-overlay">
<view class="loading-spinner"></view>
<text class="loading-text">生成中...</text>
</view>
<!-- 配置形象按钮 -->
<view v-else-if="!character.image" class="config-btn" @tap.stop="handleConfigImage(index)">
<image src="/static/icons/ai-generate-3d-line.png" class="config-icon" mode="aspectFit" />
<text class="config-text">配置形象</text>
</view>
</view>
<!-- 角色信息 -->
<view class="character-info">
<view class="info-header">
<text class="character-name">{{ character.name }}</text>
<view class="voice-tag">
<image src="/static/icons/voice-ai-line.png" class="voice-icon" mode="aspectFit" />
<text class="voice-text">{{ character.voice }}</text>
</view>
</view>
<text class="character-desc">{{ character.description }}</text>
</view>
</view>
</view>
</scroll-view>
<!-- 底部操作区域 -->
<view class="bottom-action-area" :style="bottomActionStyle">
<button class="prev-btn" @tap="handlePrev">
<text class="prev-btn-text">上一步</text>
</button>
<button class="next-btn" :class="{ disabled: !canProceed }" @tap="handleNext">
<text class="next-btn-text">下一步</text>
</button>
</view>
<!-- 底部TabBar -->
<TabBar />
</view>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { onLoad, onShow, onHide, onUnload } from '@dcloudio/uni-app'
import { getProjectCharacters, saveCharacter, deleteCharacter, getCharacterTemplates, generateCharacterImage, pollTaskUntilComplete } from '@/api/videoProject'
import { getUserInfo } from '@/api/user'
import { getAiModelByCode } from '@/api/ai'
import { useSafeArea } from '@/hooks/useSafeArea'
import TabBar from '@/components/TabBar/index.vue'
import ProgressSteps from '@/components/ProgressSteps/index.vue'
// 安全区域
const { safeAreaInsets } = useSafeArea()
// 底部按钮区域样式TabBar高度44px + 安全区域底部)
const bottomActionStyle = computed(() => ({
bottom: `${44 + safeAreaInsets.value.bottom}px`
}))
// 点击节流时间戳
let lastClickTime = 0
const CLICK_THROTTLE_MS = 500
// 点击节流检查
const checkClickThrottle = () => {
const now = Date.now()
if (now - lastClickTime < CLICK_THROTTLE_MS) {
return false
}
lastClickTime = now
return true
}
// 当前步骤(第三步)
const currentStep = ref(2)
const completedStep = ref(2) // 进入此页面时,前两步已完成
const projectId = ref(null)
const scriptResult = ref(null)
const loading = ref(false)
// 角色数据
const characters = ref([])
// 是否可以进入下一步
const canProceed = computed(() => {
return characters.value.length > 0
})
// 从上一页获取数据
onLoad((options) => {
if (options.projectId) {
projectId.value = Number(options.projectId)
}
})
onMounted(() => {
const eventChannel = uni.getOpenerEventChannel && uni.getOpenerEventChannel()
if (eventChannel) {
eventChannel.on('sendSettingsData', (data) => {
if (data.projectId) projectId.value = data.projectId
if (data.scriptResult) scriptResult.value = data.scriptResult
})
}
// 加载角色数据
if (projectId.value) {
loadCharacters()
}
})
// 加载角色列表
const loadCharacters = async () => {
loading.value = true
try {
const data = await getProjectCharacters(projectId.value)
characters.value = data.map(c => ({
id: c.id,
name: c.name,
age: c.age || '',
gender: c.gender || '',
voice: c.voiceType || '默认音色',
appearance: c.appearance || '',
clothing: c.clothing || '',
description: c.description || '',
image: c.imageUrl || '',
imageStatus: c.imageStatus || 0, // 0-无图片, 1-生成中, 2-已生成
currentTaskNo: c.currentTaskNo || ''
}))
// 检查是否有生成中的角色,启动轮询
checkAndStartPolling()
} catch (e) {
console.error('加载角色失败:', e)
} finally {
loading.value = false
}
}
// 轮询定时器
let pollingTimer = null
let charPollSignal = { aborted: false }
// 检查是否有生成中的角色,启动轮询
const checkAndStartPolling = () => {
const generatingChars = characters.value.filter(c => c.imageStatus === 1)
if (generatingChars.length > 0 && !pollingTimer) {
startPolling()
} else if (generatingChars.length === 0 && pollingTimer) {
stopPolling()
}
}
// 启动轮询
const startPolling = () => {
if (pollingTimer) return
pollingTimer = setInterval(async () => {
try {
const data = await getProjectCharacters(projectId.value)
characters.value = data.map(c => ({
id: c.id,
name: c.name,
age: c.age || '',
gender: c.gender || '',
voice: c.voiceType || '默认音色',
appearance: c.appearance || '',
clothing: c.clothing || '',
description: c.description || '',
image: c.imageUrl || '',
imageStatus: c.imageStatus || 0,
currentTaskNo: c.currentTaskNo || ''
}))
// 检查是否还有生成中的角色
const stillGenerating = characters.value.some(c => c.imageStatus === 1)
if (!stillGenerating) {
stopPolling()
}
} catch (e) {
console.error('轮询角色状态失败:', e)
}
}, 10000) // 每10秒轮询一次降低内存与请求压力
}
// 停止轮询
const stopPolling = () => {
if (pollingTimer) {
clearInterval(pollingTimer)
pollingTimer = null
}
}
// 页面显示时刷新数据并检查轮询
onShow(() => {
if (projectId.value) {
loadCharacters()
}
})
// 页面隐藏时停止轮询
onHide(() => {
stopPolling()
})
// 组件卸载时清理
onUnmounted(() => {
stopPolling()
})
// 页面卸载时清理资源,释放内存
onUnload(() => {
// 停止轮询
stopPolling()
charPollSignal.aborted = true
// 清空数据
characters.value = []
generatingIndexes.value = new Set()
// 重置状态
batchGenerating.value = false
batchTotal.value = 0
})
const handleStepClick = (index) => {
if (index === currentStep.value) return
if (!projectId.value) return
const routes = [
`/pages/create/video-create?projectId=${projectId.value}`,
`/pages/create/video-create-settings?projectId=${projectId.value}`,
`/pages/create/video-create-character?projectId=${projectId.value}`,
`/pages/create/video-create-storyboard?projectId=${projectId.value}`,
`/pages/create/video-create-storyboard-result?projectId=${projectId.value}`
]
if (routes[index]) {
uni.redirectTo({ url: routes[index] })
}
}
const handleBack = () => {
uni.navigateBack()
}
const handlePrev = () => {
uni.navigateBack()
}
const handleNext = () => {
// 点击节流保护
if (!checkClickThrottle()) {
return
}
if (!canProceed.value) return
// 跳转到下一步生成分镜页面使用redirectTo销毁当前页面释放内存
uni.redirectTo({
url: `/pages/create/video-create-storyboard?projectId=${projectId.value}`
})
}
const handleAddCharacter = () => {
uni.navigateTo({
url: `/pages/create/video-create-character-edit?projectId=${projectId.value}&mode=add`
})
}
// 生成状态 - 使用 Set 跟踪多个生成中的角色索引
const generatingIndexes = ref(new Set())
const batchGenerating = ref(false)
const batchTotal = ref(0)
// 计算待生成的角色数量
const pendingCharacterCount = computed(() => {
return characters.value.filter((c, index) =>
!c.image && !generatingIndexes.value.has(index) && c.imageStatus !== 1
).length
})
// 检查某个角色是否正在生成
const isCharacterGenerating = (index) => {
return generatingIndexes.value.has(index)
}
const handleBatchGenerate = async () => {
// 点击节流保护
if (!checkClickThrottle()) {
return
}
if (batchGenerating.value || pendingCharacterCount.value === 0) return
// 获取没有形象且不在生成中的角色及其索引
const noImageChars = characters.value
.map((c, idx) => ({ ...c, originalIndex: idx }))
.filter(c => !c.image && !generatingIndexes.value.has(c.originalIndex) && c.imageStatus !== 1)
if (noImageChars.length === 0) {
uni.showToast({ title: '所有角色都已有形象或正在生成中', icon: 'none' })
return
}
// 校验用户积分是否足够
try {
uni.showLoading({ title: '检查中...' })
// 并行获取用户积分和模型积分消耗
const [userInfo, modelInfo] = await Promise.all([
getUserInfo(),
getAiModelByCode('video-image-gen')
])
uni.hideLoading()
const userPoints = userInfo?.points || 0
const pointsCost = modelInfo?.pointsCost || 0
const totalCost = pointsCost * noImageChars.length
if (userPoints < totalCost) {
uni.showModal({
title: '积分不足',
content: `批量生成 ${noImageChars.length} 个角色形象需要 ${totalCost} 积分,您当前积分为 ${userPoints},请先充值`,
confirmText: '去充值',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/points/subscribe' })
}
}
})
return
}
} catch (e) {
uni.hideLoading()
console.error('检查积分失败:', e)
uni.showToast({ title: '检查积分失败,请重试', icon: 'none' })
return
}
batchGenerating.value = true
batchTotal.value = noImageChars.length
// 并行启动所有角色的生成(后端会控制并发)
const generatePromises = noImageChars.map(char => {
return handleGenerateImageSilent(char.originalIndex)
})
// 等待所有任务完成(不管成功失败)
await Promise.allSettled(generatePromises)
batchGenerating.value = false
batchTotal.value = 0
}
// 静默生成角色形象(不显示提示,用于批量生成)
const handleGenerateImageSilent = async (index) => {
const character = characters.value[index]
if (!character || !character.id) return
// 检查是否已在生成中
if (generatingIndexes.value.has(index) || character.imageStatus === 1) {
return
}
// 标记开始生成
generatingIndexes.value.add(index)
generatingIndexes.value = new Set(generatingIndexes.value)
try {
// 提交异步任务
const taskNo = await generateCharacterImage(projectId.value, character.id)
// 更新本地状态
character.currentTaskNo = taskNo
character.imageStatus = 1
// 轮询等待任务完成
await pollTaskUntilComplete(taskNo, { interval: 10000, signal: charPollSignal })
// 重新加载角色数据获取最新图片
await loadCharacters()
} catch (e) {
if (e?.message === '轮询已取消') return
console.error(`角色 ${character.name} 生成失败:`, e)
} finally {
generatingIndexes.value.delete(index)
generatingIndexes.value = new Set(generatingIndexes.value)
}
}
const handleConfigImage = async (index) => {
const character = characters.value[index]
if (!character || !character.id) return
// 只检查当前角色是否正在生成(不阻止其他角色)
if (generatingIndexes.value.has(index) || character.imageStatus === 1) {
uni.showToast({ title: '该角色正在生成中', icon: 'none' })
return
}
// 校验用户积分
try {
uni.showLoading({ title: '检查中...' })
const [userInfo, modelInfo] = await Promise.all([
getUserInfo(),
getAiModelByCode('video-image-gen')
])
uni.hideLoading()
const userPoints = userInfo?.points || 0
const pointsCost = modelInfo?.pointsCost || 0
if (userPoints < pointsCost) {
uni.showModal({
title: '积分不足',
content: `生成角色形象需要 ${pointsCost} 积分,您当前积分为 ${userPoints},请先充值`,
confirmText: '去充值',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/points/subscribe' })
}
}
})
return
}
} catch (e) {
uni.hideLoading()
console.error('检查积分失败:', e)
uni.showToast({ title: '检查积分失败,请重试', icon: 'none' })
return
}
// 标记开始生成
generatingIndexes.value.add(index)
generatingIndexes.value = new Set(generatingIndexes.value)
try {
// 提交异步任务
const taskNo = await generateCharacterImage(projectId.value, character.id)
// 更新本地状态
character.currentTaskNo = taskNo
character.imageStatus = 1
// 轮询等待任务完成
await pollTaskUntilComplete(taskNo, { interval: 10000 })
// 重新加载角色数据获取最新图片
await loadCharacters()
uni.showToast({ title: '生成成功', icon: 'success' })
} catch (e) {
if (e?.message === '轮询已取消') return
const errMsg = e?.data?.message || e?.message || '生成失败'
uni.showToast({ title: errMsg, icon: 'none' })
} finally {
generatingIndexes.value.delete(index)
generatingIndexes.value = new Set(generatingIndexes.value)
}
}
const handlePreviewImage = (imageUrl) => {
if (!imageUrl) return
uni.previewImage({
current: imageUrl,
urls: [imageUrl]
})
}
const handleEditCharacter = (index) => {
const character = characters.value[index]
uni.navigateTo({
url: `/pages/create/video-create-character-edit?projectId=${projectId.value}&characterId=${character.id}&mode=edit`,
success: (res) => {
res.eventChannel.emit('sendCharacterData', {
id: character.id,
name: character.name,
age: character.age,
gender: character.gender,
voice: character.voice,
appearance: character.appearance,
clothing: character.clothing,
description: character.description,
image: character.image
})
}
})
}
</script>
<style scoped>
.page-container {
min-height: 100vh;
background: #09090b;
display: flex;
flex-direction: column;
overflow: hidden;
box-sizing: border-box;
}
/* 导航栏 */
.custom-navbar {
padding-top: 44px;
background: transparent;
flex-shrink: 0;
}
.navbar-content {
height: 54px;
display: flex;
align-items: center;
padding: 0 16px;
}
.back-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
}
.back-icon {
width: 24px;
height: 24px;
}
/* 进度条 */
.progress-bar-section {
padding: 0 10px;
margin-bottom: 16px;
flex-shrink: 0;
}
/* 角色设计标题栏 */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 10px;
margin-bottom: 10px;
flex-shrink: 0;
}
.section-title {
color: #a1a1aa;
font-size: 16px;
}
.header-actions {
display: flex;
gap: 6px;
}
.action-btn {
display: flex;
align-items: center;
gap: 4px;
background: #27272a;
border-radius: 10px;
padding: 6px 14px;
}
.action-icon {
width: 13px;
height: 13px;
}
.action-btn-text {
color: #d4d4d8;
font-size: 12px;
font-weight: 500;
}
/* 主要内容 */
.main-content {
flex: 1;
padding: 0 10px;
padding-bottom: 160px; /* 底部按钮区(66px)+TabBar(44px)+安全区域(~34px)+余量 */
box-sizing: border-box;
}
/* 角色网格 */
.character-grid {
display: flex;
flex-wrap: wrap;
gap: 9px;
}
/* 角色卡片 */
.character-card {
width: calc(50% - 4.5px);
background: #27272a;
border-radius: 10px;
padding: 10px;
box-sizing: border-box;
}
/* 角色图片 */
.character-image {
width: 100%;
height: 148px;
background: #333337;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background-size: cover;
background-position: top center; /* 显示图片顶部,展示人物上半身 */
}
.config-btn {
display: flex;
align-items: center;
gap: 4px;
}
.config-icon {
width: 16px;
height: 16px;
}
.config-text {
color: #3ed0f5;
font-size: 12px;
}
/* 加载状态 */
.loading-overlay {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
}
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid rgba(62, 208, 245, 0.3);
border-top-color: #3ed0f5;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
color: #3ed0f5;
font-size: 12px;
}
/* 角色信息 */
.character-info {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.info-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.character-name {
color: #f4f4f5;
font-size: 14px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 80px;
}
.voice-tag {
display: flex;
align-items: center;
gap: 4px;
padding: 0 6px;
border: 0.5px solid #a1a1aa;
border-radius: 35px;
max-width: 100px;
flex-shrink: 0;
}
.voice-icon {
width: 10px;
height: 10px;
flex-shrink: 0;
}
.voice-text {
color: #a1a1aa;
font-size: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.character-desc {
color: #71717a;
font-size: 11px;
line-height: 1.36;
display: -webkit-box;
-webkit-line-clamp: 4;
line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 底部操作区域 */
.bottom-action-area {
position: fixed;
left: 0;
right: 0;
bottom: 78px; /* 默认值,实际由 :style 动态覆盖 */
display: flex;
gap: 10px;
padding: 10px 16px;
background: #09090b;
box-sizing: border-box;
z-index: 100;
}
.prev-btn {
width: 118px;
height: 46px;
background: #fafafa;
border-radius: 10px;
border: none;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.prev-btn-text {
color: #09090b;
font-size: 16px;
}
.next-btn {
flex: 1;
min-width: 0;
height: 46px;
background: #3ed0f5;
border-radius: 10px;
border: none;
display: flex;
align-items: center;
justify-content: center;
}
.next-btn.disabled {
background: rgba(62, 208, 245, 0.5);
}
.next-btn-text {
color: #09090b;
font-size: 16px;
}
</style>

View File

@@ -0,0 +1,621 @@
<template>
<view class="page-container">
<!-- 顶部导航栏 -->
<view class="custom-navbar">
<view class="navbar-content">
<view class="nav-left" @tap="handleBack">
<image src="/static/icons/Left (左).png" class="back-icon" mode="aspectFit" />
</view>
</view>
</view>
<!-- 进度条 -->
<view class="progress-bar-section">
<ProgressSteps
:current-step="currentStep"
:completed-step="completedStep"
@step-click="handleStepClick"
/>
</view>
<!-- 主要内容区域 -->
<scroll-view class="main-content" scroll-y>
<!-- 项目名称 -->
<view class="form-section">
<text class="section-label">项目名称</text>
<view class="input-card">
<input
v-model="projectName"
class="text-input"
placeholder="请输入项目名称"
placeholder-class="input-placeholder"
/>
</view>
</view>
<!-- 故事大纲 -->
<view class="form-section">
<text class="section-label">故事大纲</text>
<view class="input-card">
<text class="outline-title">{{ storyTitle }}</text>
<text class="outline-content">{{ storyOutline }}</text>
</view>
</view>
<!-- 视频创作模式 -->
<view class="form-section">
<text class="section-label">视频创作模式</text>
<view class="option-row mode-row">
<view
v-for="(mode, index) in creationModes"
:key="index"
class="mode-card"
:class="{ active: selectedMode === index }"
@tap="selectedMode = index"
>
<text class="mode-title" :class="{ active: selectedMode === index }">{{ mode.title }}</text>
<text class="mode-desc" :class="{ active: selectedMode === index }">{{ mode.desc }}</text>
</view>
</view>
</view>
<!-- 视频时长 -->
<view class="form-section">
<text class="section-label">视频时长</text>
<view class="option-row two-col">
<view
v-for="(duration, index) in durationOptions"
:key="index"
class="option-btn"
:class="{ active: selectedDuration === index }"
@tap="selectedDuration = index"
>
<text class="option-text" :class="{ active: selectedDuration === index }">{{ duration.text }}</text>
</view>
</view>
</view>
<!-- 视频比例 -->
<view class="form-section">
<text class="section-label">视频比例</text>
<view class="option-row two-col">
<view
v-for="(ratio, index) in ratioOptions"
:key="index"
class="option-btn ratio-btn"
:class="{ active: selectedRatio === index }"
@tap="selectedRatio = index"
>
<image :src="ratio.icon" class="ratio-icon" mode="aspectFit" />
<text class="option-text" :class="{ active: selectedRatio === index }">{{ ratio.text }}</text>
<text class="ratio-desc" :class="{ active: selectedRatio === index }">{{ ratio.desc }}</text>
</view>
</view>
</view>
<!-- 视频风格 -->
<view class="form-section">
<text class="section-label">视频风格</text>
<view class="option-row three-col">
<view
v-for="(style, index) in styleOptions"
:key="index"
class="option-btn"
:class="{ active: selectedStyle === index, 'is-label': style.isLabel }"
@tap="!style.isLabel && (selectedStyle = index)"
>
<text class="option-text" :class="{ active: selectedStyle === index, 'label-text': style.isLabel }">{{ style.text }}</text>
</view>
</view>
</view>
</scroll-view>
<!-- 底部操作区域 -->
<view class="bottom-action-area" :style="bottomActionStyle">
<button class="prev-btn" @tap="handlePrev">
<text class="prev-btn-text">上一步</text>
</button>
<button class="next-btn" @tap="handleNext">
<text class="next-btn-text">下一步</text>
</button>
</view>
<!-- 底部TabBar -->
<TabBar />
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import { updateProjectSettings, getProject } from '@/api/videoProject'
import { useSafeArea } from '@/hooks/useSafeArea'
import TabBar from '@/components/TabBar/index.vue'
import ProgressSteps from '@/components/ProgressSteps/index.vue'
// 安全区域
const { safeAreaInsets } = useSafeArea()
// 底部按钮区域样式TabBar高度44px + 安全区域底部)
const bottomActionStyle = computed(() => ({
bottom: `${44 + safeAreaInsets.value.bottom}px`
}))
// 点击节流时间戳
let lastClickTime = 0
const CLICK_THROTTLE_MS = 500
// 点击节流检查
const checkClickThrottle = () => {
const now = Date.now()
if (now - lastClickTime < CLICK_THROTTLE_MS) {
return false
}
lastClickTime = now
return true
}
// 当前步骤(第二步)
const currentStep = ref(1)
const completedStep = ref(1) // 进入此页面时,第一步已完成
const projectId = ref(null)
const scriptResult = ref(null)
const loading = ref(false)
// 表单数据
const projectName = ref('')
const storyTitle = ref('')
const storyOutline = ref('')
// 视频创作模式
const creationModes = [
{ title: '剧情演绎', desc: '以剧情演绎为主,\n含角色出镜与对白' },
{ title: '旁白解说', desc: '以旁白叙述为主,\n少量角色对话' },
{ title: '口播讲解', desc: '以口播形式呈现,\n无剧情演绎' }
]
const selectedMode = ref(0)
// 视频时长选项
const durationOptions = [
{ text: '>1min', isLabel: false },
{ text: '<1min', isLabel: false }
]
const selectedDuration = ref(0)
// 视频比例选项腾讯云VOD Sora2支持16:9、9:16、1:1
const ratioOptions = [
{ text: '9:16', icon: '/static/icons/9,16.png', desc: '竖屏' },
{ text: '16:9', icon: '/static/icons/16,9.png', desc: '横屏' },
{ text: '1:1', icon: '/static/icons/1,1.png', desc: '正方形' }
]
const selectedRatio = ref(0)
// 视频风格选项
const styleOptions = [
{ text: '动漫风', isLabel: false },
{ text: '写实风', isLabel: false },
{ text: '3D动画', isLabel: false }
]
const selectedStyle = ref(0)
// 从上一页获取数据
onLoad((options) => {
if (options.projectId) {
projectId.value = Number(options.projectId)
}
})
// 从后端加载项目数据
const loadProjectData = async () => {
if (!projectId.value) return
loading.value = true
try {
const data = await getProject(projectId.value)
if (data) {
projectName.value = data.projectName || ''
storyTitle.value = data.storyTitle || ''
storyOutline.value = data.storyOutline || ''
// 恢复选项状态
if (data.creationMode) {
const modeIndex = creationModes.findIndex(m => m.title === data.creationMode)
if (modeIndex >= 0) selectedMode.value = modeIndex
}
if (data.videoDuration) {
const durationIndex = durationOptions.findIndex(d => d.text === data.videoDuration)
if (durationIndex >= 0) selectedDuration.value = durationIndex
}
if (data.videoRatio) {
const ratioIndex = ratioOptions.findIndex(r => r.text === data.videoRatio)
if (ratioIndex >= 0) selectedRatio.value = ratioIndex
}
if (data.videoStyle) {
const styleIndex = styleOptions.findIndex(s => s.text === data.videoStyle)
if (styleIndex >= 0) selectedStyle.value = styleIndex
}
}
} catch (e) {
console.error('加载项目数据失败:', e)
} finally {
loading.value = false
}
}
onMounted(() => {
// 尝试从eventChannel获取数据
const eventChannel = uni.getOpenerEventChannel && uni.getOpenerEventChannel()
if (eventChannel) {
try {
eventChannel.on('sendScriptData', (data) => {
if (data.projectId) projectId.value = data.projectId
if (data.title) {
storyTitle.value = data.title
projectName.value = data.title
}
if (data.content) storyOutline.value = data.content.substring(0, 100) + '...'
if (data.scriptResult) scriptResult.value = data.scriptResult
})
} catch (e) {
console.log('无eventChannel数据')
}
}
// 始终从后端加载最新数据
if (projectId.value) {
loadProjectData()
}
})
const handleStepClick = (index) => {
if (index === currentStep.value) return
if (!projectId.value) return
const routes = [
`/pages/create/video-create?projectId=${projectId.value}`,
`/pages/create/video-create-settings?projectId=${projectId.value}`,
`/pages/create/video-create-character?projectId=${projectId.value}`,
`/pages/create/video-create-storyboard?projectId=${projectId.value}`,
`/pages/create/video-create-storyboard-result?projectId=${projectId.value}`
]
if (routes[index]) {
uni.redirectTo({ url: routes[index] })
}
}
const handleBack = () => {
uni.navigateBack()
}
const handlePrev = () => {
uni.navigateBack()
}
const handleNext = async () => {
// 点击节流保护
if (!checkClickThrottle()) {
return
}
// 保存项目设置
try {
uni.showLoading({ title: '保存中...' })
await updateProjectSettings(projectId.value, {
projectName: projectName.value,
creationMode: selectedMode.value,
videoDuration: durationOptions[selectedDuration.value]?.text || '>1min',
videoRatio: ratioOptions[selectedRatio.value]?.text || '9:16',
videoStyle: styleOptions[selectedStyle.value]?.text || '写实风'
})
uni.hideLoading()
// 跳转到创建角色页面使用redirectTo销毁当前页面释放内存
uni.redirectTo({
url: `/pages/create/video-create-character?projectId=${projectId.value}`
})
} catch (e) {
uni.hideLoading()
uni.showToast({ title: e.message || '保存失败', icon: 'none' })
}
}
// 页面卸载时清理资源,释放内存
onUnload(() => {
// 清空数据
projectName.value = ''
storyTitle.value = ''
storyOutline.value = ''
scriptResult.value = null
// 重置选项
selectedMode.value = 0
selectedDuration.value = 0
selectedRatio.value = 0
selectedStyle.value = 0
})
</script>
<style scoped>
.page-container {
min-height: 100vh;
background: #09090b;
display: flex;
flex-direction: column;
overflow: hidden;
box-sizing: border-box;
}
/* 导航栏 */
.custom-navbar {
padding-top: 44px;
background: transparent;
flex-shrink: 0;
}
.navbar-content {
height: 54px;
display: flex;
align-items: center;
padding: 0 16px;
}
.nav-left {
display: flex;
align-items: center;
gap: 4px;
}
.back-icon {
width: 24px;
height: 24px;
}
.back-text {
color: #fafafa;
font-size: 16px;
}
/* 进度条 */
.progress-bar-section {
padding: 0 10px;
margin-bottom: 16px;
flex-shrink: 0;
}
/* 主要内容 */
.main-content {
flex: 1;
padding: 0 10px;
padding-bottom: 160px; /* 底部按钮区(66px)+TabBar(44px)+安全区域(~34px)+余量 */
box-sizing: border-box;
}
/* 表单区块 */
.form-section {
margin-bottom: 16px;
}
.section-label {
color: #a1a1aa;
font-size: 16px;
margin-bottom: 10px;
display: block;
}
/* 输入卡片 */
.input-card {
background: #27272a;
border-radius: 10px;
padding: 16px;
box-sizing: border-box;
}
.text-input {
width: 100%;
color: #fafafa;
font-size: 14px;
background: transparent;
}
.input-placeholder {
color: #71717a;
}
/* 故事大纲 */
.outline-title {
color: #fafafa;
font-size: 16px;
font-weight: 500;
margin-bottom: 8px;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.outline-content {
color: #a1a1aa;
font-size: 14px;
line-height: 1.5;
max-height: 60px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
word-break: break-all;
}
/* 选项行 */
.option-row {
display: flex;
gap: 10px;
width: 100%;
box-sizing: border-box;
}
.option-row.three-col {
flex-wrap: nowrap;
}
.option-row.two-col {
flex-wrap: nowrap;
}
.option-row.mode-row {
flex-wrap: nowrap;
}
/* 模式卡片 */
.mode-card {
flex: 1;
min-width: 0;
height: 100px;
background: #27272a;
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
border: 1px solid transparent;
padding: 12px 8px;
box-sizing: border-box;
}
.mode-card.active {
background: #02151a;
border-color: #3ed0f5;
}
.mode-title {
color: #a1a1aa;
font-size: 14px;
font-weight: 500;
text-align: center;
}
.mode-title.active {
color: #e4e4e7;
}
.mode-desc {
color: #71717a;
font-size: 11px;
line-height: 1.36;
text-align: center;
white-space: pre-line;
}
.mode-desc.active {
color: #a1a1aa;
}
/* 选项按钮 */
.option-btn {
flex: 1;
min-width: 0;
height: 46px;
background: #27272a;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
box-sizing: border-box;
}
.option-btn.active {
background: #02151a;
border-color: #3ed0f5;
}
.option-btn.is-label {
background: #27272a;
border-color: transparent;
}
.option-text {
color: #a1a1aa;
font-size: 14px;
font-weight: 500;
}
.option-text.active {
color: #fafafa;
}
.option-text.label-text {
color: #a1a1aa;
}
/* 比例按钮 */
.ratio-btn {
flex-direction: column;
gap: 6px;
height: auto;
padding: 10px;
}
.ratio-icon {
width: 24px;
height: 24px;
}
.ratio-desc {
color: #71717a;
font-size: 11px;
margin-top: 2px;
}
.ratio-desc.active {
color: #a1a1aa;
}
/* 底部操作区域 */
.bottom-action-area {
position: fixed;
left: 0;
right: 0;
bottom: 78px; /* 默认值,实际由 :style 动态覆盖 */
display: flex;
gap: 10px;
padding: 10px 16px;
background: #09090b;
box-sizing: border-box;
z-index: 100;
}
.prev-btn {
width: 118px;
height: 46px;
background: #fafafa;
border-radius: 10px;
border: none;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.prev-btn-text {
color: #09090b;
font-size: 16px;
}
.next-btn {
flex: 1;
min-width: 0;
height: 46px;
background: #3ed0f5;
border-radius: 10px;
border: none;
display: flex;
align-items: center;
justify-content: center;
}
.next-btn-text {
color: #09090b;
font-size: 16px;
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

620
src/pages/create/video.vue Normal file
View File

@@ -0,0 +1,620 @@
<template>
<view class="create-video-page">
<!-- 顶部导航栏 -->
<view class="navbar" :style="{ paddingTop: safeAreaTop + 'px' }">
<view class="navbar-content">
<view class="nav-title-wrap" @tap="togglePagePicker">
<text class="navbar-title">{{ currentPageTitle }}</text>
<image src="/static/icons/arrow-down-s-line.png" class="arrow-icon" :class="{ 'arrow-up': showPicker }" mode="aspectFit" />
</view>
<!-- 下拉选择器 -->
<view v-if="showPicker" class="page-picker-dropdown">
<view class="picker-content">
<view class="picker-options">
<view
v-for="item in pageOptions"
:key="item.value"
class="picker-option"
:class="{ 'picker-option-active': currentPageType === item.value }"
@tap.stop="selectPage(item)"
>
<text class="picker-option-text">{{ item.label }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 主要内容区域 -->
<scroll-view
class="main-content"
scroll-y
@scrolltolower="loadMoreProjects"
:lower-threshold="100"
>
<!-- 新建项目卡片 -->
<view class="new-project-card" @tap="handleCreateProject">
<view class="new-project-content">
<view class="new-project-icon-wrap">
<image src="/static/icons/video-on-ai-fill.png" class="new-project-icon" mode="aspectFit" />
</view>
<text class="new-project-text">新建项目</text>
</view>
</view>
<!-- 项目列表区域 -->
<view class="project-section">
<!-- 项目列表 -->
<view class="project-grid">
<view
v-for="project in recentProjects"
:key="project.id"
class="project-card"
@tap="handleProjectClick(project)"
>
<!-- 封面图 -->
<view class="project-cover">
<image
v-if="project.coverUrl"
:src="project.coverUrl"
class="cover-image"
mode="aspectFill"
/>
<view v-else class="cover-placeholder">
<image src="/static/icons/1818AIGC.png" class="placeholder-logo" mode="aspectFit" />
</view>
<!-- 任务状态标签 -->
<!-- <view v-if="project.status !== 2" class="status-badge" :class="getStatusClass(project.status)">
<text class="status-text">{{ getStatusText(project.status) }}</text>
</view> -->
</view>
<!-- 项目信息 -->
<view class="project-info">
<text class="project-name">{{ project.name || '未命名项目' }}</text>
<text class="project-date">{{ formatDate(project.createTime) }}</text>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-if="recentProjects.length === 0 && !loading" class="empty-state">
<text class="empty-text">暂无项目点击上方创建新项目</text>
</view>
<!-- 加载中 -->
<view v-if="loading" class="loading-state">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
<!-- 加载更多 -->
<view v-if="loadingMore" class="loading-more">
<view class="loading-spinner-small"></view>
<text class="loading-more-text">加载更多...</text>
</view>
<!-- 没有更多 -->
<view v-if="!hasMore && recentProjects.length > 0 && !loading" class="no-more">
<text class="no-more-text">没有更多了</text>
</view>
</view>
</scroll-view>
<!-- 底部导航栏 -->
<TabBar :current="1" />
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { useUserStore } from '@/store/modules/user'
import { getProjectList, createProject, deleteProject } from '@/api/videoProject'
import TabBar from '@/components/TabBar/index.vue'
const userStore = useUserStore()
// 点击节流时间戳
let lastClickTime = 0
const CLICK_THROTTLE_MS = 500
// 点击节流检查
const checkClickThrottle = () => {
const now = Date.now()
if (now - lastClickTime < CLICK_THROTTLE_MS) {
return false
}
lastClickTime = now
return true
}
const safeAreaTop = ref(0)
const loading = ref(false)
const loadingMore = ref(false)
const recentProjects = ref([])
const pageNum = ref(1)
const pageSize = ref(10)
const hasMore = ref(true)
const showPicker = ref(false)
const currentPageType = ref('video')
const pageOptions = ref([
{ label: '一键成片', value: 'video' },
{ label: '内容生产', value: 'content' }
])
const currentPageTitle = computed(() => {
const option = pageOptions.value.find(item => item.value === currentPageType.value)
return option ? option.label : '一键成片'
})
onLoad(() => {
const sysInfo = uni.getSystemInfoSync()
safeAreaTop.value = sysInfo.statusBarHeight || 0
})
onShow(() => {
// 重置分页状态并加载
pageNum.value = 1
hasMore.value = true
recentProjects.value = []
loadRecentProjects()
})
const loadRecentProjects = async () => {
if (!userStore.isLogin) {
recentProjects.value = []
return
}
loading.value = true
try {
const res = await getProjectList({ page: pageNum.value, size: pageSize.value })
const projects = (res.records || []).map(project => ({
id: project.id,
name: project.projectName || project.storyTitle || '未命名项目',
coverUrl: project.coverUrl,
status: project.status,
currentStep: project.currentStep,
createTime: project.createdAt
}))
recentProjects.value = projects
// 判断是否还有更多数据
const total = res.total || 0
hasMore.value = recentProjects.value.length < total
} catch (e) {
console.error('加载项目失败:', e)
recentProjects.value = []
} finally {
loading.value = false
}
}
// 加载更多项目(懒加载)
const loadMoreProjects = async () => {
if (!hasMore.value || loadingMore.value || loading.value) return
if (!userStore.isLogin) return
loadingMore.value = true
pageNum.value++
try {
const res = await getProjectList({ page: pageNum.value, size: pageSize.value })
const projects = (res.records || []).map(project => ({
id: project.id,
name: project.projectName || project.storyTitle || '未命名项目',
coverUrl: project.coverUrl,
status: project.status,
currentStep: project.currentStep,
createTime: project.createdAt
}))
// 追加到现有列表
recentProjects.value = [...recentProjects.value, ...projects]
// 判断是否还有更多数据
const total = res.total || 0
hasMore.value = recentProjects.value.length < total
} catch (e) {
console.error('加载更多项目失败:', e)
pageNum.value-- // 回退页码
} finally {
loadingMore.value = false
}
}
const togglePagePicker = () => {
showPicker.value = !showPicker.value
}
const selectPage = (item) => {
currentPageType.value = item.value
showPicker.value = false
if (item.value === 'content') {
uni.switchTab({ url: '/pages/dream/index' })
}
}
const closePicker = () => {
showPicker.value = false
}
const handleCreateProject = () => {
// 点击节流保护
if (!checkClickThrottle()) {
return
}
if (!userStore.isLogin) {
uni.showToast({ title: '请先登录', icon: 'none' })
setTimeout(() => uni.navigateTo({ url: '/pages/login/index' }), 500)
return
}
// 直接跳转到创建页面,不预先创建项目
// 项目会在用户开始输入时自动创建
uni.navigateTo({ url: '/pages/create/video-create' })
}
const handleProjectClick = (project) => {
// 点击节流保护
if (!checkClickThrottle()) {
return
}
// 根据项目状态和步骤跳转到对应页面
uni.navigateTo({ url: `/pages/create/video-create?projectId=${project.id}` })
}
const getStatusClass = (status) => {
switch (status) {
case 0: return 'status-pending'
case 1: return 'status-processing'
case 3: return 'status-failed'
default: return ''
}
}
const getStatusText = (status) => {
switch (status) {
case 0: return '等待中'
case 1: return '处理中'
case 3: return '失败'
default: return ''
}
}
const formatDate = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hour}:${minute}`
}
</script>
<style scoped>
.create-video-page {
min-height: 100vh;
background-color: #09090b;
display: flex;
flex-direction: column;
}
/* 导航栏 */
.navbar {
background-color: #09090b;
position: sticky;
top: 0;
z-index: 100;
}
.navbar-content {
height: 54px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
position: relative;
}
.nav-title-wrap {
display: flex;
align-items: center;
gap: 4px;
}
.navbar-title {
color: #f1f5f9;
font-size: 24px;
font-weight: 700;
}
.arrow-icon {
width: 20px;
height: 20px;
opacity: 0.8;
transition: transform 0.2s ease;
}
.arrow-icon.arrow-up {
transform: rotate(180deg);
}
/* 下拉选择器 */
.page-picker-dropdown {
position: absolute;
top: 100%;
left: 20px;
margin-top: 8px;
width: 120px;
background: rgba(63, 63, 70, 0.6);
backdrop-filter: saturate(180%) blur(14px);
-webkit-backdrop-filter: saturate(180%) blur(14px);
border-radius: 8px;
border: 1rpx solid rgba(255, 255, 255, 0.1);
z-index: 200;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
.picker-content {
padding: 8px 0;
}
.picker-options {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 5px;
}
.picker-option {
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.picker-option-active {
background: #3f3f46;
}
.picker-option-text {
font-size: 14px;
font-weight: 500;
color: #e4e4e7;
text-align: center;
line-height: 1.43;
}
/* 主要内容 */
.main-content {
flex: 1;
padding: 20px;
padding-bottom: 100px;
height: calc(100vh - 54px);
box-sizing: border-box;
}
/* 新建项目卡片 */
.new-project-card {
width: 100%;
height: 168px;
background: #18181b;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 32px;
}
.new-project-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.new-project-icon-wrap {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.new-project-icon {
width: 40px;
height: 40px;
opacity: 0.6;
}
.new-project-text {
color: #d4d4d8;
font-size: 14px;
}
/* 项目列表区域 */
.project-section {
margin-bottom: 20px;
}
/* 项目网格 */
.project-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 11px;
width: 100%;
}
.project-card {
width: 100%;
min-width: 0;
background: #18181b;
border-radius: 10px;
border: 1rpx solid #27272a;
padding: 10px;
box-sizing: border-box;
overflow: hidden;
}
.project-cover {
width: 100%;
height: 81px;
border-radius: 8px;
overflow: hidden;
position: relative;
margin-bottom: 8px;
}
.cover-image {
width: 100%;
height: 100%;
}
.cover-placeholder {
width: 100%;
height: 100%;
background: #27272a;
display: flex;
align-items: center;
justify-content: center;
}
.placeholder-logo {
width: 60px;
height: 20px;
opacity: 0.4;
}
/* 任务状态标签 */
.status-badge {
position: absolute;
top: 6px;
left: 6px;
padding: 2px 8px;
border-radius: 4px;
backdrop-filter: blur(4px);
}
.status-pending {
background: rgba(161, 161, 170, 0.8);
}
.status-processing {
background: rgba(59, 130, 246, 0.8);
}
.status-failed {
background: rgba(239, 68, 68, 0.8);
}
.status-text {
color: #fff;
font-size: 10px;
font-weight: 500;
}
.project-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.project-name {
color: #e4e4e7;
font-size: 12px;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.project-date {
color: #71717a;
font-size: 10px;
line-height: 1.6;
}
/* 空状态 */
.empty-state {
padding: 40px 20px;
text-align: center;
}
.empty-text {
color: #71717a;
font-size: 14px;
}
/* 加载状态 */
.loading-state {
padding: 40px 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid rgba(255, 255, 255, 0.2);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
color: #71717a;
font-size: 13px;
}
/* 加载更多 */
.loading-more {
padding: 20px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.loading-spinner-small {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.2);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-more-text {
color: #71717a;
font-size: 12px;
}
/* 没有更多 */
.no-more {
padding: 20px;
text-align: center;
}
.no-more-text {
color: #52525b;
font-size: 12px;
}
</style>

2015
src/pages/dream/create.vue Normal file

File diff suppressed because it is too large Load Diff

776
src/pages/dream/detail.vue Normal file
View File

@@ -0,0 +1,776 @@
<template>
<view class="detail-page">
<!-- 顶部导航栏 -->
<view class="nav-bar" :style="{ paddingTop: safeAreaTop + 'px' }">
<view class="nav-back" @tap="goBack">
<image src="/static/icons/Left (左).png" class="back-icon" mode="aspectFit" />
</view>
</view>
<!-- 内容区域 -->
<view class="content" v-if="taskLoaded">
<!-- 媒体展示区 -->
<view class="media-container">
<!-- 图片 -->
<image
v-if="isImage"
:src="outputUrl"
class="media-image"
mode="widthFix"
@tap="previewMedia"
/>
<!-- 视频仅在页面可见时挂载隐藏时卸载释放原生video名额 -->
<video
v-else-if="pageVisible"
:src="outputUrl"
class="media-video"
controls
object-fit="contain"
/>
<view v-else class="media-video" style="background-color: #000;"></view>
</view>
<!-- 上方工具栏在作品下方 -->
<view class="top-toolbar">
<view class="tool-btn" @tap="handleRegenerate">
<image src="/static/icons/refresh-line.png" class="tool-icon" mode="aspectFit" />
<text class="tool-text">再次编辑</text>
</view>
<view class="tool-btn" @tap="handleGenerateImage">
<image src="/static/icons/image-add-02.png" class="tool-icon" mode="aspectFit" />
<text class="tool-text">生成图片</text>
</view>
<!-- 只有图片才显示生成视频按钮 -->
<view v-if="isImage" class="tool-btn" @tap="handleGenerateVideo">
<image src="/static/icons/video-on-ai-fill.png" class="tool-icon" mode="aspectFit" />
<text class="tool-text">生成视频</text>
</view>
</view>
<!-- 下方工具栏带圆角 -->
<view class="bottom-toolbar">
<!-- #ifdef MP-WEIXIN -->
<button class="toolbar-btn more-btn" open-type="share" @click="handleShareClick">
<text class="more-dots">···</text>
</button>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<view class="toolbar-btn more-btn" @tap="showShareMenu">
<text class="more-dots">···</text>
</view>
<!-- #endif -->
<view class="toolbar-btn download-btn" @tap="handleDownload">
<image src="/static/icons/To-bottom.png" class="toolbar-icon" mode="aspectFit" />
<text class="toolbar-text">下载</text>
</view>
<view
v-if="!taskData || !taskData.publishStatus || taskData.publishStatus === 0 || taskData.publishStatus === 3"
class="toolbar-btn publish-btn"
@tap="handlePublish"
>
<image src="/static/icons/navigation-03.png" class="toolbar-icon white" mode="aspectFit" />
<text class="toolbar-text white">发布作品</text>
</view>
</view>
</view>
<!-- 加载中 -->
<view v-if="loading" class="loading-wrap">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
<!-- 分享菜单 -->
<u-action-sheet
:show="showShare"
:actions="shareActions"
@close="showShare = false"
@select="onShareSelect"
/>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { onLoad, onShow, onHide, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { useUserStore } from '@/store/modules/user'
import { getAiTaskByNo } from '@/api/ai'
import { smartGoBack, handleShareCode, generateSharePath, generateShareQuery } from '@/utils/navigation'
import { shareConfigs } from '@/mixins/shareMixin'
import { enableShareMenu } from '@/utils/shareMenu'
const userStore = useUserStore()
const safeAreaTop = ref(0)
const taskLoaded = ref(false)
const loading = ref(false)
const showShare = ref(false)
const pageVisible = ref(true) // 控制video是否挂载页面隐藏时卸载释放原生video名额
// 任务数据
const taskNo = ref('')
const taskData = ref(null)
// 分享选项
const shareActions = [
{ name: '分享到微信' },
{ name: '分享到朋友圈' },
{ name: '复制链接' }
]
onLoad((options) => {
// 启用分享菜单
// #ifdef MP-WEIXIN
uni.showShareMenu({
withShareTicket: true,
menus: ['shareAppMessage', 'shareTimeline']
})
// #endif
// 获取安全区域
const sysInfo = uni.getSystemInfoSync()
safeAreaTop.value = sysInfo.statusBarHeight || 0
taskNo.value = options.taskNo
console.log('=== 造梦详情页加载 ===')
console.log('onLoad 页面参数:', options)
console.log('taskNo:', taskNo.value)
// 处理分享码 - 使用 onLoad 参数确保保存到localStorage
const shareResult = handleShareCode(options)
console.log('handleShareCode 处理结果:', shareResult)
// 额外检查:使用 getCurrentPages 方法获取参数(与工作详情页保持一致)
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const shareCodeFromCurrentPage = currentPage.options?.shareCode
console.log('getCurrentPages 获取的 shareCode:', shareCodeFromCurrentPage)
if (shareCodeFromCurrentPage && !options.shareCode) {
console.log('onLoad 参数中没有 shareCode但 getCurrentPages 中有,使用 getCurrentPages 的值')
uni.setStorageSync('shareCode', shareCodeFromCurrentPage)
}
// 检查localStorage中的分享码
const existingShareCode = uni.getStorageSync('shareCode')
console.log('localStorage中的分享码:', existingShareCode)
loadTask()
})
// 页面显示时确保分享菜单可用
onShow(() => {
pageVisible.value = true
// 确保分享菜单始终可用
// #ifdef MP-WEIXIN
uni.showShareMenu({
withShareTicket: true,
menus: ['shareAppMessage', 'shareTimeline']
})
// #endif
})
// 页面隐藏时卸载video组件释放全局原生video名额
onHide(() => {
pageVisible.value = false
})
// 是否是图片
const isImage = computed(() => {
const code = taskData.value?.modelCode
return code === 'nanobanana-image' || code === 'seedream-4-5' || code === 'video-image-gen' || code === 'tencent-hunyuan-image3.0'
})
// 输出URL
const outputUrl = computed(() => {
if (!taskData.value?.outputResult) return ''
try {
const result = JSON.parse(taskData.value.outputResult)
return result.result || result.url || result.videoUrl || result.imageUrl || ''
} catch (e) {
return taskData.value.outputResult || ''
}
})
// 返回
const goBack = () => {
smartGoBack('/pages/dream/index')
}
// 加载任务详情
const loadTask = async () => {
if (!taskNo.value) {
console.error('taskNo为空无法加载任务详情')
uni.showToast({ title: '参数错误', icon: 'none' })
return
}
loading.value = true
try {
console.log('开始加载任务详情taskNo:', taskNo.value)
// 直接使用原有接口,现在支持公开访问已完成的任务
const res = await getAiTaskByNo(taskNo.value)
if (!res) {
throw new Error('任务不存在')
}
taskData.value = res
taskLoaded.value = true
console.log('任务详情加载成功:', res)
// 任务数据加载完成后,分享配置已经可以正确生成
console.log('=== 任务数据加载完成,分享配置准备就绪 ===')
console.log('taskNo:', taskNo.value)
console.log('taskData 已加载:', !!taskData.value)
// 手动测试分享配置生成
const testShareConfig = shareConfigs.dreamDetail(taskData.value, taskNo.value)
console.log('测试分享配置生成:', testShareConfig)
} catch (e) {
console.error('加载任务详情失败:', e)
// 根据错误类型显示不同的提示
let errorMsg = '加载失败'
if (e.message && e.message.includes('404')) {
errorMsg = '作品不存在或已被删除'
} else if (e.message && e.message.includes('403')) {
errorMsg = '无权限访问此作品'
} else if (e.message && e.message.includes('网络')) {
errorMsg = '网络连接失败,请检查网络'
} else if (e.message && e.message.includes('无权限')) {
errorMsg = '作品未完成或无权限访问'
}
uni.showToast({ title: errorMsg, icon: 'none' })
// 3秒后自动返回
setTimeout(() => {
goBack()
}, 3000)
} finally {
loading.value = false
}
}
// 预览媒体
const previewMedia = () => {
if (isImage.value && outputUrl.value) {
uni.previewImage({ urls: [outputUrl.value] })
}
}
// 显示分享菜单
const showShareMenu = () => {
showShare.value = true
}
// 显示更多菜单
const showMoreMenu = () => {
// 微信小程序分享功能
// #ifdef MP-WEIXIN
uni.showShareMenu({
withShareTicket: true,
menus: ['shareAppMessage', 'shareTimeline']
})
// #endif
// 非微信环境提示
// #ifndef MP-WEIXIN
uni.showToast({ title: '请在微信中打开', icon: 'none' })
// #endif
}
// 分享选择
const onShareSelect = (e) => {
const index = e.index
if (index === 0) {
// 分享到微信
uni.showToast({ title: '分享到微信', icon: 'none' })
} else if (index === 1) {
// 分享到朋友圈
uni.showToast({ title: '分享到朋友圈', icon: 'none' })
} else if (index === 2) {
// 复制链接
uni.setClipboardData({
data: outputUrl.value,
success: () => {
uni.showToast({ title: '链接已复制', icon: 'success' })
}
})
}
}
// 再次编辑
const handleRegenerate = () => {
if (!taskData.value) return
try {
const params = JSON.parse(taskData.value.inputParams)
const type = isImage.value ? 'image' : 'video'
// 解析参考图(兼容所有模型的参考图字段)
const referenceImages = []
// NanoBanana: img_url
if (params.img_url) {
if (Array.isArray(params.img_url)) {
referenceImages.push(...params.img_url)
} else {
referenceImages.push(params.img_url)
}
}
// Seedream: image
if (params.image && typeof params.image === 'string' && params.image.startsWith('http')) {
referenceImages.push(params.image)
}
// 腾讯云VOD: file_urls
if (params.file_urls && Array.isArray(params.file_urls)) {
referenceImages.push(...params.file_urls)
}
// Grok: image_urls
if (params.image_urls) {
if (Array.isArray(params.image_urls)) {
referenceImages.push(...params.image_urls)
} else if (typeof params.image_urls === 'string' && params.image_urls.startsWith('[')) {
try {
const parsed = JSON.parse(params.image_urls)
if (Array.isArray(parsed)) referenceImages.push(...parsed)
} catch (_) {}
} else if (typeof params.image_urls === 'string' && params.image_urls.startsWith('http')) {
referenceImages.push(params.image_urls)
}
}
// 旧版Sora2: url
if (params.url && typeof params.url === 'string' && params.url.startsWith('http')) {
referenceImages.push(params.url)
}
// 通用: images数组
if (params.images && Array.isArray(params.images)) {
referenceImages.push(...params.images)
}
// 构建跳转参数
let url = `/pages/dream/create?type=${type}&prompt=${encodeURIComponent(params.prompt || '')}`
// 如果有参考图,将参考图信息传递过去
if (referenceImages.length > 0) {
url += `&refImages=${encodeURIComponent(JSON.stringify(referenceImages))}`
}
uni.navigateTo({ url })
} catch (e) {
console.error('再次编辑参数解析失败:', e)
uni.showToast({ title: '参数解析失败', icon: 'none' })
}
}
// 生成图片
const handleGenerateImage = () => {
if (!taskData.value) {
uni.showToast({ title: '任务数据未加载', icon: 'none' })
return
}
if (!outputUrl.value) {
uni.showToast({ title: '无可用的参考图', icon: 'none' })
return
}
try {
const params = JSON.parse(taskData.value.inputParams)
// 构建参考图数组,将当前生成的结果作为参考图
const referenceImages = [outputUrl.value]
// 构建跳转参数
let url = `/pages/dream/create?type=image&prompt=${encodeURIComponent(params.prompt || '')}`
// 传递参考图
url += `&refImages=${encodeURIComponent(JSON.stringify(referenceImages))}`
uni.navigateTo({ url })
} catch (e) {
console.error('生成图片参数解析失败:', e)
uni.showToast({ title: '参数解析失败', icon: 'none' })
}
}
// 生成视频(仅图片显示)
const handleGenerateVideo = () => {
if (!taskData.value || !isImage.value) {
uni.showToast({ title: '仅支持图片生成视频', icon: 'none' })
return
}
if (!outputUrl.value) {
uni.showToast({ title: '无可用的参考图', icon: 'none' })
return
}
try {
// 构建参考图数组,将当前生成的图片作为参考图
const referenceImages = [outputUrl.value]
// 固定提示词:请根据参考图生成一段精彩的视频
const prompt = '请根据参考图生成一段精彩的视频'
// 构建跳转参数
let url = `/pages/dream/create?type=video&prompt=${encodeURIComponent(prompt)}`
// 传递参考图
url += `&refImages=${encodeURIComponent(JSON.stringify(referenceImages))}`
uni.navigateTo({ url })
} catch (e) {
console.error('生成视频参数解析失败:', e)
uni.showToast({ title: '参数解析失败', icon: 'none' })
}
}
// 下载
const handleDownload = () => {
if (!outputUrl.value) {
uni.showToast({ title: '无可下载内容', icon: 'none' })
return
}
uni.showLoading({ title: '下载中...', mask: true })
uni.downloadFile({
url: outputUrl.value,
success: (res) => {
if (res.statusCode === 200) {
uni.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: () => {
uni.showToast({ title: '保存成功', icon: 'success' })
},
fail: () => {
uni.showToast({ title: '保存失败', icon: 'none' })
}
})
}
},
fail: () => {
uni.showToast({ title: '下载失败', icon: 'none' })
},
complete: () => {
uni.hideLoading()
}
})
}
// 发布作品
const handlePublish = () => {
if (!taskData.value) return
if (taskData.value.status !== 2) {
uni.showToast({ title: '任务未完成', icon: 'none' })
return
}
uni.navigateTo({ url: `/pages/work/publish?taskId=${taskData.value.id}` })
}
// 处理分享按钮点击
const handleShareClick = () => {
console.log('=== 分享按钮被点击 ===')
console.log('当前用户登录状态:', userStore.isLogin)
console.log('当前用户信息:', userStore.userInfo)
console.log('当前用户邀请码:', userStore.userInfo?.inviteCode)
// 手动调用分享配置生成
if (taskData.value && taskNo.value) {
const shareConfig = shareConfigs.dreamDetail(taskData.value, taskNo.value)
console.log('手动生成分享配置:', shareConfig)
// 手动调用分享路径生成
const finalPath = generateSharePath(shareConfig.path, userStore)
console.log('手动生成最终分享路径:', finalPath)
} else {
console.log('任务数据未加载,无法生成分享配置')
}
}
// 原生分享给好友
onShareAppMessage(() => {
console.log('=== 原生分享给好友触发 ===')
if (!taskData.value || !taskNo.value) {
console.log('任务数据未加载,使用默认分享配置')
return {
title: 'AI创作神器 - 一键生成精美图片和视频',
path: '/pages/inspiration/index'
}
}
// 生成分享配置
const shareConfig = shareConfigs.dreamDetail(taskData.value, taskNo.value)
console.log('原生分享配置:', shareConfig)
// 生成最终分享路径
const finalPath = generateSharePath(shareConfig.path, userStore)
console.log('原生分享最终路径:', finalPath)
return {
title: shareConfig.title,
path: finalPath,
imageUrl: shareConfig.imageUrl
}
})
// 原生分享到朋友圈
onShareTimeline(() => {
console.log('=== 原生分享到朋友圈触发 ===')
if (!taskData.value || !taskNo.value) {
console.log('任务数据未加载,使用默认分享配置')
return {
title: 'AI创作神器 - 一键生成精美图片和视频',
query: ''
}
}
// 生成分享配置
const shareConfig = shareConfigs.dreamDetail(taskData.value, taskNo.value)
console.log('朋友圈分享配置:', shareConfig)
// 生成最终分享query
const baseQuery = shareConfig.query || ''
const finalQuery = generateShareQuery(baseQuery, userStore)
console.log('朋友圈分享最终query:', finalQuery)
return {
title: shareConfig.title,
query: finalQuery,
imageUrl: shareConfig.imageUrl
}
})
</script>
<style scoped>
.detail-page {
min-height: 100vh;
background-color: #000;
}
/* 导航栏 */
.nav-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: linear-gradient(to bottom, rgba(0,0,0,0.6), transparent);
}
.nav-back {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.back-icon {
width: 24px;
height: 24px;
}
.nav-actions {
display: flex;
align-items: center;
gap: 8px;
}
.nav-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.nav-icon {
width: 24px;
height: 24px;
}
.more-icon {
color: #fff;
font-size: 24px;
font-weight: bold;
letter-spacing: 2px;
}
/* 内容区域 */
.content {
height: 100vh;
position: relative;
display: flex;
flex-direction: column;
}
/* 媒体容器 */
.media-container {
flex: 1;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #000;
overflow: hidden;
}
.media-image {
width: 100%;
display: block;
}
.media-video {
width: 100%;
height: 100%;
}
/* 上方工具栏(在作品下方,黑色背景) */
.top-toolbar {
display: flex;
justify-content: flex-start;
gap: 12px;
padding: 12px 16px;
background-color: #000;
}
.tool-btn {
height: 30px;
display: flex;
align-items: center;
gap: 6px;
padding: 0 12px;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
border-radius: 15px;
}
.tool-icon {
width: 16px;
height: 16px;
filter: brightness(0) invert(1);
}
.tool-text {
color: #fff;
font-size: 12px;
white-space: nowrap;
}
/* 下方工具栏(带圆角) */
.bottom-toolbar {
height: 128px;
padding: 20px 16px;
padding-bottom: calc(20px + constant(safe-area-inset-bottom));
padding-bottom: calc(20px + env(safe-area-inset-bottom));
background-color: #000;
display: flex;
align-items: center;
gap: 12px;
border-radius: 20px 20px 0 0;
border-top: 1px solid #A1A1AA;
}
.toolbar-btn {
height: 54px;
border-radius: 12px;
border: 1px solid #A1A1AA;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.more-btn {
width: 60px;
background-color: #18181B;
padding: 0;
margin: 0;
line-height: 1;
}
/* 重置 button 默认样式 */
.more-btn::after {
border: none;
}
.more-dots {
color: #fff;
font-size: 24px;
font-weight: bold;
letter-spacing: 2px;
line-height: 1;
}
.download-btn {
flex: 1;
background-color: #18181B;
}
.publish-btn {
flex: 2;
background-color: #fff;
border-color: #fff;
}
.toolbar-icon {
width: 20px;
height: 20px;
}
.toolbar-icon.white {
filter: brightness(0);
}
.toolbar-text {
color: #A1A1AA;
font-size: 15px;
font-weight: 500;
}
.toolbar-text.white {
color: #000;
}
/* 加载状态 */
.loading-wrap {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(255, 255, 255, 0.2);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
color: #666;
font-size: 14px;
}
</style>

778
src/pages/dream/index.vue Normal file
View File

@@ -0,0 +1,778 @@
<template>
<view class="dream-page">
<!-- 顶部导航栏 -->
<view class="navbar" :style="{ paddingTop: safeAreaTop + 'px' }">
<view class="navbar-content">
<text class="navbar-title">造梦</text>
</view>
</view>
<!-- 任务列表 -->
<scroll-view
class="content"
:style="{ paddingTop: (safeAreaTop + 60) + 'px' }"
scroll-y
@scrolltolower="loadMore"
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
:enable-back-to-top="true"
>
<view class="task-list">
<transition-group name="task-fade" tag="view" class="task-transition-group">
<TaskItem
v-for="task in taskList"
:key="task.id"
:task="task"
:page-visible="isPageVisible"
@delete="deleteTask"
@edit="editAgain"
@publish="publishTask"
@preview="viewTaskDetail"
/>
</transition-group>
<!-- 空状态 -->
<view v-if="!loading && taskList.length === 0" class="empty-state">
<text class="empty-text">暂无生成记录</text>
<text class="empty-hint">点击下方按钮开始创作</text>
</view>
<!-- 加载更多 -->
<view v-if="loading" class="loading-more">
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="!hasMore && taskList.length > 0" class="no-more">
<text class="no-more-text">没有更多了</text>
</view>
<!-- 底部安全区域 -->
<view class="bottom-safe-area" :style="{ height: (safeAreaBottom + 100) + 'px' }"></view>
</view>
</scroll-view>
<!-- 底部操作区 -->
<view class="bottom-bar" :style="{ paddingBottom: (safeAreaBottom + 20) + 'px' }">
<!-- 输入框容器 -->
<view class="input-container" @tap="goToCreate">
<!-- 胶囊滑块切换在输入框内 -->
<view class="tab-switcher" @tap.stop="">
<view class="tab-slider" :style="{ left: currentTab === 'image' ? '2px' : 'calc(50% - 2px)' }"></view>
<view
:class="['tab-option', { active: currentTab === 'image' }]"
@tap.stop="switchTab('image')"
>
<text class="tab-text">图片生成</text>
</view>
<view
:class="['tab-option', { active: currentTab === 'video' }]"
@tap.stop="switchTab('video')"
>
<text class="tab-text">视频生成</text>
</view>
</view>
<!-- 输入提示文字 -->
<text class="input-placeholder">输入文字或上传参考图一键生成{{ currentTab === 'image' ? '图片' : '视频' }}</text>
</view>
</view>
<!-- TabBar -->
<TabBar :current="1" />
</view>
</template>
<script setup>
import { ref, onUnmounted } from 'vue'
import { onShow, onHide, onLoad, onUnload } from '@dcloudio/uni-app'
import { useUserStore } from '@/store/modules/user'
import { getMyAiTasks, deleteAiTask, getAiModelByCode } from '@/api/ai'
import TaskItem from '@/components/TaskItem/index.vue'
import { handleShareCode } from '@/utils/navigation'
const userStore = useUserStore()
const safeAreaTop = ref(0)
const safeAreaBottom = ref(0) // 添加底部安全区域
const currentTab = ref('image')
const taskList = ref([])
const loading = ref(false)
const refreshing = ref(false)
const hasMore = ref(true)
const page = ref(1)
const pageSize = 10
const MAX_LIST_SIZE = 100 // 列表最大条数,超出后裁剪头部释放内存
// 自动刷新相关
const autoRefreshTimer = ref(null)
const isPageVisible = ref(false)
const updateDebounceTimer = ref(null)
// 模型信息(支持多模型)
const imageModels = ref([])
const videoModels = ref([])
// 处理页面参数(分享码)
onLoad((options) => {
console.log('=== 造梦首页加载 ===')
console.log('页面参数:', options)
// 处理分享码逻辑
const shareResult = handleShareCode(options)
console.log('造梦首页分享码处理结果:', shareResult)
})
onShow(async () => {
// 获取安全区域信息
const windowInfo = uni.getWindowInfo()
safeAreaTop.value = windowInfo.statusBarHeight || 0
// 获取底部安全区域信息
try {
const systemInfo = uni.getSystemInfoSync()
safeAreaBottom.value = (systemInfo.safeAreaInsets?.bottom || 0) + 44 // TabBar高度44px + 底部安全区域
} catch (e) {
safeAreaBottom.value = 44 // 默认TabBar高度
}
// 标记页面可见
isPageVisible.value = true
// 加载模型信息
await loadModels()
// 检查是否有从Banner传递的参数
const dreamParams = uni.getStorageSync('dreamParams')
if (dreamParams) {
try {
const params = JSON.parse(dreamParams)
if (params.tab) {
currentTab.value = params.tab
}
if (params.expand) {
// 如果需要展开输入框,直接跳转到创作页面
setTimeout(() => {
goToCreate()
}, 100)
}
// 清除参数
uni.removeStorageSync('dreamParams')
} catch (e) {
console.error('解析dreamParams失败:', e)
}
}
// 加载任务列表:如果已有数据则无感刷新,避免返回时闪烁
if (taskList.value.length > 0) {
await silentRefresh()
} else {
await loadTasks(true)
}
// 启动自动刷新
startAutoRefresh()
})
onHide(() => {
// 标记页面不可见
isPageVisible.value = false
// 停止自动刷新
stopAutoRefresh()
})
onUnmounted(() => {
// 页面销毁时清理定时器
stopAutoRefresh()
if (updateDebounceTimer.value) {
clearTimeout(updateDebounceTimer.value)
updateDebounceTimer.value = null
}
})
// 页面卸载时清理资源,释放内存
onUnload(() => {
// 停止所有定时器
stopAutoRefresh()
if (updateDebounceTimer.value) {
clearTimeout(updateDebounceTimer.value)
updateDebounceTimer.value = null
}
// 清空数据,释放内存
taskList.value = []
imageModels.value = []
videoModels.value = []
// 重置状态
loading.value = false
refreshing.value = false
hasMore.value = true
page.value = 1
isPageVisible.value = false
})
// 加载模型信息(多模型)
const loadModels = async () => {
try {
const silentOpts = { showError: false }
const [nanobanner, volcEngine, grok, hunyuan] = await Promise.allSettled([
getAiModelByCode('nanobanana-image', silentOpts),
getAiModelByCode('seedream-4-5', silentOpts),
getAiModelByCode('grok-video', silentOpts),
getAiModelByCode('tencent-hunyuan-image3.0', silentOpts)
])
const imgModels = []
if (hunyuan.status === 'fulfilled' && hunyuan.value) imgModels.push(hunyuan.value)
if (volcEngine.status === 'fulfilled' && volcEngine.value) imgModels.push(volcEngine.value)
if (nanobanner.status === 'fulfilled' && nanobanner.value) imgModels.push(nanobanner.value)
imageModels.value = imgModels
const vidModels = []
if (grok.status === 'fulfilled' && grok.value) vidModels.push(grok.value)
videoModels.value = vidModels
} catch (e) {
console.error('加载模型失败:', e)
}
}
// 加载任务列表 - 自动加载 sora2 和 nanobanner 的任务
const loadTasks = async (refresh = false) => {
if (loading.value) return
if (refresh) {
page.value = 1
hasMore.value = true
taskList.value = []
}
loading.value = true
try {
// 获取所有任务
const res = await getMyAiTasks({
page: page.value,
size: pageSize
})
let list = res.records || []
// 自动筛选造梦页面相关模型的任务
const dreamModelCodes = ['tencent-aigc-video', 'grok-video', 'nanobanana-image', 'seedream-4-5', 'video-image-gen', 'tencent-hunyuan-image3.0']
list = list.filter(task => dreamModelCodes.includes(task.modelCode))
if (refresh) {
taskList.value = list
} else {
taskList.value = [...taskList.value, ...list]
}
// 裁剪超出上限的头部数据,防止内存溢出
if (taskList.value.length > MAX_LIST_SIZE) {
taskList.value = taskList.value.slice(-MAX_LIST_SIZE)
}
hasMore.value = (res.records || []).length >= pageSize
// 检查是否需要启动自动刷新
checkAndStartAutoRefresh()
} catch (e) {
console.error('加载任务列表失败:', e)
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
refreshing.value = false
}
}
// 检查是否有进行中的任务
const hasProcessingTasks = () => {
return taskList.value.some(task => task.status === 0 || task.status === 1)
}
// 启动自动刷新
const startAutoRefresh = () => {
// 先清除之前的定时器
stopAutoRefresh()
// 如果有进行中的任务且页面可见,启动定时器
if (hasProcessingTasks() && isPageVisible.value) {
console.log('启动自动刷新20秒后执行')
autoRefreshTimer.value = setTimeout(() => {
if (isPageVisible.value && hasProcessingTasks()) {
console.log('执行自动刷新')
// 无感刷新不显示loading不重置页面状态
silentRefresh()
}
}, 20000) // 20秒
}
}
// 停止自动刷新
const stopAutoRefresh = () => {
if (autoRefreshTimer.value) {
clearTimeout(autoRefreshTimer.value)
autoRefreshTimer.value = null
console.log('停止自动刷新')
}
}
// 检查并启动自动刷新
const checkAndStartAutoRefresh = () => {
if (hasProcessingTasks()) {
startAutoRefresh()
} else {
stopAutoRefresh()
}
}
// 无感刷新任务列表
const silentRefresh = async () => {
try {
// 获取第一页任务
const res = await getMyAiTasks({
page: 1,
size: pageSize
}, {
showLoading: false // 不显示loading
})
let list = res.records || []
// 自动筛选造梦页面相关模型的任务
const dreamModelCodes = ['tencent-aigc-video', 'grok-video', 'nanobanana-image', 'seedream-4-5', 'video-image-gen', 'tencent-hunyuan-image3.0']
list = list.filter(task => dreamModelCodes.includes(task.modelCode))
// 智能更新:只更新有变化的任务,避免不必要的重新渲染
updateTasksIntelligently(list)
console.log('无感刷新完成,任务数量:', list.length)
// 继续检查是否需要下次刷新
checkAndStartAutoRefresh()
} catch (e) {
console.error('无感刷新失败:', e)
// 刷新失败也要继续检查
checkAndStartAutoRefresh()
}
}
// 智能更新任务列表,只更新有变化的任务
const updateTasksIntelligently = (newTasks) => {
// 防抖处理,避免频繁更新
if (updateDebounceTimer.value) {
clearTimeout(updateDebounceTimer.value)
}
updateDebounceTimer.value = setTimeout(() => {
performTaskUpdate(newTasks)
}, 100) // 100ms 防抖
}
// 执行任务更新 - 去重 + 稳定排序,避免闪烁
const performTaskUpdate = (newTasks) => {
const currentTasks = taskList.value
let hasChanges = false
// 用 Map 快速查找当前任务
const currentTaskMap = new Map(currentTasks.map(t => [t.id, t]))
const newTaskIds = new Set(newTasks.map(t => t.id))
// 1. 原地更新已有任务的数据(不改变位置)
for (const newTask of newTasks) {
const existingTask = currentTaskMap.get(newTask.id)
if (existingTask) {
const hasTaskChanged = (
existingTask.status !== newTask.status ||
existingTask.progress !== newTask.progress ||
existingTask.outputResult !== newTask.outputResult ||
existingTask.errorMessage !== newTask.errorMessage
)
if (hasTaskChanged) {
// 原地更新属性,保持引用位置不变
Object.assign(existingTask, newTask)
hasChanges = true
console.log(`任务 ${newTask.id} 状态更新`)
}
}
}
// 2. 找出真正的新任务(不在当前列表中的)
const trulyNewTasks = newTasks.filter(t => !currentTaskMap.has(t.id))
// 3. 找出需要移除的任务(仅限第一页范围内,不在新数据中的)
const removedIds = []
for (let i = 0; i < Math.min(currentTasks.length, pageSize); i++) {
if (!newTaskIds.has(currentTasks[i].id)) {
removedIds.push(currentTasks[i].id)
}
}
// 4. 构建最终列表:新任务插入到头部 + 保留原有顺序(去除已删除的)
if (trulyNewTasks.length > 0 || removedIds.length > 0) {
hasChanges = true
const removedSet = new Set(removedIds)
const kept = currentTasks.filter(t => !removedSet.has(t.id))
// 新任务放到最前面,保持原有任务顺序不变
taskList.value = [...trulyNewTasks, ...kept]
console.log(`新增 ${trulyNewTasks.length} 个任务,移除 ${removedIds.length} 个任务`)
} else if (hasChanges) {
// 仅属性更新,触发响应式更新
taskList.value = [...currentTasks]
console.log('任务列表属性已更新')
} else {
console.log('任务列表无变化,跳过更新')
}
}
// 下拉刷新
const onRefresh = () => {
refreshing.value = true
// 停止自动刷新,避免冲突
stopAutoRefresh()
loadTasks(true)
}
// 加载更多
const loadMore = () => {
if (!hasMore.value || loading.value) return
page.value++
loadTasks()
}
// 切换Tab - 只切换创作类型,不重新加载任务列表
const switchTab = (tab) => {
if (currentTab.value === tab) return
currentTab.value = tab
}
// 显示菜单
const showMenu = () => {
uni.showActionSheet({
itemList: ['历史记录', '设置'],
success: (res) => {
if (res.tapIndex === 0) {
uni.showToast({ title: '当前页面', icon: 'none' })
} else if (res.tapIndex === 1) {
uni.navigateTo({ url: '/pages/settings/index' })
}
}
})
}
// 跳转到个人中心
const goToProfile = () => {
uni.switchTab({ url: '/pages/user/index' })
}
// 查看任务详情 - 跳转到造梦详情页面
const viewTaskDetail = (task) => {
if (task.status === 2) {
// 已完成的任务,跳转到造梦详情页
uni.navigateTo({ url: `/pages/dream/detail?taskNo=${task.taskNo}` })
} else if (task.status === 1 || task.status === 0) {
// 生成中或队列中的任务,跳转到任务详情页查看进度
uni.navigateTo({ url: `/pages/ai/task?taskNo=${task.taskNo}` })
}
}
// 删除任务
const deleteTask = (task) => {
uni.showModal({
title: '提示',
content: '确定要删除这个任务吗?',
success: async (res) => {
if (res.confirm) {
try {
await deleteAiTask(task.id)
uni.showToast({ title: '删除成功', icon: 'success' })
taskList.value = taskList.value.filter(t => t.id !== task.id)
// 删除任务后重新检查自动刷新
checkAndStartAutoRefresh()
} catch (e) {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
}
// 再次编辑
const editAgain = (task) => {
try {
const params = JSON.parse(task.inputParams)
const imageModelCodes = ['nanobanana-image', 'seedream-4-5', 'video-image-gen', 'tencent-hunyuan-image3.0']
const type = imageModelCodes.includes(task.modelCode) ? 'image' : 'video'
// 解析参考图(兼容所有模型的参考图字段)
const referenceImages = []
// 腾讯混元生图: images
if (params.images && Array.isArray(params.images)) {
referenceImages.push(...params.images.filter(url => typeof url === 'string' && url.startsWith('http')))
}
// NanoBanana: img_url
if (params.img_url) {
if (Array.isArray(params.img_url)) {
referenceImages.push(...params.img_url)
} else {
referenceImages.push(params.img_url)
}
}
// Seedream: image
if (params.image && typeof params.image === 'string' && params.image.startsWith('http')) {
referenceImages.push(params.image)
}
// 腾讯云VOD: file_urls
if (params.file_urls && Array.isArray(params.file_urls)) {
referenceImages.push(...params.file_urls)
}
// Grok: image_urls (可能是数组、JSON字符串数组、或普通URL字符串)
if (params.image_urls) {
if (Array.isArray(params.image_urls)) {
referenceImages.push(...params.image_urls)
} else if (typeof params.image_urls === 'string' && params.image_urls.startsWith('[')) {
try {
const parsed = JSON.parse(params.image_urls)
if (Array.isArray(parsed)) referenceImages.push(...parsed)
} catch (_) {}
} else if (typeof params.image_urls === 'string' && params.image_urls.startsWith('http')) {
referenceImages.push(params.image_urls)
}
}
// 旧版Sora2: url
if (params.url && typeof params.url === 'string' && params.url.startsWith('http')) {
referenceImages.push(params.url)
}
// 构建跳转参数
let url = `/pages/dream/create?type=${type}&prompt=${encodeURIComponent(params.prompt || '')}`
if (referenceImages.length > 0) {
url += `&refImages=${encodeURIComponent(JSON.stringify(referenceImages))}`
}
uni.navigateTo({ url })
} catch (e) {
console.error('再次编辑参数解析失败:', e)
uni.showToast({ title: '参数解析失败', icon: 'none' })
}
}
// 发布任务
const publishTask = (task) => {
if (task.status !== 2) {
uni.showToast({ title: '任务未完成', icon: 'none' })
return
}
uni.navigateTo({ url: `/pages/work/publish?taskId=${task.id}` })
}
// 跳转到创作页面
const goToCreate = () => {
uni.navigateTo({ url: `/pages/dream/create?type=${currentTab.value}` })
}
</script>
<style scoped>
.dream-page {
min-height: 100vh;
background-color: #000;
}
/* 顶部导航栏 */
.navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background-color: #000;
}
.navbar-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
}
.navbar-title {
color: #fff;
font-size: 32px;
font-weight: 700;
}
.navbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
.icon-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
}
.icon-image {
width: 24px;
height: 24px;
}
.avatar-image {
width: 32px;
height: 32px;
border-radius: 50%;
}
.avatar-placeholder {
width: 32px;
height: 32px;
border-radius: 50%;
background-color: #333;
}
/* 内容区域 */
.content {
height: 100vh;
box-sizing: border-box;
}
/* 任务列表过渡效果 */
.task-transition-group {
width: 100%;
}
.task-fade-enter-active,
.task-fade-leave-active {
transition: all 0.3s ease;
}
.task-fade-enter-from {
opacity: 0;
transform: translateY(20px);
}
.task-fade-leave-to {
opacity: 0;
transform: translateY(-20px);
}
.task-fade-move {
transition: transform 0.3s ease;
}
/* 空状态 */
.empty-state {
padding: 60px 20px;
text-align: center;
}
.empty-text {
color: #666;
font-size: 16px;
display: block;
margin-bottom: 8px;
}
.empty-hint {
color: #444;
font-size: 14px;
display: block;
}
/* 加载状态 */
.loading-more,
.no-more {
padding: 20px;
text-align: center;
}
.loading-text,
.no-more-text {
color: #666;
font-size: 13px;
}
/* 底部安全区域 - 高度通过内联样式动态设置 */
/* 底部操作区 */
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 12px 20px;
z-index: 998;
}
/* 输入框容器 */
.input-container {
position: relative;
background-color: #2a2a2a;
border-radius: 12px;
padding: 12px 16px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
/* 胶囊滑块切换器(在输入框内) */
.tab-switcher {
position: relative;
display: flex;
width: 144px;
height: 28px;
background-color: #1a1a1a;
border-radius: 14px;
padding: 2px;
flex-shrink: 0;
}
.tab-slider {
position: absolute;
top: 2px;
bottom: 2px;
width: calc(50% - 2px);
background-color: #3a3a3a;
border-radius: 12px;
transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1;
}
.tab-option {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 2;
}
.tab-text {
color: #666;
font-size: 12px;
font-weight: 500;
transition: color 0.3s ease;
}
.tab-option.active .tab-text {
color: #fff;
}
/* 输入提示文字 */
.input-placeholder {
flex: 1;
color: #666;
font-size: 14px;
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,410 @@
<template>
<view class="page-container">
<HomeBanner position="home" />
<!-- AI功能区域上线前暂时隐藏 -->
<!-- <view class="ai-features-section" v-if="aiFeatures.length > 0">
<view class="section-header">
<text class="section-title">AI功能</text>
<text class="section-more" v-if="hasMoreModels" @click="goToMoreModels">更多</text>
</view>
<view class="ai-features-grid">
<view
v-for="feature in aiFeatures"
:key="feature.id"
class="ai-feature-item"
@click="handleAIFeatureClick(feature)"
>
<view class="feature-icon">
<image v-if="feature.icon" :src="feature.icon" class="icon-image" mode="aspectFit" />
<view v-else class="icon-placeholder">
<text class="icon-text">{{ feature.name.substring(0, 1) }}</text>
</view>
</view>
<text class="feature-title">{{ feature.name }}</text>
</view>
</view>
</view> -->
<CategoryBar @change="handleCategoryChange" @search="handleSearch" />
<WorkList
ref="workListRef"
:sort-type="currentFilter.type || 'hot'"
:category-id="currentFilter.categoryId"
:page-visible="pageVisible"
@click="handleWorkClick"
/>
<TabBar :current="0" />
<!-- 新用户注册奖励弹窗 -->
<view class="reward-mask" v-if="showRewardPopup" @click.stop>
<view class="reward-popup" @click.stop>
<!-- 背景图片 -->
<image class="reward-bg-image" :src="rewardBgImage" mode="aspectFill" />
<!-- 内容区域 -->
<view class="reward-content">
<!-- 免费领取标签 -->
<view class="reward-tag">
<text class="reward-tag-text">免费领取</text>
</view>
<!-- 积分数字 -->
<view class="reward-points-row">
<text class="reward-points-num">{{ rewardPoints }}</text>
<view class="reward-points-right">
<image class="reward-points-icon" src="/static/icons/points.png" mode="aspectFit" />
<text class="reward-points-unit">积分</text>
</view>
</view>
<!-- 领取按钮 -->
<button class="reward-btn" @click="handleClaimReward">
立即领取
</button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { onShow, onHide, onLoad, onReachBottom } from '@dcloudio/uni-app'
import HomeBanner from '@/components/HomeBanner/index.vue'
import CategoryBar from '@/components/CategoryBar/index.vue'
import WorkList from '@/components/WorkList/index.vue'
import TabBar from '@/components/TabBar/index.vue'
import { getHomeAiModels } from '@/api/ai'
import { useUserStore } from '@/store/modules/user'
import { handleShareCode } from '@/utils/navigation'
import { useShareMixin } from '@/mixins/shareMixin'
const userStore = useUserStore()
// 使用分享混入
useShareMixin({
title: 'AI创作神器 - 一键生成精美图片和视频',
path: '/pages/inspiration/index',
imageUrl: 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png'
})
const workListRef = ref(null)
const currentFilter = ref({ type: 'hot', categoryId: null })
const pageVisible = ref(true)
onHide(() => { pageVisible.value = false })
onShow(() => { pageVisible.value = true })
// 奖励弹窗相关
const showRewardPopup = ref(false)
const rewardPoints = ref(0)
// TODO: 替换为实际的背景图链接
const rewardBgImage = ref('https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/1818_bg/pop-upbackground.png')
// 更多AI功能数据
const aiFeatures = ref([])
const hasMoreModels = ref(false)
// 加载首页AI模型
const loadHomeModels = async () => {
try {
const res = await getHomeAiModels(4)
aiFeatures.value = res.models || []
hasMoreModels.value = res.hasMore || false
} catch (e) {
console.error('加载AI模型失败', e)
}
}
// 跳转到更多模型页面
const goToMoreModels = () => {
uni.navigateTo({ url: '/pages/ai/models' })
}
const handleCategoryChange = (filter) => {
// 创建新对象以确保响应式更新
currentFilter.value = { ...filter }
}
const handleSearch = () => {
uni.navigateTo({ url: '/pages/search/index' })
}
const handleWorkClick = (work) => {
// 传递当前的筛选条件,以便详情页加载相同条件的作品列表
let url = `/pages/work/detail?id=${work.id}&sortType=${currentFilter.value.type || 'hot'}`
// 只有当 categoryId 存在且不为 null/undefined 时才添加
if (currentFilter.value.categoryId != null && currentFilter.value.categoryId !== '') {
url += `&categoryId=${currentFilter.value.categoryId}`
}
uni.navigateTo({ url })
}
const handleAIFeatureClick = (feature) => {
console.log('点击AI功能:', feature)
// 跳转到AI模型详情页
uni.navigateTo({ url: `/pages/ai/detail?id=${feature.id}&code=${feature.code}` })
}
// 检查并显示注册奖励弹窗
const checkRegisterReward = () => {
const reward = uni.getStorageSync('registerReward')
if (reward && reward.show && reward.points > 0) {
rewardPoints.value = reward.points
showRewardPopup.value = true
}
}
// 领取奖励
const handleClaimReward = () => {
showRewardPopup.value = false
// 清除本地存储的奖励信息
uni.removeStorageSync('registerReward')
uni.showToast({ title: '领取成功', icon: 'success' })
}
onMounted(() => {
// 加载首页AI模型
loadHomeModels()
// 检查是否需要显示注册奖励弹窗
checkRegisterReward()
})
// 页面触底加载更多
onReachBottom(() => {
workListRef.value?.loadMore()
})
// 处理页面参数(分享码)
onLoad((options) => {
console.log('=== 首页加载 ===')
console.log('页面参数:', options)
// 处理分享码逻辑
const shareResult = handleShareCode(options)
console.log('首页分享码处理结果:', shareResult)
// 分享码已在 handleShareCode 中保存到 localStorage
// 用户可以先浏览页面内容,自主选择登录时再使用分享码
if (shareResult.shareCode) {
console.log('检测到分享码,已保存,用户可自主选择登录')
}
})
// 页面显示时也检查tabBar页面切换时触发
onShow(() => {
checkRegisterReward()
})
</script>
<style scoped>
.page-container {
min-height: 100vh;
background-color: #09090b;
display: flex;
flex-direction: column;
/* 添加底部安全区域 */
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
/* 更多AI功能区域 */
.ai-features-section {
background-color: #09090b;
padding: 20px;
margin-top: 10px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.section-title {
color: #ffffff;
font-size: 16px;
font-weight: 600;
}
.section-more {
color: #a1a1aa;
font-size: 12px;
}
.ai-features-grid {
display: flex;
justify-content: space-between;
gap: 12px;
}
.ai-feature-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 8px;
background: transparent;
border-radius: 12px;
transition: all 0.3s ease;
}
.ai-feature-item:active {
transform: scale(0.95);
opacity: 0.7;
}
.feature-icon {
width: 22px;
height: 22px;
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.icon-image {
width: 22px;
height: 22px;
}
.icon-placeholder {
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
}
.icon-text {
color: #ffffff;
font-size: 14px;
font-weight: 600;
}
.feature-title {
color: #ffffff;
font-family: PingFang SC, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-weight: 400;
font-style: normal;
font-size: 11px;
line-height: 17px;
letter-spacing: 0%;
text-align: center;
}
/* 新用户奖励弹窗样式 */
.reward-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.75);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.reward-popup {
width: 253px;
height: 274px;
position: relative;
border-radius: 16px;
overflow: hidden;
}
.reward-bg-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}
.reward-content {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 24px;
}
.reward-tag {
background: rgba(30, 41, 59, 0.8);
border-radius: 20px;
padding: 6px 20px;
margin-bottom: 30px;
}
.reward-tag-text {
color: #ffffff;
font-family: PingFang SC, -apple-system, BlinkMacSystemFont, sans-serif;
font-weight: 400;
font-size: 14px;
}
.reward-points-row {
display: flex;
align-items: flex-end;
justify-content: center;
margin-bottom: 40px;
}
.reward-points-num {
font-size: 56px;
font-weight: 700;
color: #ffffff;
line-height: 1;
}
.reward-points-right {
display: flex;
flex-direction: column;
align-items: flex-start;
margin-left: 4px;
margin-bottom: 4px;
}
.reward-points-icon {
width: 20px;
height: 20px;
margin-bottom: 2px;
}
.reward-points-unit {
font-family: PingFang SC, -apple-system, BlinkMacSystemFont, sans-serif;
font-weight: 400;
font-size: 16px;
line-height: 34px;
letter-spacing: 0%;
text-align: center;
color: rgba(255, 255, 255, 0.8);
}
.reward-btn {
width: 200px;
height: 44px;
background: #FAFAFA;
border: none;
border-radius: 8px;
color: #000000;
font-size: 16px;
font-weight: 500;
}
.reward-btn::after {
border: none;
}
</style>

869
src/pages/invite/index.vue Normal file
View File

@@ -0,0 +1,869 @@
<template>
<view class="invite-page">
<!-- 顶部导航 -->
<view class="nav-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-back" @click="handleBack">
<image class="back-icon" src="/static/icons/Left (左).png" mode="aspectFit" />
</view>
<text class="nav-title"></text>
<view class="nav-placeholder"></view>
</view>
<!-- 背景图区域 -->
<view class="banner-section">
<image
class="banner-bg"
src="https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/1818_bg/invite_bg.png"
mode="widthFix"
/>
</view>
<!-- 底部卡片区域 -->
<view class="card-section">
<!-- 提示文字 -->
<view class="tip-row">
<text class="tip-icon"></text>
<text class="tip-text">每成功邀请1位新用户注册 必得500积分</text>
<text class="tip-icon"></text>
</view>
<!-- 统计区域 -->
<view class="stats-row">
<view class="stat-card" @click="showPointsDetail">
<text class="stat-label">累计推广获得积分</text>
<view class="stat-value-row">
<image class="stat-icon" src="/static/icons/points.png" mode="aspectFit" />
<text class="stat-value">{{ inviteStats.totalPoints || 0 }}</text>
<text class="stat-arrow"></text>
</view>
</view>
<view class="stat-divider"></view>
<view class="stat-card" @click="showInviteDetail">
<text class="stat-label">累计邀请 {{ inviteStats.inviteCount || 0 }} 个好友</text>
<view class="stat-value-row">
<image class="stat-icon" src="/static/icons/user-edit-01.png" mode="aspectFit" />
<text class="stat-arrow"></text>
</view>
</view>
</view>
<!-- 立即邀请按钮 -->
<button class="invite-btn" open-type="share">
<text class="invite-btn-text">立即邀请</text>
</button>
</view>
<!-- 积分详情弹窗 -->
<view v-if="showPointsModal" class="modal-overlay" @click="closePointsModal">
<view class="modal-content points-modal" @click.stop>
<!-- 固定头部 -->
<view class="modal-header-fixed">
<view class="modal-header">
<text class="modal-title">积分明细</text>
<view class="modal-close" @click="closePointsModal">
<text class="close-icon"></text>
</view>
</view>
<!-- 积分统计 -->
<view class="points-stats">
<view class="points-label">我的积分</view>
<view class="points-value-row">
<text class="points-value">{{ userStore.userInfo?.points || 0 }}</text>
<image class="points-icon" src="/static/icons/points.png" mode="aspectFit" />
</view>
<view class="points-detail">
<text class="detail-item">累计充值 {{ pointsStats.subscribePoints || 0 }}</text>
<text class="detail-divider">|</text>
<text class="detail-item">累计获得赠送 {{ pointsStats.giftPoints || 0 }}</text>
</view>
</view>
<!-- Tab切换 -->
<view class="tab-bar">
<view
v-for="tab in pointsTabs"
:key="tab.value"
class="tab-item"
:class="{ active: currentPointsTab === tab.value }"
@click="switchPointsTab(tab.value)"
>
<text class="tab-text">{{ tab.label }}</text>
</view>
</view>
</view>
<!-- 可滚动列表区域 -->
<scroll-view
class="modal-scroll-list"
scroll-y
@scrolltolower="handlePointsScrollToLower"
lower-threshold="100"
>
<view class="record-list">
<view
v-for="item in pointsRecordList"
:key="item.id"
class="record-item-simple"
>
<view class="record-info-simple">
<text class="record-title">{{ item.remark || item.typeName }}</text>
<text class="record-time">{{ formatTime(item.createdAt) }}</text>
</view>
<text
class="record-points"
:class="{ positive: item.points > 0, negative: item.points < 0 }"
>
{{ item.points > 0 ? '+' : '' }}{{ item.points }}
</text>
</view>
<view v-if="!pointsHasMore && pointsRecordList.length > 0" class="no-more">没有更多了</view>
<view v-if="pointsLoading" class="loading-tip">加载中...</view>
<view v-if="pointsRecordList.length === 0 && !pointsLoading" class="empty-tip">暂无记录</view>
</view>
</scroll-view>
<!-- 固定底部按钮 -->
<view class="modal-footer-fixed">
<button class="footer-btn">购买套餐得积分</button>
</view>
</view>
</view>
<!-- 邀请记录弹窗 -->
<view v-if="showInviteModal" class="modal-overlay" @click="closeInviteModal">
<view class="modal-content" @click.stop>
<!-- 固定头部 -->
<view class="modal-header">
<text class="modal-title">邀请明细</text>
<view class="modal-close" @click="closeInviteModal">
<text class="close-icon"></text>
</view>
</view>
<!-- 可滚动列表区域 -->
<scroll-view
class="modal-scroll-list-simple"
scroll-y
@scrolltolower="handleInviteScrollToLower"
lower-threshold="100"
>
<view class="record-list">
<view
v-for="item in inviteRecordList"
:key="item.id"
class="record-item"
>
<view class="record-left">
<image class="record-avatar" :src="item.avatar || getDefaultAvatar()" mode="aspectFill" />
<view class="record-info">
<text class="record-name">{{ item.nickname }}</text>
<text class="record-time">{{ formatTime(item.createdAt) }}</text>
</view>
</view>
<text class="record-points positive">+{{ item.rewardPoints }}</text>
</view>
<view v-if="!inviteHasMore && inviteRecordList.length > 0" class="no-more">没有更多了</view>
<view v-if="inviteLoading" class="loading-tip">加载中...</view>
<view v-if="inviteRecordList.length === 0 && !inviteLoading" class="empty-tip">暂无邀请记录</view>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useUserStore } from '@/store/modules/user'
import { getInviteStats, getInviteRecords, getPointsRecords, getPointsStats } from '@/api/user'
const userStore = useUserStore()
const statusBarHeight = ref(0)
// 邀请统计数据从后端API获取
const inviteStats = ref({
totalPoints: 0,
inviteCount: 0
})
// 积分统计数据
const pointsStats = ref({
subscribePoints: 0,
giftPoints: 0
})
// 积分弹窗相关
const showPointsModal = ref(false)
const currentPointsTab = ref('increase')
const pointsRecordList = ref([])
const pointsPageNum = ref(1)
const pointsPageSize = ref(20)
const pointsHasMore = ref(true)
const pointsLoading = ref(false)
// 邀请弹窗相关
const showInviteModal = ref(false)
const inviteRecordList = ref([])
const invitePageNum = ref(1)
const invitePageSize = ref(20)
const inviteHasMore = ref(true)
const inviteLoading = ref(false)
const pointsTabs = [
{ label: '全部', value: 'all' },
{ label: '增加', value: 'increase' },
{ label: '消耗', value: 'decrease' }
]
onMounted(() => {
const sysInfo = uni.getSystemInfoSync()
statusBarHeight.value = sysInfo.statusBarHeight || 0
loadInviteData()
// 刷新用户积分
if (userStore.isLogin) {
userStore.fetchUserInfo()
}
})
onShow(() => {
loadInviteData()
// 页面显示时刷新积分
if (userStore.isLogin) {
userStore.fetchUserInfo()
}
})
// 加载邀请数据
const loadInviteData = async () => {
if (!userStore.isLogin) {
console.log('用户未登录,跳过加载邀请数据')
return
}
try {
const res = await getInviteStats()
console.log('邀请统计数据:', res)
inviteStats.value = res
} catch (error) {
console.error('获取邀请统计失败:', error)
uni.showToast({
title: '获取邀请统计失败',
icon: 'none'
})
}
}
const handleBack = () => {
uni.navigateBack()
}
// 显示积分详情
const showPointsDetail = async () => {
showPointsModal.value = true
currentPointsTab.value = 'increase'
pointsRecordList.value = []
pointsPageNum.value = 1
pointsHasMore.value = true
// 加载积分统计
try {
const stats = await getPointsStats()
pointsStats.value = stats
} catch (error) {
console.error('获取积分统计失败:', error)
}
loadPointsRecords()
}
// 显示邀请详情
const showInviteDetail = () => {
showInviteModal.value = true
inviteRecordList.value = []
invitePageNum.value = 1
inviteHasMore.value = true
loadInviteRecords()
}
// 关闭积分弹窗
const closePointsModal = () => {
showPointsModal.value = false
pointsRecordList.value = []
}
// 关闭邀请弹窗
const closeInviteModal = () => {
showInviteModal.value = false
inviteRecordList.value = []
}
// 切换积分Tab
const switchPointsTab = (tab) => {
if (currentPointsTab.value === tab) return
currentPointsTab.value = tab
pointsRecordList.value = []
pointsPageNum.value = 1
pointsHasMore.value = true
loadPointsRecords()
}
// 加载积分记录
const loadPointsRecords = async () => {
if (!pointsHasMore.value || pointsLoading.value) return
pointsLoading.value = true
try {
let type = null
if (currentPointsTab.value === 'decrease') {
type = 2 // 只查询消费记录
}
// increase 查询所有增加类型: 1充值 3赠送 4推广奖励 5签到 6退款
// all 查询全部不传type
const res = await getPointsRecords(pointsPageNum.value, pointsPageSize.value, type)
let newList = res.list || []
// 如果是增加Tab过滤出积分为正数的记录
if (currentPointsTab.value === 'increase') {
newList = newList.filter(item => item.points > 0)
}
pointsRecordList.value = [...pointsRecordList.value, ...newList]
pointsHasMore.value = res.hasNext || false
} catch (error) {
console.error('加载积分记录失败:', error)
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
pointsLoading.value = false
}
}
// 加载邀请记录
const loadInviteRecords = async () => {
if (!inviteHasMore.value || inviteLoading.value) return
inviteLoading.value = true
try {
const res = await getInviteRecords(invitePageNum.value, invitePageSize.value)
const newList = res.list || []
inviteRecordList.value = [...inviteRecordList.value, ...newList]
inviteHasMore.value = res.hasNext || false
} catch (error) {
console.error('加载邀请记录失败:', error)
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
inviteLoading.value = false
}
}
// 积分列表滚动到底部
const handlePointsScrollToLower = () => {
if (pointsHasMore.value && !pointsLoading.value) {
pointsPageNum.value++
loadPointsRecords()
}
}
// 邀请列表滚动到底部
const handleInviteScrollToLower = () => {
if (inviteHasMore.value && !inviteLoading.value) {
invitePageNum.value++
loadInviteRecords()
}
}
// 格式化时间
const formatTime = (time) => {
if (!time) return ''
const date = new Date(time)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hour}:${minute}`
}
// 获取默认头像
const getDefaultAvatar = () => {
return 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/1818_bg/default-avatar.png'
}
</script>
<style scoped>
.invite-page {
min-height: 100vh;
background: #09090b;
display: flex;
flex-direction: column;
position: relative;
}
.nav-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
background: transparent;
z-index: 100;
}
.nav-back {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.back-icon {
width: 24px;
height: 24px;
}
.nav-title {
font-size: 17px;
font-weight: 600;
color: #ffffff;
}
.nav-placeholder {
width: 40px;
}
/* 背景图区域 */
.banner-section {
position: absolute;
top: -45px;
left: 0;
right: 0;
width: 100%;
overflow: hidden;
}
.banner-bg {
width: 100%;
}
/* 底部卡片 */
.card-section {
position: relative;
margin-top: auto;
height: 300px;
box-sizing: border-box;
background: linear-gradient(178.39deg, rgba(58, 170, 255, 1), rgba(255, 255, 255, 1) 100%);
border-radius: 24px 24px 0 0;
padding: 24px 20px;
padding-bottom: 40px;
z-index: 10;
}
/* 提示文字 */
.tip-row {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
}
.tip-icon {
font-size: 12px;
color: #f59e0b;
}
.tip-text {
font-size: 14px;
color: #18181b;
margin: 0 8px;
font-weight: 500;
}
/* 统计区域 */
.stats-row {
display: flex;
align-items: stretch;
background: #f4f4f5;
border-radius: 12px;
padding: 16px;
margin-bottom: 24px;
}
.stat-card {
flex: 1;
display: flex;
flex-direction: column;
}
.stat-label {
font-size: 13px;
color: #71717a;
margin-bottom: 8px;
}
.stat-value-row {
display: flex;
align-items: center;
}
.stat-icon {
width: 18px;
height: 18px;
}
.stat-value {
font-size: 18px;
font-weight: 600;
color: #18181b;
margin-left: 6px;
}
.stat-arrow {
font-size: 16px;
color: #a1a1aa;
margin-left: 4px;
}
.stat-divider {
width: 1px;
background: #e4e4e7;
margin: 0 16px;
}
/* 立即邀请按钮 */
.invite-btn {
width: 100%;
height: 50px;
background: #18181b;
border-radius: 25px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
border: none;
}
.invite-btn::after {
border: none;
}
.invite-btn-text {
font-size: 16px;
font-weight: 600;
color: #ffffff;
}
/* 复制链接 */
.copy-link {
display: flex;
align-items: center;
justify-content: center;
padding: 12px 0;
}
.copy-link-text {
font-size: 14px;
color: #71717a;
}
/* 弹窗样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 1000;
display: flex;
align-items: flex-end;
}
.modal-content {
width: 100%;
height: 75vh;
max-height: 80vh;
background: #1c1c1e;
border-radius: 24px 24px 0 0;
display: flex;
flex-direction: column;
}
.points-modal {
max-height: 80vh;
background: #2c2c2e;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px;
border-bottom: 1px solid #3a3a3c;
}
.modal-title {
font-size: 18px;
font-weight: 600;
color: #ffffff;
}
.modal-close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #3a3a3c;
}
.close-icon {
font-size: 18px;
color: #8e8e93;
}
/* 积分统计 */
.points-stats {
padding: 24px 20px;
background: #2c2c2e;
}
.points-label {
font-size: 14px;
color: #8e8e93;
margin-bottom: 8px;
}
.points-value-row {
display: flex;
align-items: center;
margin-bottom: 12px;
}
.points-value {
font-size: 48px;
font-weight: 700;
color: #ffffff;
margin-right: 8px;
}
.points-icon {
width: 32px;
height: 32px;
}
.points-detail {
display: flex;
align-items: center;
font-size: 14px;
color: #8e8e93;
}
.detail-item {
color: #8e8e93;
}
.detail-divider {
margin: 0 12px;
color: #3a3a3c;
}
/* Tab栏 */
.tab-bar {
display: flex;
padding: 0 20px;
background: #2c2c2e;
border-bottom: 1px solid #3a3a3c;
}
.tab-item {
flex: 1;
padding: 16px 0;
text-align: center;
position: relative;
}
.tab-text {
font-size: 15px;
color: #8e8e93;
}
.tab-item.active .tab-text {
color: #ffffff;
font-weight: 600;
}
.tab-item.active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 40px;
height: 3px;
background: #ffffff;
border-radius: 2px;
}
/* 固定头部区域(积分弹窗) */
.modal-header-fixed {
flex-shrink: 0;
}
/* 可滚动列表区域(积分弹窗) */
.modal-scroll-list {
flex: 1;
height: 0;
background: #1c1c1e;
}
/* 可滚动列表区域(邀请弹窗) */
.modal-scroll-list-simple {
flex: 1;
height: 0;
background: #1c1c1e;
}
/* 固定底部按钮区域 */
.modal-footer-fixed {
flex-shrink: 0;
padding: 16px 20px 32px;
background: #2c2c2e;
border-top: 1px solid #3a3a3c;
}
.record-list {
padding: 20px;
}
/* 简化的记录项(无头像) */
.record-item-simple {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
border-bottom: 1px solid #2c2c2e;
}
.record-item-simple:last-child {
border-bottom: none;
}
.record-info-simple {
display: flex;
flex-direction: column;
flex: 1;
}
.record-title {
font-size: 15px;
color: #ffffff;
margin-bottom: 4px;
}
.record-time {
font-size: 13px;
color: #8e8e93;
}
/* 带头像的记录项(邀请记录用) */
.record-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
border-bottom: 1px solid #2c2c2e;
}
.record-item:last-child {
border-bottom: none;
}
.record-left {
display: flex;
align-items: center;
flex: 1;
}
.record-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: #3a3a3c;
margin-right: 12px;
}
.record-info {
display: flex;
flex-direction: column;
}
.record-name {
font-size: 15px;
color: #ffffff;
margin-bottom: 4px;
}
.record-points {
font-size: 18px;
font-weight: 600;
margin-left: 12px;
}
.record-points.positive {
color: #3ED0F5;
}
.record-points.negative {
color: #ffffff;
}
/* 底部按钮 */
.footer-btn {
width: 100%;
height: 50px;
background: #ffffff;
border-radius: 25px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 600;
color: #000000;
border: none;
}
.footer-btn::after {
border: none;
}
.no-more {
text-align: center;
padding: 20px 0;
font-size: 13px;
color: #8e8e93;
}
.loading-tip {
text-align: center;
padding: 20px 0;
font-size: 13px;
color: #8e8e93;
}
.empty-tip {
text-align: center;
padding: 60px 0;
font-size: 14px;
color: #8e8e93;
}
</style>

1362
src/pages/login/index.vue Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

409
src/pages/profile/edit.vue Normal file
View File

@@ -0,0 +1,409 @@
<template>
<view class="edit-page">
<!-- 顶部导航 -->
<view class="nav-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-back" @click="handleBack">
<image class="back-icon" src="/static/icons/Left (左).png" mode="aspectFit" />
</view>
<text class="nav-title">编辑资料</text>
<view class="nav-placeholder"></view>
</view>
<!-- 内容区域 -->
<view class="content" :style="{ marginTop: (statusBarHeight + 44) + 'px' }">
<!-- 头像区域 -->
<view class="avatar-section">
<button class="avatar-wrapper" open-type="chooseAvatar" @chooseavatar="onChooseAvatar">
<image
class="avatar-img"
:src="formData.avatar || '/static/images/default-avatar.png'"
mode="aspectFill"
/>
<view class="edit-badge">
<image class="edit-icon" src="/static/icons/edit-fill.png" mode="aspectFit" />
</view>
</button>
</view>
<!-- 昵称区域 -->
<view class="form-section">
<view class="form-item" @click="showNicknameInput">
<text class="label">昵称</text>
<view class="value-wrapper">
<text class="value">{{ formData.nickname || '未设置' }}</text>
<image class="edit-arrow" src="/static/icons/edit-fill.png" mode="aspectFit" />
</view>
</view>
</view>
</view>
<!-- 昵称编辑弹窗 -->
<view class="nickname-modal" v-if="showModal" @click="hideNicknameInput">
<view class="modal-content" @click.stop>
<text class="modal-title">修改昵称</text>
<input
class="modal-input"
type="nickname"
v-model="tempNickname"
placeholder="请输入昵称"
maxlength="20"
focus
@blur="onNicknameBlur"
/>
<view class="modal-btns">
<view class="modal-btn cancel" @click="hideNicknameInput">取消</view>
<view class="modal-btn confirm" @click="confirmNickname">确定</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useUserStore } from '@/store/modules/user'
import { updateUserProfile } from '@/api/user'
import config from '@/config'
const userStore = useUserStore()
const statusBarHeight = ref(0)
const showModal = ref(false)
const tempNickname = ref('')
const formData = reactive({
avatar: '',
nickname: ''
})
onMounted(() => {
const sysInfo = uni.getSystemInfoSync()
statusBarHeight.value = sysInfo.statusBarHeight || 0
// 初始化表单数据
if (userStore.userInfo) {
formData.avatar = userStore.userInfo.avatar || ''
formData.nickname = userStore.userInfo.nickname || ''
}
})
const handleBack = () => {
uni.navigateBack()
}
// 选择头像回调
const onChooseAvatar = (e) => {
const tempFilePath = e.detail.avatarUrl
if (tempFilePath) {
uploadAvatar(tempFilePath)
}
}
// 上传头像
const uploadAvatar = async (filePath) => {
uni.showLoading({ title: '上传中...' })
try {
const uploadRes = await new Promise((resolve, reject) => {
uni.uploadFile({
url: `${config.baseUrl}/user/upload-avatar`,
filePath: filePath,
name: 'file',
header: {
'Authorization': `Bearer ${userStore.token}`
},
success: (res) => {
if (res.statusCode === 200) {
const data = JSON.parse(res.data)
if (data.code === 0) {
resolve(data.data)
} else {
reject(new Error(data.message || '上传失败'))
}
} else {
reject(new Error('上传失败'))
}
},
fail: reject
})
})
const avatarUrl = uploadRes.url || uploadRes
formData.avatar = avatarUrl
// 保存到服务器
await updateUserProfile({ avatar: avatarUrl })
// 更新本地
userStore.setUserInfo({
...userStore.userInfo,
avatar: avatarUrl
})
uni.hideLoading()
uni.showToast({ title: '头像已更新', icon: 'success' })
} catch (e) {
uni.hideLoading()
console.error('上传头像失败', e)
uni.showToast({ title: '上传失败', icon: 'none' })
}
}
// 显示昵称输入弹窗
const showNicknameInput = () => {
tempNickname.value = formData.nickname
showModal.value = true
}
// 隐藏昵称输入弹窗
const hideNicknameInput = () => {
showModal.value = false
}
// 昵称输入完成(微信自动填充)
const onNicknameBlur = (e) => {
if (e.detail.value) {
tempNickname.value = e.detail.value
}
}
// 确认修改昵称
const confirmNickname = async () => {
const nickname = tempNickname.value.trim()
if (!nickname) {
uni.showToast({ title: '请输入昵称', icon: 'none' })
return
}
uni.showLoading({ title: '保存中...' })
try {
await updateUserProfile({ nickname })
formData.nickname = nickname
userStore.setUserInfo({
...userStore.userInfo,
nickname
})
uni.hideLoading()
uni.showToast({ title: '昵称已更新', icon: 'success' })
showModal.value = false
} catch (e) {
uni.hideLoading()
console.error('保存失败', e)
uni.showToast({ title: '保存失败', icon: 'none' })
}
}
</script>
<style scoped>
.edit-page {
min-height: 100vh;
background: #09090b;
}
.nav-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
background: #09090b;
z-index: 100;
}
.nav-back {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.back-icon {
width: 24px;
height: 24px;
}
.nav-title {
font-size: 17px;
font-weight: 600;
color: #ffffff;
}
.nav-placeholder {
width: 40px;
}
.content {
padding: 40px 16px;
}
/* 头像区域 */
.avatar-section {
display: flex;
justify-content: center;
margin-bottom: 40px;
}
.avatar-wrapper {
position: relative;
width: 120px;
height: 120px;
padding: 0;
margin: 0;
background: transparent;
border: none;
}
.avatar-wrapper::after {
border: none;
}
.avatar-img {
width: 120px;
height: 120px;
border-radius: 50%;
background: #27272a;
}
.edit-badge {
position: absolute;
right: 0;
bottom: 0;
width: 32px;
height: 32px;
background: #ffffff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.edit-icon {
width: 16px;
height: 16px;
}
/* 表单区域 */
.form-section {
background: #18181b;
border-radius: 12px;
overflow: hidden;
}
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 20px;
}
.label {
font-size: 15px;
color: #a1a1aa;
}
.value-wrapper {
display: flex;
align-items: center;
}
.value {
font-size: 15px;
color: #f4f4f5;
margin-right: 8px;
}
.edit-arrow {
width: 16px;
height: 16px;
}
/* 昵称编辑弹窗 */
.nickname-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
width: 300px;
background: #18181b;
border-radius: 16px;
padding: 24px;
}
.modal-title {
display: block;
font-size: 17px;
font-weight: 600;
color: #f4f4f5;
text-align: center;
margin-bottom: 20px;
}
.modal-input {
width: 100%;
height: 48px;
background: #27272a;
border-radius: 8px;
padding: 0 16px;
font-size: 15px;
color: #f4f4f5;
box-sizing: border-box;
}
.modal-input::placeholder {
color: #52525b;
}
.modal-btns {
display: flex;
margin-top: 24px;
gap: 12px;
}
.modal-btn {
flex: 1;
height: 44px;
border-radius: 22px;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 500;
}
.modal-btn.cancel {
background: #27272a;
color: #a1a1aa;
}
.modal-btn.confirm {
position: relative;
background: transparent;
color: #ffffff;
}
.modal-btn.confirm::before {
content: '';
position: absolute;
inset: 0;
border-radius: 22px;
padding: 1px;
background: linear-gradient(135deg, rgba(255, 243, 60, 1), rgba(225, 252, 221, 1), rgba(10, 248, 254, 1));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
}
</style>

501
src/pages/search/index.vue Normal file
View File

@@ -0,0 +1,501 @@
<template>
<view class="search-page">
<!-- 顶部导航栏返回按钮 -->
<view class="nav-header" :style="{ paddingTop: navPaddingTop + 'px' }">
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
<view class="back-btn" @click="goBack">
<image class="back-icon" src="/static/icons/Left (左).png" mode="aspectFit" />
</view>
<text class="nav-title">搜索</text>
<view class="nav-placeholder"></view>
</view>
</view>
<!-- 搜索输入框单独一行 -->
<view class="search-section">
<view class="search-input-wrap">
<image class="search-icon" src="/static/icons/search.png" mode="aspectFit" />
<input
class="search-input"
type="text"
v-model="keyword"
placeholder="搜索作品、风格、关键词..."
placeholder-class="search-placeholder"
confirm-type="search"
:focus="autoFocus"
@confirm="handleSearch"
@input="handleInput"
/>
<view class="clear-btn" v-if="keyword" @click="clearKeyword">
<text class="clear-text">×</text>
</view>
</view>
</view>
<!-- 搜索历史 -->
<view class="search-history" v-if="!hasSearched && searchHistory.length > 0">
<view class="history-header">
<text class="history-title">搜索历史</text>
<view class="clear-history" @click="clearHistory">
<image class="delete-icon" src="/static/icons/del.png" mode="aspectFit" />
</view>
</view>
<view class="history-tags">
<view
class="history-tag"
v-for="(item, index) in searchHistory"
:key="index"
@click="searchByHistory(item)"
>
<text class="history-tag-text">{{ item }}</text>
</view>
</view>
</view>
<!-- 热门搜索 -->
<view class="hot-search" v-if="!hasSearched && hotKeywords.length > 0">
<view class="hot-header">
<text class="hot-title">热门搜索</text>
</view>
<view class="hot-tags">
<view
class="hot-tag"
v-for="(item, index) in hotKeywords"
:key="index"
@click="searchByHistory(item)"
>
<text class="hot-tag-text">{{ item }}</text>
</view>
</view>
</view>
<!-- 搜索结果 -->
<view class="search-results" v-if="hasSearched">
<!-- 空状态 -->
<view class="empty-state" v-if="!isLoading && workList.length === 0">
<image class="empty-image" src="/static/icons/search.png" mode="aspectFit" />
<text class="empty-text">未找到相关作品</text>
<text class="empty-tip">换个关键词试试吧</text>
</view>
<!-- 作品列表 -->
<view class="work-list-wrap" v-else>
<WorkList
ref="workListRef"
:works="workList"
:loading="isLoading"
:finished="!hasMore"
@click="handleWorkClick"
@load-more="loadMore"
/>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onReachBottom } from '@dcloudio/uni-app'
import { searchWorks } from '@/api/work'
import WorkList from '@/components/WorkList/index.vue'
// 胶囊按钮信息
const capsuleInfo = ref({ top: 0, height: 32, width: 87, right: 10 })
const statusBarHeight = ref(0)
// 计算导航栏相关尺寸
const navPaddingTop = computed(() => capsuleInfo.value.top || statusBarHeight.value)
const navBarHeight = computed(() => capsuleInfo.value.height + 8 || 40)
const capsuleHeight = computed(() => capsuleInfo.value.height || 32)
const capsuleWidth = computed(() => capsuleInfo.value.width || 87)
const keyword = ref('')
const autoFocus = ref(true)
const hasSearched = ref(false)
const isLoading = ref(false)
const workList = ref([])
const hasMore = ref(true)
const pageNum = ref(1)
const pageSize = 10
// 搜索历史
const HISTORY_KEY = 'search_history'
const MAX_HISTORY = 10
const searchHistory = ref([])
// 热门搜索关键词
const hotKeywords = ref(['风景', '人物', '动漫', '科幻', '艺术', '写实'])
// 加载搜索历史
const loadHistory = () => {
try {
const history = uni.getStorageSync(HISTORY_KEY)
if (history) {
searchHistory.value = JSON.parse(history)
}
} catch (e) {
console.error('加载搜索历史失败', e)
}
}
// 保存搜索历史
const saveHistory = (word) => {
if (!word.trim()) return
// 去重并添加到头部
const history = searchHistory.value.filter(item => item !== word)
history.unshift(word)
// 限制数量
if (history.length > MAX_HISTORY) {
history.pop()
}
searchHistory.value = history
uni.setStorageSync(HISTORY_KEY, JSON.stringify(history))
}
// 清空历史
const clearHistory = () => {
uni.showModal({
title: '提示',
content: '确定清空搜索历史吗?',
success: (res) => {
if (res.confirm) {
searchHistory.value = []
uni.removeStorageSync(HISTORY_KEY)
}
}
})
}
// 清空关键词
const clearKeyword = () => {
keyword.value = ''
hasSearched.value = false
workList.value = []
}
// 返回
const goBack = () => {
uni.navigateBack()
}
// 处理输入
const handleInput = () => {
// 输入时重置搜索状态
if (!keyword.value.trim()) {
hasSearched.value = false
workList.value = []
}
}
// 执行搜索
const handleSearch = async () => {
const word = keyword.value.trim()
if (!word) {
uni.showToast({ title: '请输入搜索内容', icon: 'none' })
return
}
// 保存搜索历史
saveHistory(word)
// 重置状态
hasSearched.value = true
workList.value = []
pageNum.value = 1
hasMore.value = true
await loadData()
}
// 点击历史搜索
const searchByHistory = (word) => {
keyword.value = word
handleSearch()
}
// 加载数据
const loadData = async () => {
if (isLoading.value || !hasMore.value) return
isLoading.value = true
try {
const res = await searchWorks({
keyword: keyword.value.trim(),
pageNum: pageNum.value,
pageSize
})
const list = res?.list || []
if (pageNum.value === 1) {
workList.value = list
} else {
workList.value = [...workList.value, ...list]
}
hasMore.value = res?.hasNext ?? false
pageNum.value++
} catch (e) {
console.error('搜索失败', e)
} finally {
isLoading.value = false
}
}
// 加载更多
const loadMore = () => {
if (!isLoading.value && hasMore.value) {
loadData()
}
}
// 点击作品
const handleWorkClick = (work) => {
uni.navigateTo({ url: `/pages/work/detail?id=${work.id}` })
}
// 页面触底
onReachBottom(() => {
if (hasSearched.value) {
loadMore()
}
})
onMounted(() => {
// 获取系统信息
const sysInfo = uni.getSystemInfoSync()
statusBarHeight.value = sysInfo.statusBarHeight || 0
// 获取胶囊按钮信息(仅微信小程序)
// #ifdef MP-WEIXIN
try {
const menuButtonInfo = uni.getMenuButtonBoundingClientRect()
capsuleInfo.value = {
top: menuButtonInfo.top,
height: menuButtonInfo.height,
width: menuButtonInfo.width,
right: sysInfo.windowWidth - menuButtonInfo.right
}
} catch (e) {
console.warn('获取胶囊信息失败', e)
}
// #endif
loadHistory()
})
</script>
<style scoped>
.search-page {
min-height: 100vh;
background-color: #09090b;
}
/* 顶部导航栏 */
.nav-header {
position: sticky;
top: 0;
z-index: 100;
background-color: #09090b;
}
.nav-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
}
.back-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.back-icon {
width: 22px;
height: 22px;
}
.nav-title {
font-size: 17px;
font-weight: 600;
color: #f1f5f9;
}
.nav-placeholder {
width: 40px;
}
/* 搜索输入区域 */
.search-section {
padding: 12px 16px 16px;
}
.search-input-wrap {
height: 44px;
background: #18181b;
border-radius: 22px;
display: flex;
align-items: center;
padding: 0 16px;
gap: 10px;
border: 1px solid #27272a;
}
.search-icon {
width: 18px;
height: 18px;
opacity: 0.5;
flex-shrink: 0;
}
.search-input {
flex: 1;
height: 100%;
font-size: 15px;
color: #f1f5f9;
background: transparent;
}
.search-placeholder {
color: #52525b;
font-size: 15px;
}
.clear-btn {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
background: #3f3f46;
border-radius: 50%;
flex-shrink: 0;
}
.clear-text {
font-size: 14px;
color: #a1a1aa;
line-height: 1;
}
/* 搜索历史 */
.search-history {
padding: 20px 16px;
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.history-title {
font-size: 14px;
color: #a1a1aa;
font-weight: 500;
}
.clear-history {
padding: 4px;
}
.delete-icon {
width: 18px;
height: 18px;
opacity: 0.5;
}
.history-tags {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.history-tag {
background: #27272a;
border-radius: 16px;
padding: 6px 14px;
}
.history-tag-text {
font-size: 13px;
color: #e4e4e7;
}
/* 热门搜索 */
.hot-search {
padding: 0 16px 20px;
}
.hot-header {
margin-bottom: 12px;
}
.hot-title {
font-size: 14px;
color: #a1a1aa;
font-weight: 500;
}
.hot-tags {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.hot-tag {
background: #27272a;
border-radius: 16px;
padding: 6px 14px;
}
.hot-tag-text {
font-size: 13px;
color: #e4e4e7;
}
/* 搜索结果 */
.search-results {
padding: 0 10px;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 80px 0;
}
.empty-image {
width: 120px;
height: 120px;
opacity: 0.5;
margin-bottom: 16px;
}
.empty-text {
font-size: 15px;
color: #a1a1aa;
margin-bottom: 8px;
}
.empty-tip {
font-size: 13px;
color: #71717a;
}
.work-list-wrap {
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
</style>

View File

@@ -0,0 +1,259 @@
<template>
<view class="settings-page">
<!-- 顶部导航 -->
<view class="nav-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-back" @click="handleBack">
<image class="back-icon" src="/static/icons/Left (左).png" mode="aspectFit" />
</view>
<text class="nav-title">设置</text>
<view class="nav-right">
<!-- #ifdef MP-WEIXIN -->
<!-- <button class="nav-share-btn" open-type="share" plain>
<text class="share-dots">···</text>
</button> -->
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<!-- <view class="nav-share-btn" @click="showShareMenu">
<text class="share-dots">···</text>
</view> -->
<!-- #endif -->
</view>
</view>
<!-- 内容区域 -->
<view class="content" :style="{ marginTop: (statusBarHeight + 44) + 'px' }">
<!-- 第一组菜单 -->
<view class="menu-group">
<view class="menu-item" @click="handleEditProfile">
<view class="menu-left">
<image class="menu-icon" src="/static/icons/user-edit-01.png" mode="aspectFit" />
<text class="menu-text">编辑资料</text>
</view>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="handleUserAgreement">
<view class="menu-left">
<image class="menu-icon" src="/static/icons/document-validation.png" mode="aspectFit" />
<text class="menu-text">用户协议</text>
</view>
<text class="menu-arrow"></text>
</view>
</view>
<!-- 退出登录 -->
<view class="menu-group" v-if="userStore.isLogin">
<view class="menu-item logout-item" @click="handleLogout">
<view class="menu-left">
<image class="menu-icon logout-icon" src="/static/icons/logout-circle-01.png" mode="aspectFit" />
<text class="menu-text logout-text">退出登录</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useUserStore } from '@/store/modules/user'
import { useShareMixin, shareConfigs } from '@/mixins/shareMixin'
const userStore = useUserStore()
const statusBarHeight = ref(0)
// 使用分享混入
useShareMixin({
getShareConfig: () => shareConfigs.home()
})
onMounted(() => {
const sysInfo = uni.getSystemInfoSync()
statusBarHeight.value = sysInfo.statusBarHeight || 0
})
const handleBack = () => {
uni.navigateBack()
}
// 显示分享菜单
const showShareMenu = () => {
// #ifndef MP-WEIXIN
uni.showActionSheet({
itemList: ['分享给好友', '复制链接'],
success: (res) => {
if (res.tapIndex === 0) {
uni.showToast({ title: '请在微信中分享', icon: 'none' })
} else if (res.tapIndex === 1) {
const shareUrl = 'https://your-domain.com/pages/inspiration/index'
uni.setClipboardData({
data: shareUrl,
success: () => {
uni.showToast({ title: '链接已复制', icon: 'success' })
}
})
}
}
})
// #endif
}
// 编辑资料
const handleEditProfile = () => {
if (!userStore.isLogin) {
uni.navigateTo({ url: '/pages/login/index' })
return
}
uni.navigateTo({ url: '/pages/profile/edit' })
}
// 用户协议
const handleUserAgreement = () => {
uni.navigateTo({ url: '/pages/agreement/index' })
}
// 退出登录
const handleLogout = () => {
uni.showModal({
title: '提示',
content: '确定退出登录吗?',
confirmColor: '#ef4444',
success: (res) => {
if (res.confirm) {
userStore.logout()
uni.showToast({ title: '已退出登录', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 1000)
}
}
})
}
</script>
<style scoped>
.settings-page {
min-height: 100vh;
background-color: #09090b;
}
.nav-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
background: #09090b;
z-index: 100;
}
.nav-back {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.back-icon {
width: 24px;
height: 24px;
}
.nav-title {
font-size: 17px;
font-weight: 600;
color: #ffffff;
}
.nav-right {
width: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.nav-share-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
border: none;
margin: 0;
padding: 0;
}
.nav-share-btn::after {
border: none;
}
.share-dots {
color: #f4f4f5;
font-size: 16px;
font-weight: bold;
letter-spacing: 1px;
line-height: 1;
}
.content {
padding: 16px;
}
.menu-group {
background-color: #18181b;
border-radius: 12px;
overflow: hidden;
margin-bottom: 16px;
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #27272a;
}
.menu-item:last-child {
border-bottom: none;
}
.menu-left {
display: flex;
align-items: center;
}
.menu-icon {
width: 20px;
height: 20px;
}
.menu-text {
font-size: 15px;
color: #f4f4f5;
margin-left: 12px;
}
.menu-arrow {
font-size: 20px;
color: #52525b;
}
.logout-item {
border-bottom: none;
}
.logout-icon {
color: #ef4444;
}
.logout-text {
color: #ef4444;
}
</style>

895
src/pages/user/index.vue Normal file
View File

@@ -0,0 +1,895 @@
<template>
<view class="user-page">
<!-- 顶部Header与首页相同 -->
<HomeHeader logo="/static/header/1818logo.png" />
<!-- 用户信息区 -->
<view class="user-section">
<view class="user-info">
<image
class="avatar"
:src="profile.avatar || '/static/images/default-avatar.png'"
mode="aspectFill"
@click="handleAvatarClick"
/>
<view class="info-right">
<view class="name-row">
<text class="nickname">{{ profile.nickname || '点击登录' }}</text>
</view>
<view class="id-row" v-if="userStore.isLogin" @click="copyId">
<text class="user-id">ID: {{ profile.inviteCode}}</text>
<image class="copy-icon" src="/static/icons/copy.png" mode="aspectFit" />
</view>
</view>
<view class="header-actions">
<!-- #ifdef MP-WEIXIN -->
<!-- <button class="action-btn share-btn" open-type="share" plain>
<text class="action-dots">···</text>
</button> -->
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<view class="action-btn share-btn" @click="showShareMenu">
<text class="action-dots">···</text>
</view>
<!-- #endif -->
<view class="notice-btn" @click="handleNotice">
<image class="notice-icon" src="/static/icons/notice.svg" mode="aspectFit" />
<view class="notice-dot" v-if="hasUnread" />
</view>
<view class="settings-btn" @click="handleSettings">
<image class="settings-icon" src="/static/icons/setting.png" mode="aspectFit" />
</view>
</view>
</view>
<!-- 统计区 -->
<view class="stats-row">
<view class="stat-item">
<text class="stat-value">{{ profile.publishCount || 0 }}</text>
<text class="stat-label">发布</text>
</view>
<view class="stat-item">
<text class="stat-value">{{ profile.likedCount || 0 }}</text>
<text class="stat-label">获赞</text>
</view>
<view class="points-badge" @click="handlePointsClick">
<image class="points-icon" src="/static/icons/points.png" mode="aspectFit" />
<text class="points-value">{{ profile.points || 0 }} | 订阅优惠</text>
</view>
</view>
<!-- Tab切换 + 邀请按钮 -->
<view class="tab-row">
<view class="tabs">
<view
class="tab-item"
:class="{ active: activeTab === 'publish' }"
@click="switchTab('publish')"
>
<text class="tab-text">发布</text>
<view class="tab-indicator" v-if="activeTab === 'publish'" />
</view>
<view
class="tab-item"
:class="{ active: activeTab === 'liked' }"
@click="switchTab('liked')"
>
<text class="tab-text">点赞</text>
<view class="tab-indicator" v-if="activeTab === 'liked'" />
</view>
</view>
<view class="invite-btn" @click="handleInvite">
<image class="invite-icon" src="/static/icons/invite.png" mode="aspectFit" />
<text class="invite-text">邀请好友</text>
</view>
</view>
</view>
<!-- 作品列表 -->
<view class="works-container">
<WorkList
v-if="activeTab === 'publish'"
:works="publishWorks"
:loading="publishLoading"
:finished="publishFinished"
:page-visible="pageVisible"
@load-more="loadMorePublish"
@item-click="handleWorkClick"
@like-change="handleLikeChange"
/>
<WorkList
v-else
:works="likedWorks"
:loading="likedLoading"
:finished="likedFinished"
:page-visible="pageVisible"
@load-more="loadMoreLiked"
@item-click="handleWorkClick"
@like-change="handleLikeChange"
/>
<!-- 空状态 -->
<view class="empty-state" v-if="showEmpty">
<text class="empty-text">{{ activeTab === 'publish' ? '还没有发布作品' : '还没有点赞作品' }}</text>
</view>
</view>
<TabBar :current="3" />
<!-- 公告弹窗 -->
<view class="notice-mask" v-if="showNoticePopup" @click="closeNotice">
<view class="notice-popup" @click.stop>
<view class="notice-popup-header">
<text class="notice-popup-title">公告</text>
<view class="notice-close-btn" @click="closeNotice">
<text class="notice-close-icon"></text>
</view>
</view>
<scroll-view class="notice-popup-body" scroll-y>
<view v-if="noticeLoading" class="notice-loading">
<text class="notice-loading-text">加载中...</text>
</view>
<view v-else-if="noticeList.length === 0" class="notice-empty">
<text class="notice-empty-text">暂无公告</text>
</view>
<view v-else>
<view
class="notice-item"
v-for="item in noticeList"
:key="item.id"
@click="handleNoticeItemClick(item)"
>
<view class="notice-item-header">
<text class="notice-item-title">{{ item.title }}</text>
<text class="notice-item-time">{{ item.createdAt }}</text>
</view>
<text class="notice-item-content">{{ item.content }}</text>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onShow, onHide, onLoad } from '@dcloudio/uni-app'
import { useUserStore } from '@/store/modules/user'
import { getUserProfile, getUserWorks, getUserLikedWorks } from '@/api/user'
import { getNoticeList, getUnreadCount, markNoticeRead, markAllNoticeRead } from '@/api/notice'
import HomeHeader from '@/components/HomeHeader/index.vue'
import WorkList from '@/components/WorkList/index.vue'
import TabBar from '@/components/TabBar/index.vue'
import { handleShareCode } from '@/utils/navigation'
const userStore = useUserStore()
// 用户信息
const profile = ref({})
// Tab状态
const activeTab = ref('publish')
// 发布作品
const publishWorks = ref([])
const publishPage = ref(1)
const publishLoading = ref(false)
const publishFinished = ref(false)
// 点赞作品
const likedWorks = ref([])
const likedPage = ref(1)
const likedLoading = ref(false)
const likedFinished = ref(false)
const pageVisible = ref(true)
const MAX_LIST_SIZE = 100
onHide(() => { pageVisible.value = false })
// 空状态
const showEmpty = computed(() => {
if (!userStore.isLogin) return false
if (activeTab.value === 'publish') {
return !publishLoading.value && publishWorks.value.length === 0
}
return !likedLoading.value && likedWorks.value.length === 0
})
// 公告相关
const showNoticePopup = ref(false)
const noticeList = ref([])
const noticeLoading = ref(false)
const hasUnread = ref(false)
const unreadCount = ref(0)
// 加载用户信息
const loadProfile = async () => {
if (!userStore.isLogin) return
try {
const res = await getUserProfile()
profile.value = res
// 同步积分到 store确保其他页面显示一致
userStore.updatePoints(res.points)
} catch (e) {
console.error('获取用户信息失败', e)
}
}
// 加载发布作品
const loadPublishWorks = async (refresh = false) => {
if (!userStore.isLogin) return
if (publishLoading.value) return
if (!refresh && publishFinished.value) return
publishLoading.value = true
if (refresh) {
publishPage.value = 1
publishFinished.value = false
}
try {
const res = await getUserWorks({ pageNum: publishPage.value, pageSize: 10 })
if (refresh) {
publishWorks.value = res.list || []
} else {
publishWorks.value = [...publishWorks.value, ...(res.list || [])]
}
if (publishWorks.value.length > MAX_LIST_SIZE) {
publishWorks.value = publishWorks.value.slice(-MAX_LIST_SIZE)
}
publishFinished.value = publishWorks.value.length >= res.total
publishPage.value++
} catch (e) {
console.error('获取发布作品失败', e)
} finally {
publishLoading.value = false
}
}
// 加载点赞作品
const loadLikedWorks = async (refresh = false) => {
if (!userStore.isLogin) return
if (likedLoading.value) return
if (!refresh && likedFinished.value) return
likedLoading.value = true
if (refresh) {
likedPage.value = 1
likedFinished.value = false
}
try {
const res = await getUserLikedWorks({ pageNum: likedPage.value, pageSize: 10 })
if (refresh) {
likedWorks.value = res.list || []
} else {
likedWorks.value = [...likedWorks.value, ...(res.list || [])]
}
if (likedWorks.value.length > MAX_LIST_SIZE) {
likedWorks.value = likedWorks.value.slice(-MAX_LIST_SIZE)
}
likedFinished.value = likedWorks.value.length >= res.total
likedPage.value++
} catch (e) {
console.error('获取点赞作品失败', e)
} finally {
likedLoading.value = false
}
}
// 切换Tab
const switchTab = (tab) => {
activeTab.value = tab
if (tab === 'publish' && publishWorks.value.length === 0) {
loadPublishWorks(true)
} else if (tab === 'liked' && likedWorks.value.length === 0) {
loadLikedWorks(true)
}
}
// 加载更多
const loadMorePublish = () => loadPublishWorks()
const loadMoreLiked = () => loadLikedWorks()
// 点击头像
const handleAvatarClick = () => {
if (!userStore.isLogin) {
uni.navigateTo({ url: '/pages/login/index' })
}
}
// 复制ID
const copyId = () => {
const id = profile.value.inviteCode || profile.value.userId
if (id) {
uni.setClipboardData({
data: String(id),
success: () => {
uni.showToast({ title: '已复制', icon: 'success' })
}
})
}
}
// 设置
const handleSettings = () => {
uni.navigateTo({ url: '/pages/settings/index' })
}
// 公告按钮
const handleNotice = async () => {
if (!userStore.isLogin) {
uni.navigateTo({ url: '/pages/login/index' })
return
}
showNoticePopup.value = true
await loadNoticeList()
// 标记所有公告已读
if (unreadCount.value > 0) {
try {
await markAllNoticeRead()
hasUnread.value = false
unreadCount.value = 0
} catch (e) {
// 静默失败
}
}
}
// 加载公告列表
const loadNoticeList = async () => {
if (!userStore.isLogin) return
if (noticeLoading.value) return
noticeLoading.value = true
try {
const res = await getNoticeList({ page: 1, pageSize: 20 })
noticeList.value = res.list || []
} catch (e) {
console.error('获取公告列表失败', e)
noticeList.value = []
} finally {
noticeLoading.value = false
}
}
// 检查是否有未读公告(需登录)
const checkUnreadNotice = async () => {
if (!userStore.isLogin) {
hasUnread.value = false
return
}
try {
const count = await getUnreadCount()
unreadCount.value = count || 0
hasUnread.value = unreadCount.value > 0
} catch (e) {
// 静默失败
}
}
// 关闭公告
const closeNotice = () => {
showNoticePopup.value = false
}
// 点击公告详情
const handleNoticeItemClick = async (item) => {
// 已登录时标记该条已读
if (userStore.isLogin) {
try {
await markNoticeRead(item.id)
} catch (e) {
// 静默失败
}
}
}
// 邀请好友
const handleInvite = () => {
if (!userStore.isLogin) {
uni.navigateTo({ url: '/pages/login/index' })
return
}
uni.navigateTo({ url: '/pages/invite/index' })
}
// 点击积分
const handlePointsClick = () => {
uni.navigateTo({ url: '/pages/points/subscribe' })
}
// 显示分享菜单
const showShareMenu = () => {
// #ifndef MP-WEIXIN
uni.showActionSheet({
itemList: ['分享给好友', '复制链接'],
success: (res) => {
if (res.tapIndex === 0) {
uni.showToast({ title: '请在微信中分享', icon: 'none' })
} else if (res.tapIndex === 1) {
const shareUrl = 'https://your-domain.com/pages/inspiration/index'
uni.setClipboardData({
data: shareUrl,
success: () => {
uni.showToast({ title: '链接已复制', icon: 'success' })
}
})
}
}
})
// #endif
}
// 点击作品
const handleWorkClick = (work) => {
uni.navigateTo({ url: `/pages/work/detail?id=${work.id}` })
}
// 点赞变化
const handleLikeChange = (data) => {
// 更新发布列表
const publishIndex = publishWorks.value.findIndex(w => w.id === data.id)
if (publishIndex > -1) {
publishWorks.value[publishIndex].liked = data.liked
publishWorks.value[publishIndex].likeCount = data.likeCount
}
// 更新点赞列表
const likedIndex = likedWorks.value.findIndex(w => w.id === data.id)
if (likedIndex > -1) {
if (!data.liked) {
// 取消点赞,从列表移除
likedWorks.value.splice(likedIndex, 1)
} else {
likedWorks.value[likedIndex].liked = data.liked
likedWorks.value[likedIndex].likeCount = data.likeCount
}
}
}
// 清空用户数据
const clearUserData = () => {
profile.value = {}
publishWorks.value = []
likedWorks.value = []
publishPage.value = 1
likedPage.value = 1
publishFinished.value = false
likedFinished.value = false
}
// 处理页面参数(分享码)
onLoad((options) => {
console.log('=== 用户页面加载 ===')
console.log('页面参数:', options)
// 处理分享码逻辑
const shareResult = handleShareCode(options)
console.log('用户页面分享码处理结果:', shareResult)
})
onMounted(() => {
checkUnreadNotice()
if (userStore.isLogin) {
loadProfile()
loadPublishWorks(true)
}
})
onShow(() => {
pageVisible.value = true
checkUnreadNotice()
if (userStore.isLogin) {
loadProfile()
// 刷新当前tab数据
if (activeTab.value === 'publish') {
loadPublishWorks(true)
} else {
loadLikedWorks(true)
}
} else {
// 未登录时清空数据
clearUserData()
}
})
</script>
<style scoped>
.user-page {
min-height: 100vh;
background-color: #09090b;
padding-bottom: 80px;
}
.user-section {
padding: 16px;
}
.user-info {
display: flex;
align-items: center;
}
.avatar {
width: 64px;
height: 64px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.1);
}
.info-right {
flex: 1;
margin-left: 16px;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.action-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
border: none;
margin: 0;
padding: 0;
}
.action-btn::after {
border: none;
}
.action-dots {
color: #fff;
font-size: 18px;
font-weight: bold;
letter-spacing: 1px;
line-height: 1;
}
.name-row {
display: flex;
align-items: center;
}
.nickname {
font-size: 18px;
color: #f4f4f5;
font-weight: 600;
}
.id-row {
display: flex;
align-items: center;
margin-top: 6px;
}
.user-id {
font-size: 13px;
color: #71717a;
}
.copy-icon {
width: 14px;
height: 14px;
margin-left: 6px;
opacity: 0.5;
}
.settings-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
}
.settings-icon {
width: 20px;
height: 20px;
opacity: 0.7;
}
/* 统计区 */
.stats-row {
display: flex;
align-items: center;
margin-top: 20px;
padding: 0 8px;
}
.stat-item {
display: flex;
align-items: baseline;
margin-right: 24px;
}
.stat-value {
font-size: 18px;
color: #f4f4f5;
font-weight: 600;
}
.stat-label {
font-size: 13px;
color: #71717a;
margin-left: 4px;
}
.points-badge {
position: relative;
display: flex;
align-items: center;
background: transparent;
padding: 6px 14px;
border-radius: 16px;
margin-left: auto;
}
.points-badge::before {
content: '';
position: absolute;
inset: 0;
border-radius: 16px;
padding: 1px;
background: linear-gradient(135deg, rgba(255, 243, 60, 1), rgba(225, 252, 221, 1), rgba(10, 248, 254, 1));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
}
.points-icon {
width: 16px;
height: 16px;
}
.points-value {
font-size: 14px;
font-weight: 600;
margin-left: 6px;
background: linear-gradient(90deg, #FFF33C 0%, #E1FCDD 23.41%, #0AF8FE 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* Tab区 */
.tab-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 24px;
padding: 0 8px;
}
.tabs {
display: flex;
align-items: center;
}
.tab-item {
position: relative;
padding: 8px 0;
margin-right: 24px;
}
.tab-text {
font-size: 15px;
color: #71717a;
}
.tab-item.active .tab-text {
color: #f4f4f5;
font-weight: 500;
}
.tab-indicator {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 20px;
height: 2px;
background: #f4f4f5;
border-radius: 1px;
}
.invite-btn {
display: flex;
align-items: center;
justify-content: center;
width: 102px;
height: 28px;
box-sizing: border-box;
border: 1px solid rgba(24, 24, 27, 1);
border-radius: 8px;
box-shadow: inset 1px 1px 4.1px 0px rgba(255, 255, 255, 0.19);
background: rgba(24, 24, 27, 1);
}
.invite-icon {
width: 18px;
height: 18px;
}
.invite-text {
font-size: 12px;
font-weight: 500;
margin-left: 4px;
background: linear-gradient(90deg, rgba(255, 40, 147, 1), rgba(255, 203, 118, 1), rgba(255, 244, 203, 1));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* 作品列表 */
.works-container {
padding: 0 16px;
min-height: 200px;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
padding: 60px 0;
}
.empty-text {
font-size: 14px;
color: #52525b;
}
/* 公告按钮 */
.notice-btn {
position: relative;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
}
.notice-icon {
width: 20px;
height: 20px;
opacity: 0.7;
}
.notice-dot {
position: absolute;
top: 6px;
right: 6px;
width: 8px;
height: 8px;
background: #ef4444;
border-radius: 50%;
border: 1.5px solid #09090b;
}
/* 公告弹窗 */
.notice-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
}
.notice-popup {
width: 85%;
max-height: 70vh;
background: #18181b;
border-radius: 16px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.notice-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.notice-popup-title {
font-size: 17px;
font-weight: 600;
color: #f4f4f5;
}
.notice-close-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
}
.notice-close-icon {
font-size: 14px;
color: #a1a1aa;
}
.notice-popup-body {
max-height: 55vh;
padding: 12px 0;
}
.notice-loading,
.notice-empty {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 0;
}
.notice-loading-text,
.notice-empty-text {
font-size: 14px;
color: #71717a;
}
.notice-item {
padding: 14px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.notice-item:last-child {
border-bottom: none;
}
.notice-item-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.notice-item-title {
font-size: 15px;
font-weight: 500;
color: #f4f4f5;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notice-item-time {
font-size: 12px;
color: #52525b;
margin-left: 12px;
flex-shrink: 0;
}
.notice-item-content {
font-size: 13px;
color: #a1a1aa;
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

1139
src/pages/work/detail.vue Normal file

File diff suppressed because it is too large Load Diff

544
src/pages/work/publish.vue Normal file
View File

@@ -0,0 +1,544 @@
<template>
<view class="page">
<!-- 导航栏 -->
<view class="nav-bar" :style="{ paddingTop: safeAreaInsets.top + 'px' }">
<view class="nav-back" @tap="goBack">
<text class="back-icon"></text>
</view>
<text class="nav-title">发布</text>
<view class="nav-right">
<view class="more-btn">
<text class="more-icon">···</text>
</view>
<view class="help-btn">
<text class="help-icon">?</text>
</view>
</view>
</view>
<!-- 内容区域 -->
<view class="content">
<!-- 作品预览 -->
<view class="preview-wrap">
<video
v-if="isVideo && pageVisible"
:src="contentUrl"
class="preview-media"
:controls="false"
:show-play-btn="true"
object-fit="cover"
/>
<view v-else-if="isVideo" class="preview-media" style="background:#000;"></view>
<image
v-else
:src="contentUrl"
class="preview-media"
mode="aspectFit"
/>
</view>
<!-- 标题输入 -->
<view class="title-wrap">
<textarea
v-model="form.title"
class="title-input"
placeholder="添加作品标题"
maxlength="20"
:auto-height="true"
placeholder-class="title-placeholder"
/>
<text class="char-count">{{ form.title.length }}/20</text>
</view>
<!-- 分类选择 - 隐藏样式点击触发弹窗 -->
<view class="category-selector-hidden" @tap="showCategoryPicker = true">
<text class="category-label">{{ selectedCategory ? selectedCategory.name : '选择分类' }}</text>
<text class="category-arrow"></text>
</view>
</view>
<!-- 底部发布按钮 -->
<view class="footer" :style="{ paddingBottom: safeAreaInsets.bottom + 'px' }">
<button
class="publish-btn"
:disabled="!canPublish || publishing"
@tap="handlePublish"
>
<image src="/static/icons/navigation-03.png" class="btn-icon" mode="aspectFit" />
<text class="btn-text">{{ publishing ? '发布中...' : '发布作品' }}</text>
</button>
</view>
<!-- 分类选择弹窗 -->
<view class="category-popup" v-if="showCategoryPicker" @tap="showCategoryPicker = false">
<view class="popup-mask"></view>
<view class="popup-content" @tap.stop>
<view class="popup-header">
<text class="popup-title">选择分类</text>
<view class="popup-close" @tap="showCategoryPicker = false">
<text class="close-icon">×</text>
</view>
</view>
<view class="category-list">
<view
v-for="item in categories"
:key="item.id"
class="category-item"
:class="{ active: selectedCategory && selectedCategory.id === item.id }"
@tap="selectCategory(item)"
>
<text class="category-name">{{ item.name }}</text>
<text v-if="selectedCategory && selectedCategory.id === item.id" class="check-icon"></text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onLoad, onShow, onHide } from '@dcloudio/uni-app'
import { useSafeArea } from '@/hooks/useSafeArea'
import { getAiTask } from '@/api/ai'
import { getCategories } from '@/api/category'
import { publishWork } from '@/api/work'
const { safeAreaInsets } = useSafeArea()
const taskId = ref(null)
const task = ref({})
const contentUrl = ref('')
const isVideo = ref(false)
const form = ref({
title: '',
description: '',
categoryId: null
})
const categories = ref([])
const selectedCategory = ref(null)
const showCategoryPicker = ref(false)
const publishing = ref(false)
const pageVisible = ref(true)
onHide(() => { pageVisible.value = false })
onShow(() => { pageVisible.value = true })
// 是否可以发布
const canPublish = computed(() => {
return form.value.title.trim() && form.value.categoryId
})
onLoad((options) => {
taskId.value = options.taskId
if (!taskId.value) {
uni.showToast({ title: '缺少任务ID', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1500)
return
}
loadTask()
loadCategories()
})
const goBack = () => {
uni.navigateBack()
}
// 加载任务详情
const loadTask = async () => {
try {
const res = await getAiTask(taskId.value)
task.value = res
// 解析内容URL
if (res.outputResult) {
try {
const result = JSON.parse(res.outputResult)
contentUrl.value = result.result || result.url || result.image_url || ''
} catch (e) {
contentUrl.value = res.outputResult
}
}
// 判断是否是视频
isVideo.value = res.modelCode === 'tencent-sora2-video' || res.modelCode === 'sora2-video' || res.modelCode === 'tencent-aigc-video' || res.modelCode === 'grok-video'
// 解析输入参数,自动填充描述(不是标题)
if (res.inputParams) {
try {
const params = JSON.parse(res.inputParams)
if (params.prompt) {
// 将prompt作为描述的默认值
form.value.description = params.prompt
}
} catch (e) {
console.error('解析inputParams失败:', e)
}
}
} catch (e) {
console.error('加载任务失败:', e)
uni.showToast({ title: '加载失败', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1500)
}
}
// 加载分类列表
const loadCategories = async () => {
try {
const res = await getCategories()
categories.value = res || []
} catch (e) {
console.error('加载分类失败:', e)
}
}
// 选择分类
const selectCategory = (item) => {
selectedCategory.value = item
form.value.categoryId = item.id
showCategoryPicker.value = false
}
// 发布作品
const handlePublish = async () => {
if (!canPublish.value || publishing.value) return
publishing.value = true
try {
console.log('=== 发布作品请求 ===')
console.log('taskId:', taskId.value)
console.log('表单数据:', {
taskId: taskId.value,
title: form.value.title.trim(),
description: form.value.description.trim(),
categoryId: form.value.categoryId
})
await publishWork({
taskId: taskId.value,
title: form.value.title.trim(),
description: form.value.description.trim(),
categoryId: form.value.categoryId
})
uni.showToast({
title: '发布成功,等待审核',
icon: 'success',
duration: 2000
})
setTimeout(() => {
uni.navigateBack()
}, 2000)
} catch (e) {
console.error('发布失败:', e)
uni.showToast({
title: e?.data?.message || '发布失败',
icon: 'none'
})
} finally {
publishing.value = false
}
}
</script>
<style scoped>
.page {
min-height: 100vh;
background-color: #09090b;
display: flex;
flex-direction: column;
}
/* 导航栏 */
.nav-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
height: 44px;
box-sizing: content-box;
background-color: #09090b;
}
.nav-back {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.back-icon {
color: #f4f4f5;
font-size: 28px;
font-weight: 300;
}
.nav-title {
color: #f4f4f5;
font-size: 17px;
font-weight: 600;
}
.nav-right {
display: flex;
align-items: center;
gap: 12px;
}
.more-btn,
.help-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background-color: #18181b;
border-radius: 50%;
}
.more-icon,
.help-icon {
color: #a1a1aa;
font-size: 16px;
}
/* 内容区域 */
.content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
/* 预览区域 */
.preview-wrap {
width: 100%;
height: 400px;
background-color: #000;
border-radius: 16px;
overflow: hidden;
margin-bottom: 20px;
}
.preview-media {
width: 100%;
height: 100%;
}
/* 标题输入区域 */
.title-wrap {
position: relative;
background-color: #18181b;
border-radius: 12px;
border: 1px solid #27272a;
padding: 14px 16px;
margin-bottom: 16px;
}
.title-input {
width: 100%;
min-height: 24px;
color: #f4f4f5;
font-size: 16px;
line-height: 1.5;
background-color: transparent;
border: none;
box-sizing: border-box;
}
.title-placeholder {
color: #52525b;
}
.char-count {
position: absolute;
right: 16px;
bottom: 14px;
color: #52525b;
font-size: 12px;
pointer-events: none;
}
/* 分类选择器(隐藏样式) */
.category-selector-hidden {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
background-color: #18181b;
border-radius: 12px;
border: 1px solid #27272a;
margin-bottom: 16px;
}
.category-label {
color: #f4f4f5;
font-size: 15px;
flex: 1;
}
.category-label:empty::before {
content: '选择分类';
color: #52525b;
}
.category-arrow {
color: #71717a;
font-size: 20px;
font-weight: 300;
margin-left: 8px;
}
/* 底部按钮 */
.footer {
padding: 12px 16px;
background-color: #09090b;
border-top: 1px solid #27272a;
}
.publish-btn {
width: 100%;
height: 50px;
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
border-radius: 12px;
color: #fff;
font-size: 16px;
font-weight: 600;
border: none;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3);
}
.publish-btn::after {
border: none;
}
.publish-btn[disabled] {
background: #27272a;
color: #52525b;
box-shadow: none;
}
.btn-icon {
width: 20px;
height: 20px;
}
.btn-text {
font-size: 16px;
}
/* 分类选择弹窗 */
.category-popup {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
display: flex;
align-items: flex-end;
}
.popup-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
}
.popup-content {
position: relative;
width: 100%;
max-height: 70vh;
background-color: #18181b;
border-radius: 24px 24px 0 0;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 20px 16px;
border-bottom: 1px solid #27272a;
}
.popup-title {
color: #f4f4f5;
font-size: 17px;
font-weight: 600;
}
.popup-close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background-color: #27272a;
border-radius: 50%;
}
.close-icon {
color: #a1a1aa;
font-size: 24px;
line-height: 1;
}
.category-list {
max-height: 50vh;
overflow-y: auto;
padding: 8px 0;
}
.category-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
transition: background-color 0.2s;
}
.category-item.active {
background-color: rgba(249, 115, 22, 0.1);
}
.category-name {
color: #f4f4f5;
font-size: 15px;
flex: 1;
}
.category-item.active .category-name {
color: #f97316;
font-weight: 500;
}
.check-icon {
color: #f97316;
font-size: 20px;
font-weight: bold;
}
</style>

82
src/project.config.json Normal file
View File

@@ -0,0 +1,82 @@
{
"description": "项目配置文件",
"packOptions": {
"ignore": [
{
"type": "file",
"value": ".eslintrc.js"
}
]
},
"setting": {
"urlCheck": false,
"es6": true,
"enhance": true,
"postcss": true,
"preloadBackgroundData": false,
"minified": true,
"newFeature": false,
"coverView": true,
"nodeModules": false,
"autoAudits": false,
"showShadowRootInWxmlPanel": true,
"scopeDataCheck": false,
"uglifyFileName": false,
"checkInvalidKey": true,
"checkSiteMap": true,
"uploadWithSourceMap": true,
"compileHotReLoad": false,
"lazyloadPlaceholderEnable": false,
"useMultiFrameRuntime": true,
"useApiHook": true,
"useApiHostProcess": true,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
},
"enableEngineNative": false,
"useIsolateContext": true,
"userConfirmedBundleSwitch": false,
"packNpmManually": false,
"packNpmRelationList": [],
"minifyWXSS": true,
"disableUseStrict": false,
"minifyWXML": true,
"showES6CompileOption": false,
"useCompilerPlugins": false
},
"compileType": "miniprogram",
"libVersion": "2.19.4",
"appid": "wxe09413e19ac0c02c",
"projectname": "1818AIGC",
"debugOptions": {
"hidedInDevtools": []
},
"scripts": {},
"staticServerOptions": {
"baseURL": "",
"servePath": ""
},
"isGameTourist": false,
"condition": {
"search": {
"list": []
},
"conversation": {
"list": []
},
"game": {
"list": []
},
"plugin": {
"list": []
},
"gamePlugin": {
"list": []
},
"miniprogram": {
"list": []
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
src/static/icons/1,1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 B

BIN
src/static/icons/16,9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
src/static/icons/9,16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 790 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
src/static/icons/assets.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1016 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 B

BIN
src/static/icons/copy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 B

BIN
src/static/icons/create.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
src/static/icons/del.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 B

BIN
src/static/icons/five-o.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
src/static/icons/five.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
src/static/icons/four-o.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
src/static/icons/four.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Some files were not shown because too many files have changed in this diff Show More