first commit
26
.eslintrc.js
Normal 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
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
*.local
|
||||
.env.local
|
||||
.env.*.local
|
||||
unpackage
|
||||
2
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
{
|
||||
}
|
||||
80
KEYBOARD_UPLOAD_FEATURE.md
Normal 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
@@ -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 地址
|
||||
250
docs/分享功能验证指南.md
Normal 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
@@ -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
39
package.json
Normal 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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
426
src/App.vue
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 })
|
||||
453
src/components/AiTaskForm/index.vue
Normal 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>
|
||||
44
src/components/BaseButton/index.vue
Normal 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>
|
||||
189
src/components/CategoryBar/index.vue
Normal 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>
|
||||
89
src/components/CustomNavbar/index.vue
Normal 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>
|
||||
411
src/components/HomeBanner/index.vue
Normal 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('开始获取Banner,position:', 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>
|
||||
111
src/components/HomeHeader/index.vue
Normal 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>
|
||||
427
src/components/MediaPreview/index.vue
Normal 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>
|
||||
167
src/components/ProgressSteps/index.vue
Normal 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>
|
||||
37
src/components/SafeAreaView/index.vue
Normal 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>
|
||||
169
src/components/TabBar/index.vue
Normal 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>
|
||||
747
src/components/TaskItem/index.vue
Normal 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>
|
||||
222
src/components/WorkCard/index.vue
Normal 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>
|
||||
317
src/components/WorkList/index.vue
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": "您的位置信息将用于小程序位置接口的效果展示"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
314
src/mixins/globalShareMixin.js
Normal 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
@@ -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
@@ -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": "我的"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
182
src/pages/agreement/index.vue
Normal 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>
|
||||
192
src/pages/agreement/payment.vue
Normal 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
@@ -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
241
src/pages/ai/models.vue
Normal 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
@@ -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
1548
src/pages/create/video-compose.vue
Normal file
1130
src/pages/create/video-create-character-edit.vue
Normal file
809
src/pages/create/video-create-character.vue
Normal 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>
|
||||
621
src/pages/create/video-create-settings.vue
Normal 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>
|
||||
3422
src/pages/create/video-create-storyboard-result.vue
Normal file
1018
src/pages/create/video-create-storyboard.vue
Normal file
1747
src/pages/create/video-create.vue
Normal file
620
src/pages/create/video.vue
Normal 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
776
src/pages/dream/detail.vue
Normal 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
@@ -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>
|
||||
410
src/pages/inspiration/index.vue
Normal 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
@@ -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
1035
src/pages/points/subscribe.vue
Normal file
409
src/pages/profile/edit.vue
Normal 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
@@ -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>
|
||||
259
src/pages/settings/index.vue
Normal 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
@@ -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
544
src/pages/work/publish.vue
Normal 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
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
src/static/header/1818logo.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
src/static/icons/1,1.png
Normal file
|
After Width: | Height: | Size: 571 B |
BIN
src/static/icons/16,9.png
Normal file
|
After Width: | Height: | Size: 598 B |
BIN
src/static/icons/1818AIGC.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/static/icons/9,16.png
Normal file
|
After Width: | Height: | Size: 608 B |
BIN
src/static/icons/Left (左).png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src/static/icons/Reference.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src/static/icons/To-bottom.png
Normal file
|
After Width: | Height: | Size: 790 B |
BIN
src/static/icons/add-line.png
Normal file
|
After Width: | Height: | Size: 205 B |
BIN
src/static/icons/ai-generate-3d-line.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/static/icons/ai-generate-text.png
Normal file
|
After Width: | Height: | Size: 362 B |
BIN
src/static/icons/arrow-down-s-line.png
Normal file
|
After Width: | Height: | Size: 491 B |
BIN
src/static/icons/assets-active.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
src/static/icons/assets.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src/static/icons/bard-fill.png
Normal file
|
After Width: | Height: | Size: 1016 B |
BIN
src/static/icons/closed-captioning-ai-line.png
Normal file
|
After Width: | Height: | Size: 430 B |
BIN
src/static/icons/copy.png
Normal file
|
After Width: | Height: | Size: 504 B |
BIN
src/static/icons/create.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
src/static/icons/del.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src/static/icons/document-validation.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
src/static/icons/edit-fill.png
Normal file
|
After Width: | Height: | Size: 490 B |
BIN
src/static/icons/fenjing.png
Normal file
|
After Width: | Height: | Size: 244 B |
BIN
src/static/icons/five-o.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/static/icons/five.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/static/icons/folder-02.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src/static/icons/four-o.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
src/static/icons/four.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |