mirror of
https://github.com/yangjian102621/geekai.git
synced 2026-04-22 11:04:26 +08:00
完成移动端邀请页面功能
This commit is contained in:
@@ -8,14 +8,17 @@ package handler
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"geekai/core"
|
||||
"geekai/store/model"
|
||||
"geekai/store/vo"
|
||||
"geekai/utils"
|
||||
"geekai/utils/resp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// InviteHandler 用户邀请
|
||||
@@ -33,6 +36,8 @@ func (h *InviteHandler) RegisterRoutes() {
|
||||
group.GET("code", h.Code)
|
||||
group.GET("list", h.List)
|
||||
group.GET("hits", h.Hits)
|
||||
group.GET("stats", h.Stats)
|
||||
group.GET("rules", h.Rules)
|
||||
}
|
||||
|
||||
// Code 获取当前用户邀请码
|
||||
@@ -73,21 +78,34 @@ func (h *InviteHandler) List(c *gin.Context) {
|
||||
var total int64
|
||||
session.Model(&model.InviteLog{}).Count(&total)
|
||||
var items []model.InviteLog
|
||||
var list = make([]vo.InviteLog, 0)
|
||||
offset := (page - 1) * pageSize
|
||||
res := session.Order("id DESC").Offset(offset).Limit(pageSize).Find(&items)
|
||||
if res.Error == nil {
|
||||
for _, item := range items {
|
||||
var v vo.InviteLog
|
||||
err := utils.CopyObject(item, &v)
|
||||
if err == nil {
|
||||
v.Id = item.Id
|
||||
v.CreatedAt = item.CreatedAt.Unix()
|
||||
list = append(list, v)
|
||||
} else {
|
||||
logger.Error(err)
|
||||
}
|
||||
err := session.Order("id DESC").Offset(offset).Limit(pageSize).Find(&items).Error
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
userIds := make([]uint, 0)
|
||||
for _, item := range items {
|
||||
userIds = append(userIds, item.UserId)
|
||||
}
|
||||
userMap := make(map[uint]model.User)
|
||||
var users []model.User
|
||||
h.DB.Model(&model.User{}).Where("id IN (?)", userIds).Find(&users)
|
||||
for _, user := range users {
|
||||
userMap[user.Id] = user
|
||||
}
|
||||
|
||||
var list = make([]vo.InviteLog, 0)
|
||||
for _, item := range items {
|
||||
var v vo.InviteLog
|
||||
err := utils.CopyObject(item, &v)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
v.CreatedAt = item.CreatedAt.Unix()
|
||||
v.Avatar = userMap[item.UserId].Avatar
|
||||
list = append(list, v)
|
||||
}
|
||||
resp.SUCCESS(c, vo.NewPage(total, page, pageSize, list))
|
||||
}
|
||||
@@ -98,3 +116,89 @@ func (h *InviteHandler) Hits(c *gin.Context) {
|
||||
h.DB.Model(&model.InviteCode{}).Where("code = ?", code).UpdateColumn("hits", gorm.Expr("hits + ?", 1))
|
||||
resp.SUCCESS(c)
|
||||
}
|
||||
|
||||
// Stats 获取邀请统计
|
||||
func (h *InviteHandler) Stats(c *gin.Context) {
|
||||
userId := h.GetLoginUserId(c)
|
||||
|
||||
// 获取邀请码
|
||||
var inviteCode model.InviteCode
|
||||
res := h.DB.Where("user_id = ?", userId).First(&inviteCode)
|
||||
if res.Error != nil {
|
||||
resp.ERROR(c, "邀请码不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// 统计累计邀请数
|
||||
var totalInvite int64
|
||||
h.DB.Model(&model.InviteLog{}).Where("inviter_id = ?", userId).Count(&totalInvite)
|
||||
|
||||
// 统计今日邀请数
|
||||
today := time.Now().Format("2006-01-02")
|
||||
var todayInvite int64
|
||||
h.DB.Model(&model.InviteLog{}).Where("inviter_id = ? AND DATE(created_at) = ?", userId, today).Count(&todayInvite)
|
||||
|
||||
// 获取系统配置中的邀请奖励
|
||||
var config model.Config
|
||||
var invitePower int = 200 // 默认值
|
||||
if h.DB.Where("name = ?", "system").First(&config).Error == nil {
|
||||
var configMap map[string]any
|
||||
if utils.JsonDecode(config.Value, &configMap) == nil {
|
||||
if power, ok := configMap["invite_power"].(float64); ok {
|
||||
invitePower = int(power)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算获得奖励总数
|
||||
rewardTotal := int(totalInvite) * invitePower
|
||||
|
||||
// 构建邀请链接
|
||||
inviteLink := fmt.Sprintf("%s/register?invite=%s", h.App.Config.StaticUrl, inviteCode.Code)
|
||||
|
||||
stats := vo.InviteStats{
|
||||
InviteCount: int(totalInvite),
|
||||
RewardTotal: rewardTotal,
|
||||
TodayInvite: int(todayInvite),
|
||||
InviteCode: inviteCode.Code,
|
||||
InviteLink: inviteLink,
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, stats)
|
||||
}
|
||||
|
||||
// Rules 获取奖励规则
|
||||
func (h *InviteHandler) Rules(c *gin.Context) {
|
||||
// 获取系统配置中的邀请奖励
|
||||
var config model.Config
|
||||
var invitePower int = 200 // 默认值
|
||||
if h.DB.Where("name = ?", "system").First(&config).Error == nil {
|
||||
var configMap map[string]interface{}
|
||||
if utils.JsonDecode(config.Value, &configMap) == nil {
|
||||
if power, ok := configMap["invite_power"].(float64); ok {
|
||||
invitePower = int(power)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rules := []vo.RewardRule{
|
||||
{
|
||||
Id: 1,
|
||||
Title: "好友注册",
|
||||
Desc: "好友通过邀请链接成功注册",
|
||||
Icon: "icon-user-fill",
|
||||
Color: "#1989fa",
|
||||
Reward: invitePower,
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
Title: "好友首次充值",
|
||||
Desc: "好友首次充值任意金额",
|
||||
Icon: "icon-money",
|
||||
Color: "#07c160",
|
||||
Reward: invitePower * 2, // 假设首次充值奖励是注册奖励的2倍
|
||||
},
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, rules)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"geekai/store/vo"
|
||||
"geekai/utils"
|
||||
"geekai/utils/resp"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -31,6 +32,7 @@ func NewPowerLogHandler(app *core.AppServer, db *gorm.DB) *PowerLogHandler {
|
||||
func (h *PowerLogHandler) RegisterRoutes() {
|
||||
group := h.App.Engine.Group("/api/powerLog/")
|
||||
group.POST("list", h.List)
|
||||
group.GET("stats", h.Stats)
|
||||
}
|
||||
|
||||
func (h *PowerLogHandler) List(c *gin.Context) {
|
||||
@@ -78,3 +80,45 @@ func (h *PowerLogHandler) List(c *gin.Context) {
|
||||
}
|
||||
resp.SUCCESS(c, vo.NewPage(total, data.Page, data.PageSize, list))
|
||||
}
|
||||
|
||||
// Stats 获取用户算力统计
|
||||
func (h *PowerLogHandler) Stats(c *gin.Context) {
|
||||
userId := h.GetLoginUserId(c)
|
||||
if userId == 0 {
|
||||
resp.NotAuth(c)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取用户信息(包含余额)
|
||||
var user model.User
|
||||
if err := h.DB.Where("id", userId).First(&user).Error; err != nil {
|
||||
resp.ERROR(c, "用户不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// 计算总消费(所有支出记录)
|
||||
var totalConsume int64
|
||||
h.DB.Model(&model.PowerLog{}).
|
||||
Where("user_id", userId).
|
||||
Where("mark", types.PowerSub).
|
||||
Select("COALESCE(SUM(amount), 0)").
|
||||
Scan(&totalConsume)
|
||||
|
||||
// 计算今日消费
|
||||
today := time.Now().Format("2006-01-02")
|
||||
var todayConsume int64
|
||||
h.DB.Model(&model.PowerLog{}).
|
||||
Where("user_id", userId).
|
||||
Where("mark", types.PowerSub).
|
||||
Where("DATE(created_at) = ?", today).
|
||||
Select("COALESCE(SUM(amount), 0)").
|
||||
Scan(&todayConsume)
|
||||
|
||||
stats := map[string]interface{}{
|
||||
"total": totalConsume,
|
||||
"today": todayConsume,
|
||||
"balance": user.Power,
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, stats)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ type InviteLog struct {
|
||||
InviterId uint `json:"inviter_id"`
|
||||
UserId uint `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
Remark string `json:"remark"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
|
||||
9
api/store/vo/invite_stats.go
Normal file
9
api/store/vo/invite_stats.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package vo
|
||||
|
||||
type InviteStats struct {
|
||||
InviteCount int `json:"invite_count"` // 累计邀请数
|
||||
RewardTotal int `json:"reward_total"` // 获得奖励总数
|
||||
TodayInvite int `json:"today_invite"` // 今日邀请数
|
||||
InviteCode string `json:"invite_code"` // 邀请码
|
||||
InviteLink string `json:"invite_link"` // 邀请链接
|
||||
}
|
||||
10
api/store/vo/reward_rule.go
Normal file
10
api/store/vo/reward_rule.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package vo
|
||||
|
||||
type RewardRule struct {
|
||||
Id int `json:"id"` // 规则ID
|
||||
Title string `json:"title"` // 规则标题
|
||||
Desc string `json:"desc"` // 规则描述
|
||||
Icon string `json:"icon"` // 图标类名
|
||||
Color string `json:"color"` // 图标颜色
|
||||
Reward int `json:"reward"` // 奖励算力
|
||||
}
|
||||
@@ -46,24 +46,6 @@
|
||||
>忘记密码?</el-button
|
||||
>
|
||||
</div>
|
||||
<div v-if="wechatLoginURL !== ''">
|
||||
<el-divider>
|
||||
<div class="text-center">其他登录方式</div>
|
||||
</el-divider>
|
||||
<div class="c-login flex justify-center">
|
||||
<div class="p-2 w-full">
|
||||
<a :href="wechatLoginURL">
|
||||
<el-button
|
||||
type="success"
|
||||
class="w-full"
|
||||
size="large"
|
||||
@click="setRoute(router.currentRoute.value.path)"
|
||||
><i class="iconfont icon-wechat mr-2"></i> 微信登录
|
||||
</el-button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
@@ -265,6 +247,14 @@ import { useRouter } from 'vue-router'
|
||||
// eslint-disable-next-line no-undef
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
active: {
|
||||
type: String,
|
||||
default: 'login',
|
||||
},
|
||||
inviteCode: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
const showDialog = ref(false)
|
||||
watch(
|
||||
@@ -274,7 +264,7 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const login = ref(true)
|
||||
const login = ref(props.active === 'login')
|
||||
const data = ref({
|
||||
username: import.meta.env.VITE_USER,
|
||||
password: import.meta.env.VITE_PASS,
|
||||
@@ -282,13 +272,13 @@ const data = ref({
|
||||
email: '',
|
||||
repass: '',
|
||||
code: '',
|
||||
invite_code: '',
|
||||
invite_code: props.inviteCode,
|
||||
})
|
||||
const enableMobile = ref(false)
|
||||
const enableEmail = ref(false)
|
||||
const enableUser = ref(false)
|
||||
const enableRegister = ref(true)
|
||||
const wechatLoginURL = ref('')
|
||||
|
||||
const activeName = ref('')
|
||||
const wxImg = ref('/images/wx.png')
|
||||
const captchaRef = ref(null)
|
||||
@@ -301,15 +291,6 @@ const router = useRouter()
|
||||
const store = useSharedStore()
|
||||
|
||||
onMounted(() => {
|
||||
const returnURL = `${location.protocol}//${location.host}/login/callback?action=login`
|
||||
httpGet('/api/user/clogin?return_url=' + returnURL)
|
||||
.then((res) => {
|
||||
wechatLoginURL.value = res.data.url
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e.message)
|
||||
})
|
||||
|
||||
getSystemInfo()
|
||||
.then((res) => {
|
||||
if (res.data) {
|
||||
@@ -472,30 +453,6 @@ const doRegister = (verifyData) => {
|
||||
}
|
||||
}
|
||||
|
||||
.c-login {
|
||||
display: flex;
|
||||
.text {
|
||||
font-size: 16px;
|
||||
color: #a1a1a1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.login-type {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
.iconfont {
|
||||
font-size: 18px;
|
||||
background: #e9f1f6;
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.iconfont.icon-wechat {
|
||||
color: #0bc15f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,61 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="relative bg-gray-100 rounded-lg py-1 mb-2.5 overflow-hidden" ref="tabsHeader">
|
||||
<div class="flex whitespace-nowrap overflow-x-auto scrollbar-hide" ref="tabsContainer">
|
||||
<div class="relative bg-gray-100 rounded-lg py-1.5 mb-3 px-2 overflow-hidden" ref="tabsHeader">
|
||||
<!-- 左滑动指示器 -->
|
||||
<div
|
||||
v-show="canScrollLeft"
|
||||
class="absolute left-1 top-1/2 -translate-y-1/2 z-30 w-6 h-6 bg-white/95 backdrop-blur-sm rounded-full shadow-sm border border-gray-200/50 flex items-center justify-center cursor-pointer hover:bg-white hover:shadow-md hover:scale-105 transition-all duration-200 group"
|
||||
@click="scrollLeft"
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3 text-gray-500 group-hover:text-purple-600 transition-colors duration-200"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2.5"
|
||||
d="M15 19l-7-7 7-7"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 右滑动指示器 -->
|
||||
<div
|
||||
v-show="canScrollRight"
|
||||
class="absolute right-1 top-1/2 -translate-y-1/2 z-30 w-6 h-6 bg-white/95 backdrop-blur-sm rounded-full shadow-sm border border-gray-200/50 flex items-center justify-center cursor-pointer hover:bg-white hover:shadow-md hover:scale-105 transition-all duration-200 group"
|
||||
@click="scrollRight"
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3 text-gray-500 group-hover:text-purple-600 transition-colors duration-200"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2.5"
|
||||
d="M9 5l7 7-7 7"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex whitespace-nowrap overflow-x-auto scrollbar-hide"
|
||||
ref="tabsContainer"
|
||||
@scroll="checkScrollPosition"
|
||||
>
|
||||
<div
|
||||
class="flex-shrink-0 text-center py-1.5 px-3 font-medium text-gray-700 cursor-pointer transition-colors duration-300 rounded-md relative z-20 hover:text-purple-600"
|
||||
class="flex-shrink-0 text-center py-1 px-2 font-medium text-gray-700 cursor-pointer transition-all duration-300 rounded-md relative z-20 hover:text-purple-600"
|
||||
v-for="(tab, index) in panes"
|
||||
:key="tab.name"
|
||||
:class="{ '!text-purple-600': modelValue === tab.name }"
|
||||
:class="{
|
||||
'!text-purple-600 bg-white shadow-sm': modelValue === tab.name,
|
||||
'hover:bg-gray-50': modelValue !== tab.name,
|
||||
}"
|
||||
@click="handleTabClick(tab.name, index)"
|
||||
ref="tabItems"
|
||||
>
|
||||
@@ -16,11 +65,6 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="absolute top-1 bottom-1 bg-white rounded-md shadow-sm transition-all duration-300 ease-out z-10"
|
||||
:style="indicatorStyle"
|
||||
ref="indicator"
|
||||
></div>
|
||||
</div>
|
||||
<div>
|
||||
<slot></slot>
|
||||
@@ -43,13 +87,11 @@ const emit = defineEmits(['update:modelValue', 'tab-click'])
|
||||
const tabsHeader = ref(null)
|
||||
const tabsContainer = ref(null)
|
||||
const tabItems = ref([])
|
||||
const indicator = ref(null)
|
||||
const panes = ref([])
|
||||
|
||||
const indicatorStyle = ref({
|
||||
transform: 'translateX(0px)',
|
||||
width: '0px',
|
||||
})
|
||||
// 滑动状态
|
||||
const canScrollLeft = ref(false)
|
||||
const canScrollRight = ref(false)
|
||||
|
||||
// 提供当前激活的 tab 给子组件
|
||||
provide(
|
||||
@@ -70,50 +112,151 @@ provide('registerPane', (pane) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 检查滑动位置状态
|
||||
const checkScrollPosition = () => {
|
||||
if (!tabsContainer.value) return
|
||||
|
||||
const container = tabsContainer.value
|
||||
canScrollLeft.value = container.scrollLeft > 0
|
||||
canScrollRight.value = container.scrollLeft < container.scrollWidth - container.clientWidth
|
||||
}
|
||||
|
||||
// 向左滑动
|
||||
const scrollLeft = () => {
|
||||
if (!tabsContainer.value) return
|
||||
|
||||
const container = tabsContainer.value
|
||||
const scrollAmount = Math.min(200, container.scrollLeft) // 每次滑动200px或剩余距离
|
||||
|
||||
container.scrollTo({
|
||||
left: container.scrollLeft - scrollAmount,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
|
||||
// 向右滑动
|
||||
const scrollRight = () => {
|
||||
if (!tabsContainer.value) return
|
||||
|
||||
const container = tabsContainer.value
|
||||
const maxScroll = container.scrollWidth - container.clientWidth
|
||||
const scrollAmount = Math.min(200, maxScroll - container.scrollLeft) // 每次滑动200px或剩余距离
|
||||
|
||||
container.scrollTo({
|
||||
left: container.scrollLeft + scrollAmount,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
|
||||
const handleTabClick = (tabName, index) => {
|
||||
emit('update:modelValue', tabName)
|
||||
emit('tab-click', tabName, index)
|
||||
updateIndicator(index)
|
||||
scrollToTab(index)
|
||||
}
|
||||
|
||||
const updateIndicator = async (activeIndex) => {
|
||||
// 简化后的滚动到指定tab的函数
|
||||
const scrollToTab = async (activeIndex) => {
|
||||
await nextTick()
|
||||
if (tabItems.value && tabItems.value.length > 0 && tabsHeader.value) {
|
||||
const activeTab = tabItems.value[activeIndex]
|
||||
if (activeTab) {
|
||||
const tabRect = activeTab.getBoundingClientRect()
|
||||
const containerRect = tabsHeader.value.getBoundingClientRect()
|
||||
|
||||
const leftPosition = tabRect.left - containerRect.left
|
||||
const tabWidth = tabRect.width
|
||||
|
||||
indicatorStyle.value = {
|
||||
transform: `translateX(${leftPosition}px)`,
|
||||
width: `${tabWidth}px`,
|
||||
}
|
||||
}
|
||||
if (!tabsContainer.value || !tabItems.value || tabItems.value.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const activeTab = tabItems.value[activeIndex]
|
||||
if (!activeTab) {
|
||||
return
|
||||
}
|
||||
|
||||
const container = tabsContainer.value
|
||||
const tabRect = activeTab.getBoundingClientRect()
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
|
||||
// 计算tab相对于容器的位置
|
||||
const tabLeft = tabRect.left - containerRect.left
|
||||
const tabRight = tabLeft + tabRect.width
|
||||
const containerWidth = containerRect.width
|
||||
|
||||
// 检查tab是否在可视区域内(增加一些容错空间)
|
||||
const tolerance = 4 // 4px的容错空间
|
||||
const isVisible = tabLeft >= -tolerance && tabRight <= containerWidth + tolerance
|
||||
|
||||
if (!isVisible) {
|
||||
let scrollLeft = container.scrollLeft
|
||||
|
||||
if (tabLeft < -tolerance) {
|
||||
// tab在左侧不可见,滚动到tab的起始位置
|
||||
scrollLeft += tabLeft - 12 // 留出12px的边距
|
||||
} else if (tabRight > containerWidth + tolerance) {
|
||||
// tab在右侧不可见,滚动到tab的结束位置
|
||||
scrollLeft += tabRight - containerWidth + 12 // 留出12px的边距
|
||||
}
|
||||
|
||||
// 确保滚动位置不超出边界
|
||||
scrollLeft = Math.max(0, Math.min(scrollLeft, container.scrollWidth - containerWidth))
|
||||
|
||||
// 平滑滚动到目标位置
|
||||
container.scrollTo({
|
||||
left: scrollLeft,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
|
||||
// 更新滑动状态
|
||||
setTimeout(checkScrollPosition, 300)
|
||||
}
|
||||
|
||||
// 监听 modelValue 变化,更新指示器位置
|
||||
// 监听 modelValue 变化,滚动到tab
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
const activeIndex = panes.value.findIndex((pane) => pane.name === newValue)
|
||||
if (activeIndex !== -1) {
|
||||
updateIndicator(activeIndex)
|
||||
scrollToTab(activeIndex)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 监听 panes 变化,当tab数量变化时重新计算
|
||||
watch(
|
||||
() => panes.value.length,
|
||||
() => {
|
||||
nextTick(() => {
|
||||
const activeIndex = panes.value.findIndex((pane) => pane.name === props.modelValue)
|
||||
if (activeIndex !== -1) {
|
||||
scrollToTab(activeIndex)
|
||||
}
|
||||
// 检查滑动状态
|
||||
setTimeout(checkScrollPosition, 100)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化指示器位置
|
||||
// 初始化时滚动到选中tab
|
||||
nextTick(() => {
|
||||
const activeIndex = panes.value.findIndex((pane) => pane.name === props.modelValue)
|
||||
if (activeIndex !== -1) {
|
||||
updateIndicator(activeIndex)
|
||||
scrollToTab(activeIndex)
|
||||
}
|
||||
// 检查初始滑动状态
|
||||
setTimeout(checkScrollPosition, 100)
|
||||
})
|
||||
|
||||
// 监听窗口大小变化,重新计算滚动
|
||||
const handleResize = () => {
|
||||
const activeIndex = panes.value.findIndex((pane) => pane.name === props.modelValue)
|
||||
if (activeIndex !== -1) {
|
||||
scrollToTab(activeIndex)
|
||||
}
|
||||
// 检查滑动状态
|
||||
setTimeout(checkScrollPosition, 100)
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
// 清理事件监听器
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -140,4 +283,14 @@ onMounted(() => {
|
||||
.flex-shrink-0:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* 添加平滑滚动效果 */
|
||||
.overflow-x-auto {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* 滑动指示器样式 */
|
||||
.absolute {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ActionSheet,
|
||||
Badge,
|
||||
Button,
|
||||
Calendar,
|
||||
Cell,
|
||||
CellGroup,
|
||||
Circle,
|
||||
@@ -52,6 +53,7 @@ import {
|
||||
Overlay,
|
||||
Picker,
|
||||
Popup,
|
||||
PullRefresh,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Row,
|
||||
@@ -130,5 +132,7 @@ app.use(Tabs)
|
||||
app.use(Divider)
|
||||
app.use(NoticeBar)
|
||||
app.use(ActionSheet)
|
||||
app.use(PullRefresh)
|
||||
app.use(Calendar)
|
||||
app.use(Toast)
|
||||
app.use(router).use(ElementPlus).mount('#app')
|
||||
|
||||
@@ -123,12 +123,7 @@ const routes = [
|
||||
meta: { title: '导出会话记录' },
|
||||
component: () => import('@/views/ChatExport.vue'),
|
||||
},
|
||||
{
|
||||
name: 'login-callback',
|
||||
path: '/login/callback',
|
||||
meta: { title: '用户登录' },
|
||||
component: () => import('@/views/LoginCallback.vue'),
|
||||
},
|
||||
|
||||
{
|
||||
name: 'login',
|
||||
path: '/login',
|
||||
@@ -296,31 +291,37 @@ const routes = [
|
||||
component: () => import('@/views/mobile/Index.vue'),
|
||||
},
|
||||
{
|
||||
meta: { title: 'AI对话' },
|
||||
path: '/mobile/chat',
|
||||
name: 'mobile-chat',
|
||||
component: () => import('@/views/mobile/ChatList.vue'),
|
||||
},
|
||||
{
|
||||
meta: { title: '创作中心' },
|
||||
path: '/mobile/create',
|
||||
name: 'mobile-create',
|
||||
component: () => import('@/views/mobile/Create.vue'),
|
||||
},
|
||||
{
|
||||
meta: { title: '发现' },
|
||||
path: '/mobile/discover',
|
||||
name: 'mobile-discover',
|
||||
component: () => import('@/views/mobile/Discover.vue'),
|
||||
},
|
||||
{
|
||||
meta: { title: '个人中心' },
|
||||
path: '/mobile/profile',
|
||||
name: 'mobile-profile',
|
||||
component: () => import('@/views/mobile/Profile.vue'),
|
||||
},
|
||||
{
|
||||
meta: { title: '会员充值' },
|
||||
path: '/mobile/member',
|
||||
name: 'mobile-member',
|
||||
component: () => import('@/views/mobile/Member.vue'),
|
||||
},
|
||||
{
|
||||
meta: { title: '作品展示' },
|
||||
path: '/mobile/imgWall',
|
||||
name: 'mobile-img-wall',
|
||||
component: () => import('@/views/mobile/pages/ImgWall.vue'),
|
||||
@@ -332,40 +333,47 @@ const routes = [
|
||||
},
|
||||
|
||||
{
|
||||
meta: { title: '应用中心' },
|
||||
path: '/mobile/apps',
|
||||
name: 'mobile-apps',
|
||||
component: () => import('@/views/mobile/Apps.vue'),
|
||||
},
|
||||
// 新增的功能页面路由
|
||||
{
|
||||
meta: { title: '消费日志' },
|
||||
path: '/mobile/power-log',
|
||||
name: 'mobile-power-log',
|
||||
component: () => import('@/views/mobile/PowerLog.vue'),
|
||||
},
|
||||
{
|
||||
meta: { title: '推广计划' },
|
||||
path: '/mobile/invite',
|
||||
name: 'mobile-invite',
|
||||
component: () => import('@/views/mobile/Invite.vue'),
|
||||
},
|
||||
{
|
||||
path: '/mobile/tools',
|
||||
name: 'mobile-tools',
|
||||
component: () => import('@/views/mobile/Tools.vue'),
|
||||
},
|
||||
{
|
||||
meta: { title: '设置' },
|
||||
path: '/mobile/settings',
|
||||
name: 'mobile-settings',
|
||||
component: () => import('@/views/mobile/Settings.vue'),
|
||||
},
|
||||
{
|
||||
path: '/mobile/help',
|
||||
name: 'mobile-help',
|
||||
component: () => import('@/views/mobile/Help.vue'),
|
||||
meta: { title: 'Suno音乐创作' },
|
||||
path: '/mobile/suno-create',
|
||||
name: 'mobile-suno-create',
|
||||
component: () => import('@/views/mobile/pages/SunoCreate.vue'),
|
||||
},
|
||||
{
|
||||
path: '/mobile/feedback',
|
||||
name: 'mobile-feedback',
|
||||
component: () => import('@/views/mobile/Feedback.vue'),
|
||||
meta: { title: '视频生成' },
|
||||
path: '/mobile/video-create',
|
||||
name: 'mobile-video-create',
|
||||
component: () => import('@/views/mobile/pages/VideoCreate.vue'),
|
||||
},
|
||||
{
|
||||
meta: { title: '即梦AI' },
|
||||
path: '/mobile/jimeng-create',
|
||||
name: 'mobile-jimeng-create',
|
||||
component: () => import('@/views/mobile/pages/JimengCreate.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -915,10 +915,10 @@ const initParams = {
|
||||
model: models[0].value,
|
||||
chaos: 0,
|
||||
stylize: 0,
|
||||
seed: 0,
|
||||
seed: -1,
|
||||
img_arr: [],
|
||||
raw: false,
|
||||
iw: 0,
|
||||
iw: 0.7,
|
||||
prompt: router.currentRoute.value.params['prompt'] ?? '',
|
||||
neg_prompt: '',
|
||||
tile: false,
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="item-box green">
|
||||
<div class="item-box bg-green-500">
|
||||
<el-row :gutter="10">
|
||||
<el-col :span="10">
|
||||
<div class="item-icon">
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<router-link to="/" class="back-home-btn" title="返回首页">
|
||||
<i class="iconfont icon-home"></i>
|
||||
</router-link>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
@@ -58,6 +61,39 @@ onMounted(() => {
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
|
||||
.back-home-btn {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
left: 24px;
|
||||
z-index: 10;
|
||||
font-size: 22px;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.back-home-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.back-home-btn {
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
font-size: 20px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
:deep(.van-theme-dark) .back-home-btn {
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.login-container {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="login-callback"
|
||||
v-loading="loading"
|
||||
element-loading-text="正在同步登录信息..."
|
||||
:style="{ height: winHeight + 'px' }"
|
||||
>
|
||||
<el-dialog
|
||||
v-model="show"
|
||||
:close-on-click-modal="false"
|
||||
:show-close="false"
|
||||
style="width: 360px"
|
||||
>
|
||||
<el-result icon="success" title="登录成功" style="--el-result-padding: 10px">
|
||||
<template #sub-title>
|
||||
<div class="user-info">
|
||||
<div class="line">您的初始账户信息如下:</div>
|
||||
<div class="line"><span>用户名:</span>{{ username }}</div>
|
||||
<div class="line"><span>密码:</span>{{ password }}</div>
|
||||
<div class="line">您后期也可以通过此账号和密码登录</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #extra>
|
||||
<el-button
|
||||
type="primary"
|
||||
class="copy-user-info"
|
||||
:data-clipboard-text="'用户名:' + username + ' 密码:' + password"
|
||||
>复制</el-button
|
||||
>
|
||||
<el-button type="danger" @click="finishLogin">关闭</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { checkSession } from '@/store/cache'
|
||||
import { setUserToken } from '@/store/session'
|
||||
import { getRoute } from '@/store/system'
|
||||
import { showMessageError, showMessageOK } from '@/utils/dialog'
|
||||
import { httpGet } from '@/utils/http'
|
||||
import Clipboard from 'clipboard'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const winHeight = ref(window.innerHeight)
|
||||
const loading = ref(true)
|
||||
const router = useRouter()
|
||||
const show = ref(false)
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
|
||||
const code = router.currentRoute.value.query.code
|
||||
const action = router.currentRoute.value.query.action
|
||||
if (code === '') {
|
||||
ElMessage.error({
|
||||
message: '登录失败:code 参数不能为空',
|
||||
duration: 2000,
|
||||
onClose: () => router.push('/'),
|
||||
})
|
||||
} else {
|
||||
checkSession()
|
||||
.then((user) => {
|
||||
// bind user
|
||||
doLogin(user.id)
|
||||
})
|
||||
.catch(() => {
|
||||
doLogin(0)
|
||||
})
|
||||
}
|
||||
|
||||
const doLogin = (userId) => {
|
||||
// 发送请求获取用户信息
|
||||
httpGet('/api/user/clogin/callback', {
|
||||
login_type: 'wx',
|
||||
code: code,
|
||||
action: action,
|
||||
user_id: userId,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.data.token) {
|
||||
setUserToken(res.data.token)
|
||||
}
|
||||
if (res.data.username) {
|
||||
username.value = res.data.username
|
||||
password.value = res.data.password
|
||||
show.value = true
|
||||
loading.value = false
|
||||
} else {
|
||||
finishLogin()
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessageBox.alert(e.message, {
|
||||
confirmButtonText: '重新登录',
|
||||
type: 'error',
|
||||
title: '登录失败',
|
||||
callback: () => {
|
||||
router.push('/')
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const clipboard = ref(null)
|
||||
onMounted(() => {
|
||||
clipboard.value = new Clipboard('.copy-user-info')
|
||||
clipboard.value.on('success', () => {
|
||||
showMessageOK('复制成功!')
|
||||
})
|
||||
|
||||
clipboard.value.on('error', () => {
|
||||
showMessageError('复制失败!')
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clipboard.value.destroy()
|
||||
})
|
||||
|
||||
const finishLogin = () => {
|
||||
show.value = false
|
||||
router.push(getRoute())
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-callback {
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px;
|
||||
border: 1px dashed #e1e1e1;
|
||||
border-radius: 10px;
|
||||
.line {
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
|
||||
span {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="register-page">
|
||||
<router-link to="/" class="back-home-btn" title="返回首页">
|
||||
<i class="iconfont icon-home"></i>
|
||||
</router-link>
|
||||
<div class="register-container">
|
||||
<div class="register-card">
|
||||
<div class="register-header">
|
||||
@@ -10,6 +13,8 @@
|
||||
<div class="register-content">
|
||||
<login-dialog
|
||||
:show="true"
|
||||
active="register"
|
||||
:inviteCode="inviteCode"
|
||||
@hide="handleRegisterHide"
|
||||
@success="handleRegisterSuccess"
|
||||
ref="loginDialogRef"
|
||||
@@ -28,6 +33,7 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const loginDialogRef = ref(null)
|
||||
const inviteCode = ref(router.currentRoute.value.query.invite_code || '')
|
||||
|
||||
// 处理注册弹窗隐藏
|
||||
const handleRegisterHide = () => {
|
||||
@@ -58,6 +64,39 @@ onMounted(() => {
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
|
||||
.back-home-btn {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
left: 24px;
|
||||
z-index: 10;
|
||||
font-size: 22px;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.back-home-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.back-home-btn {
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
font-size: 20px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
:deep(.van-theme-dark) .back-home-btn {
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.register-container {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
@@ -110,6 +149,12 @@ onMounted(() => {
|
||||
padding: 16px;
|
||||
background: var(--van-background);
|
||||
|
||||
.back-home-btn {
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.register-container {
|
||||
max-width: 100%;
|
||||
|
||||
@@ -143,6 +188,12 @@ onMounted(() => {
|
||||
.register-page {
|
||||
padding: 12px;
|
||||
|
||||
.back-home-btn {
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.register-container {
|
||||
.register-card {
|
||||
.register-header {
|
||||
|
||||
@@ -1,83 +1,69 @@
|
||||
<template>
|
||||
<div class="apps-page">
|
||||
<div class="apps-filter mb-8 px-3">
|
||||
<CustomTabs :model-value="activeTab" @update:model-value="activeTab = $event">
|
||||
<div class="apps-page p-3">
|
||||
<div class="apps-filter mb-8">
|
||||
<CustomTabs :model-value="activeTab" @update:model-value="handleTabChange">
|
||||
<CustomTabPane name="all" label="全部分类">
|
||||
<div class="app-list">
|
||||
<van-list v-model="loading" :finished="true" finished-text="" @load="fetchApps()">
|
||||
<van-cell v-for="item in apps" :key="item.id" class="app-cell">
|
||||
<div class="app-card">
|
||||
<div class="app-info">
|
||||
<div class="app-image">
|
||||
<van-image :src="item.icon" round />
|
||||
</div>
|
||||
<div class="app-detail">
|
||||
<div class="app-title">{{ item.name }}</div>
|
||||
<div class="app-desc">{{ item.hello_msg }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-actions">
|
||||
<van-button
|
||||
size="small"
|
||||
type="primary"
|
||||
class="action-btn"
|
||||
@click="useRole(item.id)"
|
||||
>对话</van-button
|
||||
>
|
||||
<van-button
|
||||
size="small"
|
||||
:type="hasRole(item.key) ? 'danger' : 'success'"
|
||||
class="action-btn"
|
||||
@click="updateRole(item, hasRole(item.key) ? 'remove' : 'add')"
|
||||
>
|
||||
{{ hasRole(item.key) ? '移除' : '添加' }}
|
||||
</van-button>
|
||||
</div>
|
||||
<van-list v-model="loading" :finished="!loading" finished-text="" @load="() => {}">
|
||||
<template v-if="!loading && currentApps.length > 0">
|
||||
<AppCard
|
||||
v-for="item in currentApps"
|
||||
:key="item.id"
|
||||
:app="item"
|
||||
:has-role="hasRole(item.key)"
|
||||
@use-role="useRole"
|
||||
@update-role="updateRole"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="!loading && currentApps.length === 0">
|
||||
<EmptyState
|
||||
type="search"
|
||||
description="暂无应用"
|
||||
:show-action="true"
|
||||
action-text="刷新"
|
||||
@action="refreshData"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="loading-state">
|
||||
<van-loading type="spinner" size="24px">加载中...</van-loading>
|
||||
</div>
|
||||
</van-cell>
|
||||
</template>
|
||||
</van-list>
|
||||
</div>
|
||||
</CustomTabPane>
|
||||
<CustomTabPane v-for="type in appTypes" :key="type.id" :name="type.id" :label="type.name">
|
||||
<CustomTabPane
|
||||
v-for="type in appTypes"
|
||||
:key="type.id"
|
||||
:name="type.id.toString()"
|
||||
:label="type.name"
|
||||
>
|
||||
<div class="app-list">
|
||||
<van-list
|
||||
v-model="loading"
|
||||
:finished="true"
|
||||
finished-text=""
|
||||
@load="fetchApps(type.id)"
|
||||
>
|
||||
<van-cell v-for="item in typeApps" :key="item.id" class="app-cell">
|
||||
<div class="app-card">
|
||||
<div class="app-info">
|
||||
<div class="app-image">
|
||||
<van-image :src="item.icon" round />
|
||||
</div>
|
||||
<div class="app-detail">
|
||||
<div class="app-title">{{ item.name }}</div>
|
||||
<div class="app-desc">{{ item.hello_msg }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-actions">
|
||||
<van-button
|
||||
size="small"
|
||||
type="primary"
|
||||
class="action-btn"
|
||||
@click="useRole(item.id)"
|
||||
>对话</van-button
|
||||
>
|
||||
<van-button
|
||||
size="small"
|
||||
:type="hasRole(item.key) ? 'danger' : 'success'"
|
||||
class="action-btn"
|
||||
@click="updateRole(item, hasRole(item.key) ? 'remove' : 'add')"
|
||||
>
|
||||
{{ hasRole(item.key) ? '移除' : '添加' }}
|
||||
</van-button>
|
||||
</div>
|
||||
<van-list v-model="loading" :finished="!loading" finished-text="" @load="() => {}">
|
||||
<template v-if="!loading && getAppsByType(type.id).length > 0">
|
||||
<AppCard
|
||||
v-for="item in getAppsByType(type.id)"
|
||||
:key="item.id"
|
||||
:app="item"
|
||||
:has-role="hasRole(item.key)"
|
||||
@use-role="useRole"
|
||||
@update-role="updateRole"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="!loading && getAppsByType(type.id).length === 0">
|
||||
<EmptyState
|
||||
type="search"
|
||||
:description="`${type.name}分类暂无应用`"
|
||||
:show-action="true"
|
||||
action-text="刷新"
|
||||
@action="refreshData"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="loading-state">
|
||||
<van-loading type="spinner" size="24px">加载中...</van-loading>
|
||||
</div>
|
||||
</van-cell>
|
||||
</template>
|
||||
</van-list>
|
||||
</div>
|
||||
</CustomTabPane>
|
||||
@@ -89,64 +75,105 @@
|
||||
<script setup>
|
||||
import CustomTabPane from '@/components/ui/CustomTabPane.vue'
|
||||
import CustomTabs from '@/components/ui/CustomTabs.vue'
|
||||
import AppCard from './components/AppCard.vue'
|
||||
import EmptyState from './components/EmptyState.vue'
|
||||
import { checkSession } from '@/store/cache'
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import { arrayContains, removeArrayItem, showLoginDialog, substr } from '@/utils/libs'
|
||||
import { showNotify } from 'vant'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, ref, computed, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const isLogin = ref(false)
|
||||
const apps = ref([])
|
||||
const typeApps = ref([])
|
||||
const allApps = ref([]) // 存储所有应用数据
|
||||
const appTypes = ref([])
|
||||
const loading = ref(false)
|
||||
const roles = ref([])
|
||||
const activeTab = ref(0)
|
||||
const activeTab = ref('all')
|
||||
const initialized = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
checkSession()
|
||||
.then((user) => {
|
||||
isLogin.value = true
|
||||
roles.value = user.chat_roles
|
||||
})
|
||||
.catch(() => {})
|
||||
fetchAppTypes()
|
||||
fetchApps()
|
||||
// 按分类分组的应用数据
|
||||
const appsByType = computed(() => {
|
||||
const grouped = {}
|
||||
allApps.value.forEach((app) => {
|
||||
const tid = app.tid || 0
|
||||
if (!grouped[tid]) {
|
||||
grouped[tid] = []
|
||||
}
|
||||
grouped[tid].push(app)
|
||||
})
|
||||
return grouped
|
||||
})
|
||||
|
||||
const fetchAppTypes = () => {
|
||||
httpGet('/api/app/type/list')
|
||||
.then((res) => {
|
||||
appTypes.value = res.data
|
||||
})
|
||||
.catch((e) => {
|
||||
showNotify({ type: 'danger', message: '获取应用分类失败:' + e.message })
|
||||
})
|
||||
// 获取当前tab显示的应用列表
|
||||
const currentApps = computed(() => {
|
||||
if (activeTab.value === 'all') {
|
||||
return allApps.value
|
||||
}
|
||||
return getAppsByType(parseInt(activeTab.value)) || []
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const user = await checkSession()
|
||||
isLogin.value = true
|
||||
roles.value = user.chat_roles
|
||||
} catch (error) {
|
||||
// 用户未登录,继续执行
|
||||
}
|
||||
|
||||
await Promise.all([fetchAppTypes(), fetchAllApps()])
|
||||
|
||||
initialized.value = true
|
||||
})
|
||||
|
||||
const fetchAppTypes = async () => {
|
||||
try {
|
||||
const res = await httpGet('/api/app/type/list')
|
||||
appTypes.value = res.data
|
||||
} catch (e) {
|
||||
showNotify({ type: 'danger', message: '获取应用分类失败:' + e.message })
|
||||
}
|
||||
}
|
||||
|
||||
const fetchApps = (typeId = '') => {
|
||||
httpGet('/api/app/list', { tid: typeId })
|
||||
.then((res) => {
|
||||
const items = res.data
|
||||
// 处理 hello message
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
items[i].intro = substr(items[i].hello_msg, 80)
|
||||
}
|
||||
|
||||
if (typeId) {
|
||||
typeApps.value = items
|
||||
} else {
|
||||
apps.value = items
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
showNotify({ type: 'danger', message: '获取应用失败:' + e.message })
|
||||
})
|
||||
// 一次性获取所有应用数据
|
||||
const fetchAllApps = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await httpGet('/api/app/list')
|
||||
const items = res.data
|
||||
// 处理 hello message
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
items[i].intro = substr(items[i].hello_msg, 80)
|
||||
}
|
||||
allApps.value = items
|
||||
} catch (e) {
|
||||
showNotify({ type: 'danger', message: '获取应用失败:' + e.message })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateRole = (row, opt) => {
|
||||
// 刷新数据
|
||||
const refreshData = async () => {
|
||||
await Promise.all([fetchAppTypes(), fetchAllApps()])
|
||||
showNotify({ type: 'success', message: '数据已刷新' })
|
||||
}
|
||||
|
||||
// 根据分类ID获取对应的应用列表
|
||||
const getAppsByType = (typeId) => {
|
||||
return appsByType.value[typeId] || []
|
||||
}
|
||||
|
||||
// 处理tab切换
|
||||
const handleTabChange = async (tabName) => {
|
||||
activeTab.value = tabName
|
||||
// 等待DOM更新完成
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
const updateRole = async (app, opt) => {
|
||||
if (!isLogin.value) {
|
||||
return showLoginDialog(router)
|
||||
}
|
||||
@@ -154,26 +181,26 @@ const updateRole = (row, opt) => {
|
||||
let actionTitle = ''
|
||||
if (opt === 'add') {
|
||||
actionTitle = '添加应用'
|
||||
const exists = arrayContains(roles.value, row.key)
|
||||
const exists = arrayContains(roles.value, app.key)
|
||||
if (exists) {
|
||||
return
|
||||
}
|
||||
roles.value.push(row.key)
|
||||
roles.value.push(app.key)
|
||||
} else {
|
||||
actionTitle = '移除应用'
|
||||
const exists = arrayContains(roles.value, row.key)
|
||||
const exists = arrayContains(roles.value, app.key)
|
||||
if (!exists) {
|
||||
return
|
||||
}
|
||||
roles.value = removeArrayItem(roles.value, row.key)
|
||||
roles.value = removeArrayItem(roles.value, app.key)
|
||||
}
|
||||
|
||||
try {
|
||||
await httpPost('/api/app/update', { keys: roles.value })
|
||||
showNotify({ type: 'success', message: actionTitle + '成功!' })
|
||||
} catch (e) {
|
||||
showNotify({ type: 'danger', message: actionTitle + '失败:' + e.message })
|
||||
}
|
||||
httpPost('/api/app/update', { keys: roles.value })
|
||||
.then(() => {
|
||||
showNotify({ type: 'success', message: actionTitle + '成功!' })
|
||||
})
|
||||
.catch((e) => {
|
||||
showNotify({ type: 'danger', message: actionTitle + '失败:' + e.message })
|
||||
})
|
||||
}
|
||||
|
||||
const hasRole = (roleKey) => {
|
||||
@@ -202,64 +229,10 @@ const useRole = (roleId) => {
|
||||
.app-list {
|
||||
padding: 0;
|
||||
|
||||
.app-cell {
|
||||
padding: 0;
|
||||
margin-bottom: 15px;
|
||||
|
||||
.app-card {
|
||||
background: var(--van-cell-background);
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
|
||||
|
||||
.app-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
|
||||
.app-image {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
margin-right: 15px;
|
||||
|
||||
:deep(.van-image) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.app-detail {
|
||||
flex: 1;
|
||||
|
||||
.app-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 5px;
|
||||
color: var(--van-text-color);
|
||||
}
|
||||
|
||||
.app-desc {
|
||||
font-size: 13px;
|
||||
color: var(--van-gray-6);
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.app-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
border-radius: 20px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.loading-state {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
color: var(--van-gray-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,16 +16,11 @@
|
||||
</span>
|
||||
</template>
|
||||
<template #right>
|
||||
<van-icon name="share-o" @click="showShare = true" />
|
||||
<van-icon name="share-o" @click="copyShareUrl" />
|
||||
</template>
|
||||
</van-nav-bar>
|
||||
|
||||
<van-share-sheet
|
||||
v-model:show="showShare"
|
||||
title="立即分享给好友"
|
||||
:options="shareOptions"
|
||||
@select="shareChat"
|
||||
/>
|
||||
<!-- 移除分享面板 -->
|
||||
|
||||
<div class="chat-list-wrapper">
|
||||
<div id="message-list-box" :style="{ height: winHeight + 'px' }" class="message-list-box">
|
||||
@@ -80,10 +75,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="copy-link-btn" style="display: none" :data-clipboard-text="url">
|
||||
复制链接地址
|
||||
</button>
|
||||
|
||||
<!-- <van-overlay :show="showMic" z-index="100">-->
|
||||
<!-- <div class="mic-wrapper">-->
|
||||
<!-- <div class="image">-->
|
||||
@@ -127,7 +118,7 @@ import { showMessageError } from '@/utils/dialog'
|
||||
import { httpGet } from '@/utils/http'
|
||||
import { processContent, randString, renderInputText, UUID } from '@/utils/libs'
|
||||
import { fetchEventSource } from '@microsoft/fetch-event-source'
|
||||
import Clipboard from 'clipboard'
|
||||
// 移除 Clipboard.js 相关内容
|
||||
import hl from 'highlight.js'
|
||||
import 'highlight.js/styles/a11y-dark.css'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
@@ -260,14 +251,7 @@ onMounted(() => {
|
||||
winHeight.value =
|
||||
window.innerHeight - navBarRef.value.$el.offsetHeight - bottomBarRef.value.$el.offsetHeight - 70
|
||||
|
||||
const clipboard = new Clipboard('.content-mobile,.copy-code-mobile,#copy-link-btn')
|
||||
clipboard.on('success', (e) => {
|
||||
e.clearSelection()
|
||||
showNotify({ type: 'success', message: '复制成功', duration: 1000 })
|
||||
})
|
||||
clipboard.on('error', () => {
|
||||
showNotify({ type: 'danger', message: '复制失败', duration: 2000 })
|
||||
})
|
||||
// 移除 Clipboard.js 相关内容
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -545,23 +529,7 @@ const reGenerate = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const showShare = ref(false)
|
||||
const shareOptions = [
|
||||
{ name: '微信', icon: 'wechat' },
|
||||
{ name: '复制链接', icon: 'link' },
|
||||
]
|
||||
const shareChat = (option) => {
|
||||
showShare.value = false
|
||||
if (option.icon === 'wechat') {
|
||||
showToast({
|
||||
message: '链接已复制,请通过微信分享给好友',
|
||||
duration: 3000,
|
||||
})
|
||||
document.getElementById('copy-link-btn').click()
|
||||
} else if (option.icon === 'link') {
|
||||
document.getElementById('copy-link-btn').click()
|
||||
}
|
||||
}
|
||||
// 移除 showShare、shareOptions、shareChat 相关内容
|
||||
|
||||
const getRoleById = function (rid) {
|
||||
for (let i = 0; i < roles.value.length; i++) {
|
||||
@@ -581,32 +549,6 @@ const getModelName = (model_id) => {
|
||||
return ''
|
||||
}
|
||||
|
||||
// // eslint-disable-next-line no-undef
|
||||
// const recognition = new webkitSpeechRecognition() || SpeechRecognition();
|
||||
// //recognition.lang = 'zh-CN' // 设置语音识别语言
|
||||
// recognition.onresult = function (event) {
|
||||
// prompt.value = event.results[0][0].transcript
|
||||
// };
|
||||
//
|
||||
// recognition.onerror = function (event) {
|
||||
// showMic.value = false
|
||||
// recognition.stop()
|
||||
// showNotify({type: 'danger', message: '语音识别错误:' + event.error})
|
||||
// };
|
||||
//
|
||||
// recognition.onend = function () {
|
||||
// console.log('语音识别结束');
|
||||
// };
|
||||
// const inputVoice = () => {
|
||||
// showMic.value = true
|
||||
// recognition.start();
|
||||
// }
|
||||
//
|
||||
// const stopVoice = () => {
|
||||
// showMic.value = false
|
||||
// recognition.stop()
|
||||
// }
|
||||
|
||||
const onChange = (item) => {
|
||||
const selectedValues = item.selectedOptions
|
||||
if (selectedValues[0].model_id) {
|
||||
@@ -619,6 +561,16 @@ const onChange = (item) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 新增复制分享链接方法
|
||||
const copyShareUrl = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url.value)
|
||||
showNotify({ type: 'success', message: '复制成功,请把链接发送给好友', duration: 3000 })
|
||||
} catch (e) {
|
||||
showNotify({ type: 'danger', message: '复制失败', duration: 2000 })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -52,240 +52,11 @@ import { Button, Field, Image, showNotify } from 'vant'
|
||||
import { h, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
// 创建缺失的移动端组件
|
||||
const SunoCreate = {
|
||||
name: 'SunoCreate',
|
||||
setup() {
|
||||
const prompt = ref('')
|
||||
const duration = ref(30)
|
||||
const loading = ref(false)
|
||||
const result = ref('')
|
||||
|
||||
const generateMusic = () => {
|
||||
if (!prompt.value.trim()) {
|
||||
showNotify({ type: 'warning', message: '请输入音乐描述' })
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
// TODO: 调用Suno API
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
showNotify({ type: 'success', message: '音乐生成功能开发中' })
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const downloadMusic = () => {
|
||||
// TODO: 实现下载功能
|
||||
showNotify({ type: 'primary', message: '下载功能开发中' })
|
||||
}
|
||||
|
||||
return () =>
|
||||
h('div', { class: 'suno-create' }, [
|
||||
h('div', { class: 'create-header' }, [h('h3', '音乐创作'), h('p', 'AI驱动的音乐生成工具')]),
|
||||
h('div', { class: 'create-form' }, [
|
||||
h(Field, {
|
||||
value: prompt.value,
|
||||
onInput: (val) => {
|
||||
prompt.value = val
|
||||
},
|
||||
type: 'textarea',
|
||||
placeholder: '描述您想要的音乐风格、情感或主题...',
|
||||
rows: 4,
|
||||
maxlength: 500,
|
||||
'show-word-limit': true,
|
||||
}),
|
||||
h(Field, {
|
||||
value: duration.value,
|
||||
onInput: (val) => {
|
||||
duration.value = val
|
||||
},
|
||||
label: '时长',
|
||||
type: 'number',
|
||||
placeholder: '音乐时长(秒)',
|
||||
}),
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
type: 'primary',
|
||||
size: 'large',
|
||||
block: true,
|
||||
loading: loading.value,
|
||||
onClick: generateMusic,
|
||||
},
|
||||
'生成音乐'
|
||||
),
|
||||
]),
|
||||
result.value
|
||||
? h('div', { class: 'result-area' }, [
|
||||
h('h4', '生成结果'),
|
||||
h('audio', { src: result.value, controls: true }),
|
||||
h(Button, { size: 'small', onClick: downloadMusic }, '下载'),
|
||||
])
|
||||
: null,
|
||||
])
|
||||
},
|
||||
}
|
||||
|
||||
const VideoCreate = {
|
||||
name: 'VideoCreate',
|
||||
setup() {
|
||||
const prompt = ref('')
|
||||
const duration = ref(10)
|
||||
const loading = ref(false)
|
||||
const result = ref('')
|
||||
|
||||
const generateVideo = () => {
|
||||
if (!prompt.value.trim()) {
|
||||
showNotify({ type: 'warning', message: '请输入视频描述' })
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
// TODO: 调用视频生成API
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
showNotify({ type: 'success', message: '视频生成功能开发中' })
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const downloadVideo = () => {
|
||||
// TODO: 实现下载功能
|
||||
showNotify({ type: 'primary', message: '下载功能开发中' })
|
||||
}
|
||||
|
||||
return () =>
|
||||
h('div', { class: 'video-create' }, [
|
||||
h('div', { class: 'create-header' }, [h('h3', '视频生成'), h('p', 'AI驱动的视频创作工具')]),
|
||||
h('div', { class: 'create-form' }, [
|
||||
h(Field, {
|
||||
value: prompt.value,
|
||||
onInput: (val) => {
|
||||
prompt.value = val
|
||||
},
|
||||
type: 'textarea',
|
||||
placeholder: '描述您想要的视频内容、风格或场景...',
|
||||
rows: 4,
|
||||
maxlength: 500,
|
||||
'show-word-limit': true,
|
||||
}),
|
||||
h(Field, {
|
||||
value: duration.value,
|
||||
onInput: (val) => {
|
||||
duration.value = val
|
||||
},
|
||||
label: '时长',
|
||||
type: 'number',
|
||||
placeholder: '视频时长(秒)',
|
||||
}),
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
type: 'primary',
|
||||
size: 'large',
|
||||
block: true,
|
||||
loading: loading.value,
|
||||
onClick: generateVideo,
|
||||
},
|
||||
'生成视频'
|
||||
),
|
||||
]),
|
||||
result.value
|
||||
? h('div', { class: 'result-area' }, [
|
||||
h('h4', '生成结果'),
|
||||
h('video', { src: result.value, controls: true }),
|
||||
h(Button, { size: 'small', onClick: downloadVideo }, '下载'),
|
||||
])
|
||||
: null,
|
||||
])
|
||||
},
|
||||
}
|
||||
|
||||
const JimengCreate = {
|
||||
name: 'JimengCreate',
|
||||
setup() {
|
||||
const prompt = ref('')
|
||||
const negativePrompt = ref('')
|
||||
const steps = ref(20)
|
||||
const loading = ref(false)
|
||||
const result = ref('')
|
||||
|
||||
const generateImage = () => {
|
||||
if (!prompt.value.trim()) {
|
||||
showNotify({ type: 'warning', message: '请输入图像描述' })
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
// TODO: 调用即梦AI API
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
showNotify({ type: 'success', message: '即梦AI功能开发中' })
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const downloadImage = () => {
|
||||
// TODO: 实现下载功能
|
||||
showNotify({ type: 'primary', message: '下载功能开发中' })
|
||||
}
|
||||
|
||||
return () =>
|
||||
h('div', { class: 'jimeng-create' }, [
|
||||
h('div', { class: 'create-header' }, [h('h3', '即梦AI'), h('p', '专业的AI图像生成工具')]),
|
||||
h('div', { class: 'create-form' }, [
|
||||
h(Field, {
|
||||
value: prompt.value,
|
||||
onInput: (val) => {
|
||||
prompt.value = val
|
||||
},
|
||||
type: 'textarea',
|
||||
placeholder: '描述您想要的图像内容...',
|
||||
rows: 4,
|
||||
maxlength: 500,
|
||||
'show-word-limit': true,
|
||||
}),
|
||||
h(Field, {
|
||||
value: negativePrompt.value,
|
||||
onInput: (val) => {
|
||||
negativePrompt.value = val
|
||||
},
|
||||
type: 'textarea',
|
||||
placeholder: '负面提示词(可选)',
|
||||
rows: 2,
|
||||
maxlength: 200,
|
||||
}),
|
||||
h(Field, {
|
||||
value: steps.value,
|
||||
onInput: (val) => {
|
||||
steps.value = val
|
||||
},
|
||||
label: '步数',
|
||||
type: 'number',
|
||||
placeholder: '生成步数',
|
||||
}),
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
type: 'primary',
|
||||
size: 'large',
|
||||
block: true,
|
||||
loading: loading.value,
|
||||
onClick: generateImage,
|
||||
},
|
||||
'生成图像'
|
||||
),
|
||||
]),
|
||||
result.value
|
||||
? h('div', { class: 'result-area' }, [
|
||||
h('h4', '生成结果'),
|
||||
h(Image, { src: result.value, fit: 'cover' }),
|
||||
h(Button, { size: 'small', onClick: downloadImage }, '下载'),
|
||||
])
|
||||
: null,
|
||||
])
|
||||
},
|
||||
}
|
||||
// 删除 SunoCreate、VideoCreate、JimengCreate 相关 setup 代码和渲染逻辑
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const activeTab = ref('mj')
|
||||
const activeTab = ref(route.query.tab || 'mj')
|
||||
const menus = ref([])
|
||||
const activeMenu = ref({
|
||||
mj: false,
|
||||
@@ -322,8 +93,6 @@ onMounted(() => {
|
||||
const fetchMenus = () => {
|
||||
httpGet('/api/menu/list')
|
||||
.then((res) => {
|
||||
console.log(res)
|
||||
|
||||
menus.value = res.data
|
||||
activeMenu.value = {
|
||||
mj: menus.value.some((item) => item.url === '/mj'),
|
||||
@@ -340,6 +109,18 @@ const fetchMenus = () => {
|
||||
if (firstAvailable) {
|
||||
activeTab.value = firstAvailable
|
||||
}
|
||||
} else {
|
||||
// 如果当前选中的tab不可用,选择第一个可用的
|
||||
if (!activeMenu.value[route.query.tab]) {
|
||||
const firstAvailable = Object.keys(activeMenu.value).find((key) => activeMenu.value[key])
|
||||
if (firstAvailable) {
|
||||
activeTab.value = firstAvailable
|
||||
router.replace({
|
||||
path: route.path,
|
||||
query: { ...route.query, tab: firstAvailable },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
|
||||
@@ -87,13 +87,13 @@ const aiTools = ref([
|
||||
key: 'suno',
|
||||
name: '音乐创作',
|
||||
desc: 'AI音乐生成与编辑',
|
||||
icon: 'icon-music',
|
||||
icon: 'icon-mp3',
|
||||
gradient: 'linear-gradient(135deg, #EF4444, #DC2626)',
|
||||
badge: '新功能',
|
||||
tag: 'AI音乐',
|
||||
status: 'active',
|
||||
statusText: '可用',
|
||||
url: '/mobile/create?tab=suno',
|
||||
url: '/mobile/suno-create',
|
||||
},
|
||||
{
|
||||
key: 'video',
|
||||
@@ -104,7 +104,7 @@ const aiTools = ref([
|
||||
tag: 'AI视频',
|
||||
status: 'beta',
|
||||
statusText: '测试版',
|
||||
url: '/mobile/create?tab=video',
|
||||
url: '/mobile/video-create',
|
||||
},
|
||||
{
|
||||
key: 'jimeng',
|
||||
@@ -115,24 +115,24 @@ const aiTools = ref([
|
||||
tag: 'AI绘画',
|
||||
status: 'active',
|
||||
statusText: '可用',
|
||||
url: '/mobile/create?tab=jimeng',
|
||||
url: '/mobile/jimeng-create',
|
||||
},
|
||||
{
|
||||
key: 'xmind',
|
||||
name: '思维导图',
|
||||
desc: 'AI思维导图生成',
|
||||
icon: 'icon-mind',
|
||||
key: 'imgWall',
|
||||
name: 'AI画廊',
|
||||
desc: 'AI绘画作品展示',
|
||||
icon: 'icon-image-list',
|
||||
gradient: 'linear-gradient(135deg, #3B82F6, #2563EB)',
|
||||
tag: 'AI工具',
|
||||
tag: 'AI展示',
|
||||
status: 'active',
|
||||
statusText: '可用',
|
||||
url: '/mobile/tools?tab=xmind',
|
||||
url: '/mobile/imgWall',
|
||||
},
|
||||
{
|
||||
key: 'apps',
|
||||
name: '应用中心',
|
||||
desc: '更多AI应用工具',
|
||||
icon: 'icon-apps',
|
||||
icon: 'icon-app',
|
||||
gradient: 'linear-gradient(135deg, #EC4899, #DB2777)',
|
||||
tag: '应用',
|
||||
status: 'active',
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
<template>
|
||||
<div class="mobile-feedback">
|
||||
<!-- 反馈表单 -->
|
||||
<div class="feedback-content">
|
||||
<!-- 反馈类型 -->
|
||||
<van-cell-group title="反馈类型">
|
||||
<van-radio-group v-model="feedbackType" direction="horizontal">
|
||||
<van-radio name="bug">问题反馈</van-radio>
|
||||
<van-radio name="feature">功能建议</van-radio>
|
||||
<van-radio name="other">其他</van-radio>
|
||||
</van-radio-group>
|
||||
</van-cell-group>
|
||||
|
||||
<!-- 反馈内容 -->
|
||||
<van-cell-group title="反馈内容">
|
||||
<van-field
|
||||
v-model="feedbackContent"
|
||||
type="textarea"
|
||||
placeholder="请详细描述您遇到的问题或建议..."
|
||||
:rows="6"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
autosize
|
||||
/>
|
||||
</van-cell-group>
|
||||
|
||||
<!-- 联系方式 -->
|
||||
<van-cell-group title="联系方式(选填)">
|
||||
<van-field v-model="contactInfo" placeholder="邮箱或手机号,方便我们回复您" clearable />
|
||||
</van-cell-group>
|
||||
|
||||
<!-- 图片上传 -->
|
||||
<van-cell-group title="上传截图(选填)">
|
||||
<van-uploader
|
||||
v-model="fileList"
|
||||
:max-count="3"
|
||||
:after-read="afterRead"
|
||||
:before-delete="beforeDelete"
|
||||
upload-text="上传图片"
|
||||
/>
|
||||
</van-cell-group>
|
||||
|
||||
<!-- 提交按钮 -->
|
||||
<div class="submit-section">
|
||||
<van-button type="primary" block :loading="submitting" @click="submitFeedback">
|
||||
提交反馈
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { showSuccessToast, showToast } from 'vant'
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 响应式数据
|
||||
const feedbackType = ref('bug')
|
||||
const feedbackContent = ref('')
|
||||
const contactInfo = ref('')
|
||||
const fileList = ref([])
|
||||
const submitting = ref(false)
|
||||
|
||||
// 上传图片后的处理
|
||||
const afterRead = (file) => {
|
||||
// 这里可以处理图片上传逻辑
|
||||
console.log('上传文件:', file)
|
||||
}
|
||||
|
||||
// 删除图片前的处理
|
||||
const beforeDelete = (file, detail) => {
|
||||
// 这里可以处理图片删除逻辑
|
||||
console.log('删除文件:', file)
|
||||
return true
|
||||
}
|
||||
|
||||
// 提交反馈
|
||||
const submitFeedback = async () => {
|
||||
if (!feedbackContent.value.trim()) {
|
||||
showToast('请输入反馈内容')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
|
||||
try {
|
||||
const feedbackData = {
|
||||
type: feedbackType.value,
|
||||
content: feedbackContent.value,
|
||||
contact: contactInfo.value,
|
||||
images: fileList.value.map((file) => file.url || file.content),
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
// 暂时使用本地存储保存反馈数据
|
||||
// 后续可以对接后端API
|
||||
const existingFeedback = JSON.parse(localStorage.getItem('userFeedback') || '[]')
|
||||
existingFeedback.push(feedbackData)
|
||||
localStorage.setItem('userFeedback', JSON.stringify(existingFeedback))
|
||||
|
||||
showSuccessToast('反馈提交成功,感谢您的建议!')
|
||||
|
||||
// 清空表单
|
||||
feedbackContent.value = ''
|
||||
contactInfo.value = ''
|
||||
fileList.value = []
|
||||
|
||||
// 返回上一页
|
||||
setTimeout(() => {
|
||||
router.back()
|
||||
}, 1500)
|
||||
} catch (error) {
|
||||
console.error('提交反馈失败:', error)
|
||||
showToast('提交失败,请稍后重试')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mobile-feedback {
|
||||
min-height: 100vh;
|
||||
background-color: #f7f8fa;
|
||||
}
|
||||
|
||||
.feedback-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.van-cell-group {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.van-radio-group {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.van-field {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.submit-section {
|
||||
margin-top: 32px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.van-uploader {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
// 自定义样式
|
||||
:deep(.van-cell-group__title) {
|
||||
padding: 16px 16px 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #323233;
|
||||
}
|
||||
|
||||
:deep(.van-field__control) {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
:deep(.van-radio__label) {
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,855 +0,0 @@
|
||||
<template>
|
||||
<div class="help-page">
|
||||
<div class="help-content">
|
||||
<!-- 搜索框 -->
|
||||
<div class="search-section" v-if="showSearch">
|
||||
<van-search
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索帮助内容"
|
||||
@search="onSearch"
|
||||
@cancel="showSearch = false"
|
||||
show-action
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 常见问题 -->
|
||||
<div class="faq-section" v-if="!showSearch">
|
||||
<h3 class="section-title">常见问题</h3>
|
||||
<van-collapse v-model="activeNames" accordion>
|
||||
<van-collapse-item
|
||||
v-for="faq in frequentFAQs"
|
||||
:key="faq.id"
|
||||
:title="faq.question"
|
||||
:name="faq.id"
|
||||
class="faq-item"
|
||||
>
|
||||
<div class="faq-answer" v-html="faq.answer"></div>
|
||||
</van-collapse-item>
|
||||
</van-collapse>
|
||||
</div>
|
||||
|
||||
<!-- 功能指南 -->
|
||||
<div class="guide-section" v-if="!showSearch">
|
||||
<h3 class="section-title">功能指南</h3>
|
||||
<van-grid :column-num="2" :gutter="12" :border="false">
|
||||
<van-grid-item
|
||||
v-for="guide in guides"
|
||||
:key="guide.id"
|
||||
@click="openGuide(guide)"
|
||||
class="guide-item"
|
||||
>
|
||||
<div class="guide-card">
|
||||
<div class="guide-icon" :style="{ backgroundColor: guide.color }">
|
||||
<i class="iconfont" :class="guide.icon"></i>
|
||||
</div>
|
||||
<div class="guide-title">{{ guide.title }}</div>
|
||||
<div class="guide-desc">{{ guide.desc }}</div>
|
||||
</div>
|
||||
</van-grid-item>
|
||||
</van-grid>
|
||||
</div>
|
||||
|
||||
<!-- 问题分类 -->
|
||||
<div class="category-section" v-if="!showSearch">
|
||||
<h3 class="section-title">问题分类</h3>
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
:title="category.name"
|
||||
:value="`${category.count}个问题`"
|
||||
is-link
|
||||
@click="openCategory(category)"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="iconfont" :class="category.icon" class="category-icon"></i>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<div class="search-results" v-if="showSearch && searchResults.length > 0">
|
||||
<h3 class="section-title">搜索结果</h3>
|
||||
<van-list>
|
||||
<van-cell
|
||||
v-for="result in searchResults"
|
||||
:key="result.id"
|
||||
:title="result.title"
|
||||
@click="openSearchResult(result)"
|
||||
is-link
|
||||
>
|
||||
<template #label>
|
||||
<div class="search-snippet" v-html="result.snippet"></div>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-list>
|
||||
</div>
|
||||
|
||||
<!-- 空搜索结果 -->
|
||||
<van-empty
|
||||
v-if="showSearch && searchKeyword && searchResults.length === 0"
|
||||
description="没有找到相关内容"
|
||||
/>
|
||||
|
||||
<!-- 联系客服 -->
|
||||
<div class="contact-section" v-if="!showSearch">
|
||||
<h3 class="section-title">联系我们</h3>
|
||||
<van-cell-group inset>
|
||||
<van-cell title="在线客服" icon="service-o" is-link @click="openCustomerService">
|
||||
<template #value>
|
||||
<span class="online-status">在线</span>
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-cell
|
||||
title="意见反馈"
|
||||
icon="chat-o"
|
||||
is-link
|
||||
@click="router.push('/mobile/feedback')"
|
||||
/>
|
||||
<van-cell title="官方QQ群" icon="friends-o" is-link @click="joinQQGroup">
|
||||
<template #value>
|
||||
<span class="qq-number">123456789</span>
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-cell title="官方微信" icon="wechat" is-link @click="showWeChatQR = true" />
|
||||
</van-cell-group>
|
||||
</div>
|
||||
|
||||
<!-- 使用提示 -->
|
||||
<div class="tips-section" v-if="!showSearch">
|
||||
<h3 class="section-title">使用提示</h3>
|
||||
<van-swipe :autoplay="5000" class="tips-swipe">
|
||||
<van-swipe-item v-for="tip in tips" :key="tip.id">
|
||||
<div class="tip-card">
|
||||
<div class="tip-icon">
|
||||
<i class="iconfont" :class="tip.icon"></i>
|
||||
</div>
|
||||
<h4 class="tip-title">{{ tip.title }}</h4>
|
||||
<p class="tip-content">{{ tip.content }}</p>
|
||||
</div>
|
||||
</van-swipe-item>
|
||||
</van-swipe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 帮助详情弹窗 -->
|
||||
<van-action-sheet v-model:show="showHelpDetail" :title="selectedHelp?.title">
|
||||
<div class="help-detail" v-if="selectedHelp">
|
||||
<div class="detail-content" v-html="selectedHelp.content"></div>
|
||||
<div class="detail-actions">
|
||||
<van-button @click="likeHelp(selectedHelp)">
|
||||
<van-icon name="good-job-o" /> 有用
|
||||
</van-button>
|
||||
<van-button @click="shareHelp(selectedHelp)">
|
||||
<van-icon name="share-o" /> 分享
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-action-sheet>
|
||||
|
||||
<!-- 微信二维码弹窗 -->
|
||||
<van-dialog v-model:show="showWeChatQR" title="官方微信" :show-cancel-button="false">
|
||||
<div class="wechat-qr">
|
||||
<div class="qr-code">
|
||||
<img src="/images/wechat-qr.png" alt="微信二维码" @error="onQRError" />
|
||||
</div>
|
||||
<p class="qr-tip">扫描二维码添加官方微信</p>
|
||||
</div>
|
||||
</van-dialog>
|
||||
|
||||
<!-- 客服聊天 -->
|
||||
<van-action-sheet
|
||||
v-model:show="showCustomerChat"
|
||||
title="在线客服"
|
||||
:close-on-click-overlay="false"
|
||||
>
|
||||
<div class="customer-chat">
|
||||
<div class="chat-header">
|
||||
<div class="customer-info">
|
||||
<van-image src="/images/customer-service.png" round width="40" height="40" />
|
||||
<div class="customer-detail">
|
||||
<div class="customer-name">智能客服</div>
|
||||
<div class="customer-status online">在线</div>
|
||||
</div>
|
||||
</div>
|
||||
<van-button size="small" @click="showCustomerChat = false">结束</van-button>
|
||||
</div>
|
||||
|
||||
<div class="chat-messages" ref="chatMessages">
|
||||
<div
|
||||
v-for="message in customerMessages"
|
||||
:key="message.id"
|
||||
class="message-item"
|
||||
:class="{ 'user-message': message.isUser }"
|
||||
>
|
||||
<div class="message-content">{{ message.content }}</div>
|
||||
<div class="message-time">{{ formatTime(message.time) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-input">
|
||||
<van-field
|
||||
v-model="customerMessage"
|
||||
placeholder="请输入您的问题..."
|
||||
@keyup.enter="sendCustomerMessage"
|
||||
>
|
||||
<template #button>
|
||||
<van-button size="small" type="primary" @click="sendCustomerMessage">
|
||||
发送
|
||||
</van-button>
|
||||
</template>
|
||||
</van-field>
|
||||
</div>
|
||||
</div>
|
||||
</van-action-sheet>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { showNotify, showSuccessToast } from 'vant'
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const showSearch = ref(false)
|
||||
const searchKeyword = ref('')
|
||||
const searchResults = ref([])
|
||||
const activeNames = ref([])
|
||||
const selectedHelp = ref(null)
|
||||
const showHelpDetail = ref(false)
|
||||
const showWeChatQR = ref(false)
|
||||
const showCustomerChat = ref(false)
|
||||
const customerMessage = ref('')
|
||||
const customerMessages = ref([])
|
||||
const chatMessages = ref()
|
||||
|
||||
// 常见问题
|
||||
const frequentFAQs = ref([
|
||||
{
|
||||
id: 1,
|
||||
question: '如何获得算力?',
|
||||
answer:
|
||||
'<p>您可以通过以下方式获得算力:</p><ul><li>注册即送算力</li><li>购买充值套餐</li><li>邀请好友注册</li><li>参与活动获得</li></ul>',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
question: '如何使用AI绘画功能?',
|
||||
answer:
|
||||
'<p>使用AI绘画功能很简单:</p><ol><li>进入创作中心</li><li>选择绘画工具(MJ、SD、DALL-E)</li><li>输入描述文字</li><li>点击生成即可</li></ol>',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
question: '为什么生成失败?',
|
||||
answer:
|
||||
'<p>生成失败可能的原因:</p><ul><li>算力不足</li><li>内容违规</li><li>网络不稳定</li><li>服务器繁忙</li></ul><p>请检查算力余额并重试。</p>',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
question: '如何成为VIP会员?',
|
||||
answer:
|
||||
'<p>成为VIP会员的方式:</p><ol><li>进入会员中心</li><li>选择合适的套餐</li><li>完成支付</li><li>自动开通VIP权限</li></ol>',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
question: '如何导出聊天记录?',
|
||||
answer:
|
||||
'<p>导出聊天记录步骤:</p><ol><li>进入对话页面</li><li>点击右上角菜单</li><li>选择"导出记录"</li><li>选择导出格式</li><li>确认导出</li></ol>',
|
||||
},
|
||||
])
|
||||
|
||||
// 功能指南
|
||||
const guides = ref([
|
||||
{
|
||||
id: 1,
|
||||
title: 'AI对话',
|
||||
desc: '与AI智能对话',
|
||||
icon: 'icon-chat',
|
||||
color: '#1989fa',
|
||||
content: 'AI对话使用指南详细内容...',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'AI绘画',
|
||||
desc: '生成精美图片',
|
||||
icon: 'icon-mj',
|
||||
color: '#8B5CF6',
|
||||
content: 'AI绘画使用指南详细内容...',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'AI音乐',
|
||||
desc: '创作美妙音乐',
|
||||
icon: 'icon-music',
|
||||
color: '#ee0a24',
|
||||
content: 'AI音乐创作指南详细内容...',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'AI视频',
|
||||
desc: '制作精彩视频',
|
||||
icon: 'icon-video',
|
||||
color: '#07c160',
|
||||
content: 'AI视频制作指南详细内容...',
|
||||
},
|
||||
])
|
||||
|
||||
// 问题分类
|
||||
const categories = ref([
|
||||
{ id: 1, name: '账户问题', icon: 'icon-user', count: 15 },
|
||||
{ id: 2, name: '功能使用', icon: 'icon-apps', count: 23 },
|
||||
{ id: 3, name: '充值支付', icon: 'icon-money', count: 12 },
|
||||
{ id: 4, name: '技术问题', icon: 'icon-setting', count: 18 },
|
||||
{ id: 5, name: '其他问题', icon: 'icon-help', count: 8 },
|
||||
])
|
||||
|
||||
// 使用提示
|
||||
const tips = ref([
|
||||
{
|
||||
id: 1,
|
||||
title: '提高绘画质量',
|
||||
content: '使用详细的描述词可以获得更好的绘画效果,建议加入风格、色彩、构图等关键词。',
|
||||
icon: 'icon-bulb',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '节省算力',
|
||||
content: '合理使用不同模型,简单问题使用GPT-3.5,复杂任务使用GPT-4。',
|
||||
icon: 'icon-flash',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '快速上手',
|
||||
content: '查看应用中心的预设角色,可以快速体验不同类型的AI对话。',
|
||||
icon: 'icon-star',
|
||||
},
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化客服消息
|
||||
customerMessages.value = [
|
||||
{
|
||||
id: 1,
|
||||
content: '您好!欢迎使用我们的AI创作平台,有什么可以帮助您的吗?',
|
||||
isUser: false,
|
||||
time: new Date(),
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
// 搜索
|
||||
const onSearch = (keyword) => {
|
||||
if (!keyword.trim()) {
|
||||
searchResults.value = []
|
||||
return
|
||||
}
|
||||
|
||||
// 模拟搜索结果
|
||||
const allContent = [
|
||||
...frequentFAQs.value.map((faq) => ({
|
||||
id: faq.id,
|
||||
title: faq.question,
|
||||
content: faq.answer,
|
||||
type: 'faq',
|
||||
})),
|
||||
...guides.value.map((guide) => ({
|
||||
id: guide.id,
|
||||
title: guide.title,
|
||||
content: guide.content,
|
||||
type: 'guide',
|
||||
})),
|
||||
]
|
||||
|
||||
searchResults.value = allContent
|
||||
.filter((item) => item.title.includes(keyword) || item.content.includes(keyword))
|
||||
.map((item) => ({
|
||||
...item,
|
||||
snippet: getSearchSnippet(item.content, keyword),
|
||||
}))
|
||||
}
|
||||
|
||||
// 获取搜索摘要
|
||||
const getSearchSnippet = (content, keyword) => {
|
||||
const cleanContent = content.replace(/<[^>]*>/g, '')
|
||||
const index = cleanContent.toLowerCase().indexOf(keyword.toLowerCase())
|
||||
if (index === -1) return cleanContent.substr(0, 100) + '...'
|
||||
|
||||
const start = Math.max(0, index - 50)
|
||||
const end = Math.min(cleanContent.length, index + keyword.length + 50)
|
||||
let snippet = cleanContent.substr(start, end - start)
|
||||
|
||||
// 高亮关键词
|
||||
const regex = new RegExp(`(${keyword})`, 'gi')
|
||||
snippet = snippet.replace(regex, '<mark>$1</mark>')
|
||||
|
||||
return (start > 0 ? '...' : '') + snippet + (end < cleanContent.length ? '...' : '')
|
||||
}
|
||||
|
||||
// 打开指南
|
||||
const openGuide = (guide) => {
|
||||
selectedHelp.value = {
|
||||
title: guide.title,
|
||||
content: guide.content || '<p>该指南内容正在完善中,敬请期待。</p>',
|
||||
}
|
||||
showHelpDetail.value = true
|
||||
}
|
||||
|
||||
// 打开分类
|
||||
const openCategory = (category) => {
|
||||
showNotify({ type: 'primary', message: `正在加载${category.name}...` })
|
||||
// 这里可以跳转到分类详情页
|
||||
}
|
||||
|
||||
// 打开搜索结果
|
||||
const openSearchResult = (result) => {
|
||||
selectedHelp.value = {
|
||||
title: result.title,
|
||||
content: result.content,
|
||||
}
|
||||
showHelpDetail.value = true
|
||||
}
|
||||
|
||||
// 点赞帮助
|
||||
const likeHelp = (help) => {
|
||||
showSuccessToast('感谢您的反馈!')
|
||||
}
|
||||
|
||||
// 分享帮助
|
||||
const shareHelp = (help) => {
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: help.title,
|
||||
text: help.content.replace(/<[^>]*>/g, ''),
|
||||
url: window.location.href,
|
||||
})
|
||||
} else {
|
||||
showNotify({ type: 'primary', message: '该功能暂不支持' })
|
||||
}
|
||||
}
|
||||
|
||||
// 打开客服
|
||||
const openCustomerService = () => {
|
||||
showCustomerChat.value = true
|
||||
}
|
||||
|
||||
// 发送客服消息
|
||||
const sendCustomerMessage = () => {
|
||||
if (!customerMessage.value.trim()) return
|
||||
|
||||
// 添加用户消息
|
||||
customerMessages.value.push({
|
||||
id: Date.now(),
|
||||
content: customerMessage.value,
|
||||
isUser: true,
|
||||
time: new Date(),
|
||||
})
|
||||
|
||||
const userMessage = customerMessage.value
|
||||
customerMessage.value = ''
|
||||
|
||||
// 滚动到底部
|
||||
nextTick(() => {
|
||||
if (chatMessages.value) {
|
||||
chatMessages.value.scrollTop = chatMessages.value.scrollHeight
|
||||
}
|
||||
})
|
||||
|
||||
// 模拟客服回复
|
||||
setTimeout(() => {
|
||||
let reply = '感谢您的问题,我们会尽快为您处理。'
|
||||
|
||||
if (userMessage.includes('算力')) {
|
||||
reply = '关于算力问题,您可以在会员中心购买算力套餐,或者通过邀请好友获得免费算力。'
|
||||
} else if (userMessage.includes('绘画')) {
|
||||
reply = '关于AI绘画,建议您使用详细的描述词,这样可以获得更好的效果。'
|
||||
} else if (userMessage.includes('充值')) {
|
||||
reply = '充值问题请您检查支付方式是否正确,如有问题可以联系技术客服。'
|
||||
}
|
||||
|
||||
customerMessages.value.push({
|
||||
id: Date.now(),
|
||||
content: reply,
|
||||
isUser: false,
|
||||
time: new Date(),
|
||||
})
|
||||
|
||||
nextTick(() => {
|
||||
if (chatMessages.value) {
|
||||
chatMessages.value.scrollTop = chatMessages.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// 加入QQ群
|
||||
const joinQQGroup = () => {
|
||||
// 尝试打开QQ群链接
|
||||
const qqGroupUrl =
|
||||
'mqqopensdkapi://bizAgent/qm/qr?url=http%3A%2F%2Fqm.qq.com%2Fcgi-bin%2Fqm%2Fqr%3Ffrom%3Dapp%26p%3Dandroid%26k%3D123456789'
|
||||
window.location.href = qqGroupUrl
|
||||
|
||||
setTimeout(() => {
|
||||
showNotify({ type: 'primary', message: '请在QQ中搜索群号:123456789' })
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time) => {
|
||||
return time.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
// 二维码加载错误
|
||||
const onQRError = (e) => {
|
||||
e.target.src = '/images/default-qr.png'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.help-page {
|
||||
min-height: 100vh;
|
||||
background: var(--van-background);
|
||||
|
||||
.help-content {
|
||||
padding: 54px 16px 20px;
|
||||
|
||||
.search-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--van-text-color);
|
||||
margin: 0 0 16px 4px;
|
||||
}
|
||||
|
||||
.faq-section {
|
||||
margin-bottom: 24px;
|
||||
|
||||
:deep(.van-collapse-item) {
|
||||
background: var(--van-cell-background);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
|
||||
.van-collapse-item__title {
|
||||
padding: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.van-collapse-item__content {
|
||||
padding: 0 16px 16px;
|
||||
|
||||
.faq-answer {
|
||||
color: var(--van-gray-7);
|
||||
line-height: 1.6;
|
||||
|
||||
:deep(ul),
|
||||
:deep(ol) {
|
||||
padding-left: 20px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
:deep(li) {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
:deep(p) {
|
||||
margin: 8px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.guide-section {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.guide-item {
|
||||
.guide-card {
|
||||
background: var(--van-cell-background);
|
||||
border-radius: 12px;
|
||||
padding: 20px 16px;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.guide-icon {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 12px;
|
||||
|
||||
.iconfont {
|
||||
font-size: 24px;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.guide-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--van-text-color);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.guide-desc {
|
||||
font-size: 13px;
|
||||
color: var(--van-gray-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.category-section,
|
||||
.contact-section {
|
||||
margin-bottom: 24px;
|
||||
|
||||
:deep(.van-cell-group) {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
|
||||
.van-cell {
|
||||
padding: 16px;
|
||||
|
||||
.category-icon {
|
||||
font-size: 18px;
|
||||
color: var(--van-primary-color);
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.van-cell__title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.online-status {
|
||||
color: #07c160;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.qq-number {
|
||||
color: var(--van-gray-6);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-results {
|
||||
.search-snippet {
|
||||
margin-top: 4px;
|
||||
color: var(--van-gray-6);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
|
||||
:deep(mark) {
|
||||
background: var(--van-primary-color);
|
||||
color: white;
|
||||
padding: 1px 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tips-section {
|
||||
.tips-swipe {
|
||||
height: 140px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
|
||||
.tip-card {
|
||||
background: linear-gradient(135deg, var(--van-primary-color), #8b5cf6);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
.tip-icon {
|
||||
margin-bottom: 12px;
|
||||
|
||||
.iconfont {
|
||||
font-size: 28px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.tip-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.tip-content {
|
||||
font-size: 13px;
|
||||
opacity: 0.9;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.help-detail {
|
||||
padding: 20px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
|
||||
.detail-content {
|
||||
color: var(--van-text-color);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 20px;
|
||||
|
||||
:deep(p) {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
:deep(ul),
|
||||
:deep(ol) {
|
||||
padding-left: 20px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
.van-button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wechat-qr {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
|
||||
.qr-code {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
margin: 0 auto 16px;
|
||||
border: 1px solid var(--van-border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.qr-tip {
|
||||
font-size: 14px;
|
||||
color: var(--van-gray-6);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.customer-chat {
|
||||
height: 500px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--van-border-color);
|
||||
|
||||
.customer-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.customer-detail {
|
||||
margin-left: 12px;
|
||||
|
||||
.customer-name {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--van-text-color);
|
||||
}
|
||||
|
||||
.customer-status {
|
||||
font-size: 12px;
|
||||
|
||||
&.online {
|
||||
color: #07c160;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
|
||||
.message-item {
|
||||
margin-bottom: 16px;
|
||||
|
||||
&.user-message {
|
||||
text-align: right;
|
||||
|
||||
.message-content {
|
||||
background: var(--van-primary-color);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.message-content {
|
||||
display: inline-block;
|
||||
max-width: 80%;
|
||||
padding: 10px 12px;
|
||||
background: var(--van-gray-1);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 11px;
|
||||
color: var(--van-gray-5);
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--van-border-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 深色主题优化
|
||||
:deep(.van-theme-dark) {
|
||||
.help-page {
|
||||
.van-collapse-item,
|
||||
.guide-card,
|
||||
.van-cell-group {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -151,29 +151,23 @@ const features = ref([
|
||||
name: '音乐创作',
|
||||
icon: 'icon-mp3',
|
||||
color: '#EF4444',
|
||||
url: '/mobile/create?tab=suno',
|
||||
url: '/mobile/suno-create',
|
||||
},
|
||||
{
|
||||
key: 'video',
|
||||
name: '视频生成',
|
||||
icon: 'icon-video',
|
||||
color: '#10B981',
|
||||
url: '/mobile/create?tab=video',
|
||||
url: '/mobile/video-create',
|
||||
},
|
||||
{
|
||||
key: 'jimeng',
|
||||
name: '即梦AI',
|
||||
icon: 'icon-jimeng',
|
||||
color: '#F97316',
|
||||
url: '/mobile/create?tab=jimeng',
|
||||
},
|
||||
{
|
||||
key: 'agent',
|
||||
name: '智能体',
|
||||
icon: 'icon-app',
|
||||
color: '#3B82F6',
|
||||
url: '/mobile/apps',
|
||||
url: '/mobile/jimeng-create',
|
||||
},
|
||||
{ key: 'agent', name: '智能体', icon: 'icon-app', color: '#3B82F6', url: '/mobile/apps' },
|
||||
{
|
||||
key: 'imgWall',
|
||||
name: '作品展示',
|
||||
|
||||
@@ -56,43 +56,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邀请方式 -->
|
||||
<div class="invite-methods">
|
||||
<h3 class="section-title">邀请方式</h3>
|
||||
<div class="methods-grid">
|
||||
<div class="method-item" @click="shareToWeChat">
|
||||
<div class="method-icon wechat">
|
||||
<i class="iconfont icon-wechat"></i>
|
||||
</div>
|
||||
<div class="method-name">微信分享</div>
|
||||
</div>
|
||||
<div class="method-item" @click="copyInviteLink">
|
||||
<div class="method-icon link">
|
||||
<i class="iconfont icon-link"></i>
|
||||
</div>
|
||||
<div class="method-name">复制链接</div>
|
||||
</div>
|
||||
<div class="method-item" @click="shareQRCode">
|
||||
<div class="method-icon qr">
|
||||
<i class="iconfont icon-qrcode"></i>
|
||||
</div>
|
||||
<div class="method-name">二维码</div>
|
||||
</div>
|
||||
<div class="method-item" @click="shareToFriends">
|
||||
<div class="method-icon more">
|
||||
<i class="iconfont icon-share"></i>
|
||||
</div>
|
||||
<div class="method-name">更多</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邀请码 -->
|
||||
<div class="invite-code-section">
|
||||
<div class="code-card">
|
||||
<div class="code-header">
|
||||
<span class="code-label">我的邀请码</span>
|
||||
<van-button size="small" type="primary" plain @click="copyInviteCode">
|
||||
<van-button
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
class="copy-invite-code"
|
||||
:data-clipboard-text="inviteCode"
|
||||
>
|
||||
复制
|
||||
</van-button>
|
||||
</div>
|
||||
@@ -100,7 +75,12 @@
|
||||
<div class="code-link">
|
||||
<van-field v-model="inviteLink" readonly placeholder="邀请链接">
|
||||
<template #button>
|
||||
<van-button size="small" type="primary" @click="copyInviteLink">
|
||||
<van-button
|
||||
size="small"
|
||||
type="primary"
|
||||
class="copy-invite-link"
|
||||
:data-clipboard-text="inviteLink"
|
||||
>
|
||||
复制链接
|
||||
</van-button>
|
||||
</template>
|
||||
@@ -113,70 +93,45 @@
|
||||
<div class="invite-records">
|
||||
<div class="records-header">
|
||||
<h3 class="section-title">邀请记录</h3>
|
||||
<van-button size="small" type="primary" plain @click="showAllRecords = !showAllRecords">
|
||||
{{ showAllRecords ? '收起' : '查看全部' }}
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<div class="records-list">
|
||||
<van-list
|
||||
v-model:loading="recordsLoading"
|
||||
:finished="recordsFinished"
|
||||
finished-text="没有更多记录"
|
||||
@load="loadInviteRecords"
|
||||
>
|
||||
<div v-for="record in displayRecords" :key="record.id" class="record-item">
|
||||
<div v-if="inviteRecords.length > 0">
|
||||
<div v-for="record in inviteRecords" :key="record.id" class="record-item">
|
||||
<div class="record-avatar">
|
||||
<van-image :src="record.avatar" round width="40" height="40" />
|
||||
<van-image
|
||||
:src="record.avatar || '/images/avatar/default.jpg'"
|
||||
round
|
||||
width="40"
|
||||
height="40"
|
||||
@error="onAvatarError"
|
||||
/>
|
||||
</div>
|
||||
<div class="record-info">
|
||||
<div class="record-name">{{ record.username }}</div>
|
||||
<div class="record-time">{{ formatTime(record.created_at) }}</div>
|
||||
</div>
|
||||
<div class="record-status">
|
||||
<van-tag :type="record.status === 'completed' ? 'success' : 'warning'">
|
||||
{{ record.status === 'completed' ? '已获得奖励' : '待获得奖励' }}
|
||||
</van-tag>
|
||||
<van-tag type="success">已获得奖励</van-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<van-empty
|
||||
v-if="!recordsLoading && inviteRecords.length === 0"
|
||||
description="暂无邀请记录"
|
||||
/>
|
||||
</van-list>
|
||||
<van-empty v-else description="暂无邀请记录" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 二维码弹窗 -->
|
||||
<van-dialog
|
||||
v-model:show="showQRDialog"
|
||||
title="邀请二维码"
|
||||
:show-cancel-button="false"
|
||||
confirm-button-text="保存图片"
|
||||
@confirm="saveQRCode"
|
||||
>
|
||||
<div class="qr-content">
|
||||
<div ref="qrCodeRef" class="qr-code">
|
||||
<!-- 这里应该生成实际的二维码 -->
|
||||
<div class="qr-placeholder">
|
||||
<i class="iconfont icon-qrcode"></i>
|
||||
<p>邀请二维码</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="qr-tip">扫描二维码或长按保存分享给好友</p>
|
||||
</div>
|
||||
</van-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { checkSession } from '@/store/cache'
|
||||
import { showLoginDialog } from '@/utils/libs'
|
||||
import { httpGet } from '@/utils/http'
|
||||
import { showNotify, showSuccessToast } from 'vant'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Clipboard from 'clipboard'
|
||||
|
||||
const router = useRouter()
|
||||
const userStats = ref({
|
||||
@@ -187,204 +142,126 @@ const userStats = ref({
|
||||
const inviteCode = ref('')
|
||||
const inviteLink = ref('')
|
||||
const inviteRecords = ref([])
|
||||
const recordsLoading = ref(false)
|
||||
const recordsFinished = ref(false)
|
||||
const showAllRecords = ref(false)
|
||||
const showQRDialog = ref(false)
|
||||
const qrCodeRef = ref()
|
||||
|
||||
// 奖励规则配置
|
||||
const rewardRules = ref([
|
||||
{
|
||||
id: 1,
|
||||
title: '好友注册',
|
||||
desc: '好友通过邀请链接成功注册',
|
||||
icon: 'icon-user-plus',
|
||||
color: '#1989fa',
|
||||
reward: 50,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '好友首次充值',
|
||||
desc: '好友首次充值任意金额',
|
||||
icon: 'icon-money',
|
||||
color: '#07c160',
|
||||
reward: 100,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '好友活跃使用',
|
||||
desc: '好友连续使用7天',
|
||||
icon: 'icon-star',
|
||||
color: '#ff9500',
|
||||
reward: 200,
|
||||
},
|
||||
])
|
||||
const rewardRules = ref([])
|
||||
|
||||
// 显示的记录(根据showAllRecords决定)
|
||||
const displayRecords = computed(() => {
|
||||
return showAllRecords.value ? inviteRecords.value : inviteRecords.value.slice(0, 5)
|
||||
})
|
||||
// clipboard实例
|
||||
const clipboard = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
initPage()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清理clipboard实例
|
||||
if (clipboard.value) {
|
||||
clipboard.value.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
const initPage = async () => {
|
||||
try {
|
||||
const user = await checkSession()
|
||||
|
||||
// 生成邀请码和链接
|
||||
inviteCode.value = user.invite_code || generateInviteCode()
|
||||
inviteLink.value = `${location.origin}/register?invite=${inviteCode.value}`
|
||||
|
||||
// 获取用户邀请统计
|
||||
// 获取邀请统计(包含邀请码和链接)
|
||||
fetchInviteStats()
|
||||
|
||||
// 加载邀请记录
|
||||
loadInviteRecords()
|
||||
// 获取奖励规则
|
||||
fetchRewardRules()
|
||||
|
||||
// 一次性加载邀请记录
|
||||
await loadInviteRecords()
|
||||
|
||||
// 初始化clipboard
|
||||
initClipboard()
|
||||
} catch (error) {
|
||||
showLoginDialog(router)
|
||||
}
|
||||
}
|
||||
|
||||
const generateInviteCode = () => {
|
||||
return Math.random().toString(36).substr(2, 8).toUpperCase()
|
||||
}
|
||||
|
||||
const fetchInviteStats = () => {
|
||||
// 这里应该调用实际的API
|
||||
// httpGet('/api/user/invite/stats').then(res => {
|
||||
// userStats.value = res.data
|
||||
// })
|
||||
|
||||
// 临时使用模拟数据
|
||||
userStats.value = {
|
||||
inviteCount: Math.floor(Math.random() * 50),
|
||||
rewardTotal: Math.floor(Math.random() * 5000),
|
||||
todayInvite: Math.floor(Math.random() * 5),
|
||||
}
|
||||
}
|
||||
|
||||
const loadInviteRecords = () => {
|
||||
if (recordsFinished.value) return
|
||||
|
||||
recordsLoading.value = true
|
||||
|
||||
// 模拟API调用
|
||||
setTimeout(() => {
|
||||
const mockRecords = generateMockRecords()
|
||||
inviteRecords.value.push(...mockRecords)
|
||||
|
||||
recordsLoading.value = false
|
||||
|
||||
// 模拟数据加载完成
|
||||
if (inviteRecords.value.length >= 20) {
|
||||
recordsFinished.value = true
|
||||
const fetchInviteStats = async () => {
|
||||
try {
|
||||
const res = await httpGet('/api/invite/stats')
|
||||
userStats.value = {
|
||||
inviteCount: res.data.invite_count,
|
||||
rewardTotal: res.data.reward_total,
|
||||
todayInvite: res.data.today_invite,
|
||||
}
|
||||
inviteCode.value = res.data.invite_code
|
||||
inviteLink.value = res.data.invite_link
|
||||
} catch (error) {
|
||||
console.error('获取邀请统计失败:', error)
|
||||
// 使用默认值
|
||||
userStats.value = {
|
||||
inviteCount: 0,
|
||||
rewardTotal: 0,
|
||||
todayInvite: 0,
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const generateMockRecords = () => {
|
||||
const records = []
|
||||
const names = ['张三', '李四', '王五', '赵六', '钱七', '孙八', '周九', '吴十']
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
records.push({
|
||||
id: Date.now() + i,
|
||||
username: names[i % names.length] + (i + 1),
|
||||
avatar: '/images/avatar/default.jpg',
|
||||
status: Math.random() > 0.3 ? 'completed' : 'pending',
|
||||
created_at: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
return records
|
||||
}
|
||||
|
||||
const formatTime = (timeStr) => {
|
||||
const date = new Date(timeStr)
|
||||
const fetchRewardRules = async () => {
|
||||
try {
|
||||
const res = await httpGet('/api/invite/rules')
|
||||
rewardRules.value = res.data
|
||||
} catch (error) {
|
||||
console.error('获取奖励规则失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadInviteRecords = async () => {
|
||||
try {
|
||||
const res = await httpGet('/api/invite/list', {
|
||||
page: 1, // 加载第一页
|
||||
page_size: 20,
|
||||
})
|
||||
console.log('邀请记录API返回:', res.data) // 调试日志
|
||||
inviteRecords.value = res.data.items || []
|
||||
console.log('设置邀请记录:', inviteRecords.value) // 调试日志
|
||||
|
||||
// 调试头像信息
|
||||
if (inviteRecords.value.length > 0) {
|
||||
console.log('第一条记录头像:', inviteRecords.value[0].avatar)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取邀请记录失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (timestamp) => {
|
||||
const date = new Date(timestamp * 1000) // 转换为毫秒
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
|
||||
// 分享到微信
|
||||
const shareToWeChat = () => {
|
||||
if (typeof WeixinJSBridge !== 'undefined') {
|
||||
// 在微信中分享
|
||||
WeixinJSBridge.invoke('sendAppMessage', {
|
||||
title: '邀请你使用AI创作平台',
|
||||
desc: '强大的AI工具,让创作更简单',
|
||||
link: inviteLink.value,
|
||||
imgUrl: `${location.origin}/images/share-logo.png`,
|
||||
})
|
||||
} else {
|
||||
// 复制链接提示
|
||||
copyInviteLink()
|
||||
showNotify({ type: 'primary', message: '请在微信中打开链接进行分享' })
|
||||
// 初始化clipboard
|
||||
const initClipboard = () => {
|
||||
// 销毁之前的实例
|
||||
if (clipboard.value) {
|
||||
clipboard.value.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
// 复制邀请码
|
||||
const copyInviteCode = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(inviteCode.value)
|
||||
showSuccessToast('邀请码已复制')
|
||||
} catch (err) {
|
||||
// 降级方案
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = inviteCode.value
|
||||
document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textArea)
|
||||
showSuccessToast('邀请码已复制')
|
||||
}
|
||||
}
|
||||
// 创建新的clipboard实例
|
||||
clipboard.value = new Clipboard('.copy-invite-code, .copy-invite-link')
|
||||
|
||||
// 复制邀请链接
|
||||
const copyInviteLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(inviteLink.value)
|
||||
showSuccessToast('邀请链接已复制')
|
||||
} catch (err) {
|
||||
// 降级方案
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = inviteLink.value
|
||||
document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textArea)
|
||||
showSuccessToast('邀请链接已复制')
|
||||
}
|
||||
}
|
||||
clipboard.value.on('success', () => {
|
||||
showSuccessToast('复制成功')
|
||||
})
|
||||
|
||||
// 显示二维码
|
||||
const shareQRCode = () => {
|
||||
showQRDialog.value = true
|
||||
}
|
||||
|
||||
// 保存二维码
|
||||
const saveQRCode = () => {
|
||||
showNotify({ type: 'primary', message: '请长按二维码保存到相册' })
|
||||
}
|
||||
|
||||
// 更多分享方式
|
||||
const shareToFriends = () => {
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: '邀请你使用AI创作平台',
|
||||
text: '强大的AI工具,让创作更简单',
|
||||
url: inviteLink.value,
|
||||
})
|
||||
} else {
|
||||
copyInviteLink()
|
||||
}
|
||||
clipboard.value.on('error', () => {
|
||||
showNotify({ type: 'danger', message: '复制失败' })
|
||||
})
|
||||
}
|
||||
|
||||
// 图片加载错误处理
|
||||
const onImageError = (e) => {
|
||||
e.target.src = '/images/img-holder.png'
|
||||
}
|
||||
|
||||
// 头像加载错误处理
|
||||
const onAvatarError = (e) => {
|
||||
e.target.src = '/images/avatar/default.jpg'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -393,8 +270,6 @@ const onImageError = (e) => {
|
||||
background: var(--van-background);
|
||||
|
||||
.invite-content {
|
||||
padding-top: 46px;
|
||||
|
||||
.invite-header {
|
||||
position: relative;
|
||||
height: 200px;
|
||||
@@ -469,7 +344,6 @@ const onImageError = (e) => {
|
||||
}
|
||||
|
||||
.rules-section,
|
||||
.invite-methods,
|
||||
.invite-code-section,
|
||||
.invite-records {
|
||||
padding: 0 16px 16px;
|
||||
@@ -541,65 +415,6 @@ const onImageError = (e) => {
|
||||
}
|
||||
}
|
||||
|
||||
.methods-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
|
||||
.method-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 16px;
|
||||
background: var(--van-cell-background);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.method-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&.wechat {
|
||||
background: #07c160;
|
||||
}
|
||||
|
||||
&.link {
|
||||
background: #1989fa;
|
||||
}
|
||||
|
||||
&.qr {
|
||||
background: #8b5cf6;
|
||||
}
|
||||
|
||||
&.more {
|
||||
background: #ff9500;
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
font-size: 20px;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.method-name {
|
||||
font-size: 12px;
|
||||
color: var(--van-text-color);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.invite-code-section {
|
||||
.code-card {
|
||||
background: var(--van-cell-background);
|
||||
@@ -675,44 +490,6 @@ const onImageError = (e) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.qr-content {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
|
||||
.qr-code {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
margin: 0 auto 16px;
|
||||
border: 1px solid var(--van-border-color);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--van-background-2);
|
||||
|
||||
.qr-placeholder {
|
||||
text-align: center;
|
||||
color: var(--van-gray-6);
|
||||
|
||||
.iconfont {
|
||||
font-size: 48px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.qr-tip {
|
||||
font-size: 13px;
|
||||
color: var(--van-gray-6);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 深色主题优化
|
||||
|
||||
@@ -1,36 +1,9 @@
|
||||
<template>
|
||||
<div class="member-page">
|
||||
<div class="member-content" v-loading="loading" :element-loading-text="loadingText">
|
||||
<!-- 用户信息卡片 -->
|
||||
<div class="user-card" v-if="isLogin">
|
||||
<div class="user-header">
|
||||
<div class="user-avatar">
|
||||
<van-image :src="userAvatar" round width="60" height="60" />
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<h3 class="username">{{ userInfo.nickname || userInfo.username }}</h3>
|
||||
<div class="user-meta">
|
||||
<van-tag type="primary" v-if="isVip">VIP会员</van-tag>
|
||||
<van-tag type="default" v-else>普通用户</van-tag>
|
||||
<span class="user-id">ID: {{ userInfo.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ userInfo.power || 0 }}</div>
|
||||
<div class="stat-label">剩余算力</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ userInfo.invite_count || 0 }}</div>
|
||||
<div class="stat-label">邀请人数</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 产品套餐 -->
|
||||
<div class="products-section">
|
||||
<h3 class="section-title">充值套餐</h3>
|
||||
<div class="text-center bg-[#7c3aed] text-white rounded-lg p-3 mb-4">充值套餐</div>
|
||||
<!-- <div class="info-alert" v-if="vipInfoText">
|
||||
<van-notice-bar
|
||||
:text="vipInfoText"
|
||||
@@ -73,7 +46,6 @@
|
||||
</div>
|
||||
|
||||
<div class="payment-methods">
|
||||
<div class="methods-title">支付方式:</div>
|
||||
<div class="methods-grid">
|
||||
<van-button
|
||||
v-for="payWay in payWays"
|
||||
@@ -167,7 +139,6 @@ import { computed, onMounted, ref } from 'vue'
|
||||
// 响应式数据
|
||||
const list = ref([])
|
||||
const vipImg = ref('/images/menu/member.png')
|
||||
const userInfo = ref({})
|
||||
const isLogin = ref(false)
|
||||
const loading = ref(true)
|
||||
const loadingText = ref('加载中...')
|
||||
@@ -187,24 +158,6 @@ const currentPayWay = ref(null)
|
||||
|
||||
const store = useSharedStore()
|
||||
|
||||
// 计算属性
|
||||
const isVip = computed(() => {
|
||||
const now = Date.now()
|
||||
const expiredTime = userInfo.value.expired_time ? userInfo.value.expired_time * 1000 : 0
|
||||
return expiredTime > now
|
||||
})
|
||||
|
||||
const vipDays = computed(() => {
|
||||
if (!isVip.value) return 0
|
||||
const now = Date.now()
|
||||
const expiredTime = userInfo.value.expired_time * 1000
|
||||
return Math.ceil((expiredTime - now) / (24 * 60 * 60 * 1000))
|
||||
})
|
||||
|
||||
const userAvatar = computed(() => {
|
||||
return userInfo.value.avatar || '/images/avatar/default.jpg'
|
||||
})
|
||||
|
||||
// 支付按钮颜色
|
||||
const getPayButtonColor = (payType) => {
|
||||
const colors = {
|
||||
@@ -248,7 +201,6 @@ const getPayButtonText = (payType) => {
|
||||
onMounted(() => {
|
||||
checkSession()
|
||||
.then((user) => {
|
||||
userInfo.value = user
|
||||
isLogin.value = true
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -310,7 +262,7 @@ const pay = (product, payWay) => {
|
||||
product_id: product.id,
|
||||
pay_way: payWay.pay_way,
|
||||
pay_type: payWay.pay_type,
|
||||
user_id: userInfo.value.id,
|
||||
user_id: 0, // 移除用户ID依赖
|
||||
host: host,
|
||||
device: 'mobile',
|
||||
})
|
||||
@@ -343,10 +295,6 @@ const payCallback = (success) => {
|
||||
if (success) {
|
||||
showSuccessToast('支付成功!')
|
||||
userOrderKey.value += 1
|
||||
// 刷新用户信息
|
||||
checkSession().then((user) => {
|
||||
userInfo.value = user
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,10 +304,6 @@ const redeemCallback = (success) => {
|
||||
|
||||
if (success) {
|
||||
showSuccessToast('卡密兑换成功!')
|
||||
// 刷新用户信息
|
||||
checkSession().then((user) => {
|
||||
userInfo.value = user
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -372,66 +316,6 @@ const redeemCallback = (success) => {
|
||||
.member-content {
|
||||
padding: 20px 16px;
|
||||
|
||||
.user-card {
|
||||
background: linear-gradient(135deg, var(--van-primary-color), #8b5cf6);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
color: white;
|
||||
box-shadow: 0 8px 32px rgba(139, 92, 246, 0.3);
|
||||
|
||||
.user-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.user-avatar {
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
|
||||
.username {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.user-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.user-id {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-stats {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
@@ -653,18 +537,6 @@ const redeemCallback = (success) => {
|
||||
.member-content {
|
||||
padding: 16px 12px;
|
||||
|
||||
.user-card {
|
||||
padding: 20px;
|
||||
|
||||
.user-header .user-info .username {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.user-stats .stat-item .stat-value {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.products-section .products-grid .product-card {
|
||||
padding: 16px;
|
||||
|
||||
|
||||
@@ -28,19 +28,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="filter-bar">
|
||||
<CustomTabs
|
||||
:model-value="activeType"
|
||||
@update:model-value="activeType = $event"
|
||||
@tab-click="onTypeChange"
|
||||
>
|
||||
<CustomTabPane name="all" label="全部" />
|
||||
<CustomTabPane name="chat" label="对话" />
|
||||
<CustomTabPane name="image" label="绘画" />
|
||||
<CustomTabPane name="music" label="音乐" />
|
||||
<CustomTabPane name="video" label="视频" />
|
||||
</CustomTabs>
|
||||
</div>
|
||||
<div class="filter-bar" style="display: none"></div>
|
||||
|
||||
<!-- 日志列表 -->
|
||||
<div class="log-list">
|
||||
@@ -62,17 +50,26 @@
|
||||
<i class="iconfont" :class="getTypeIcon(item.type)"></i>
|
||||
</div>
|
||||
<div class="log-info">
|
||||
<div class="log-title">{{ item.title }}</div>
|
||||
<div class="log-title">
|
||||
{{ item.model || getTypeTitle(item.type) }}
|
||||
<van-tag type="primary" class="ml-2">{{ item.type_str }}</van-tag>
|
||||
</div>
|
||||
<div class="log-time">{{ formatTime(item.created_at) }}</div>
|
||||
</div>
|
||||
<div class="log-cost">
|
||||
<span class="cost-value">-{{ item.cost }}</span>
|
||||
<span class="cost-value" :class="{ income: item.mark === 1 }">
|
||||
{{ item.mark === 1 ? '+' : '-' }}{{ item.amount }}
|
||||
</span>
|
||||
<span class="cost-unit">算力</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-detail" v-if="item.remark">
|
||||
<van-text-ellipsis :content="item.remark" expand-text="展开" collapse-text="收起" />
|
||||
</div>
|
||||
<div class="log-balance" v-if="item.balance !== undefined">
|
||||
<span class="balance-label">余额:</span>
|
||||
<span class="balance-value">{{ item.balance }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
@@ -81,74 +78,21 @@
|
||||
</van-pull-refresh>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选弹窗 -->
|
||||
<van-action-sheet
|
||||
:model-value="showFilter"
|
||||
@update:model-value="showFilter = $event"
|
||||
title="筛选条件"
|
||||
>
|
||||
<div class="filter-content">
|
||||
<van-form>
|
||||
<van-field label="时间范围">
|
||||
<template #input>
|
||||
<van-button size="small" @click="showDatePicker = true">
|
||||
{{
|
||||
dateRange.start && dateRange.end
|
||||
? `${dateRange.start} 至 ${dateRange.end}`
|
||||
: '选择时间'
|
||||
}}
|
||||
</van-button>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field label="消费类型">
|
||||
<template #input>
|
||||
<van-radio-group v-model="filterType" direction="horizontal">
|
||||
<van-radio name="all">全部</van-radio>
|
||||
<van-radio name="chat">对话</van-radio>
|
||||
<van-radio name="image">绘画</van-radio>
|
||||
<van-radio name="music">音乐</van-radio>
|
||||
</van-radio-group>
|
||||
</template>
|
||||
</van-field>
|
||||
</van-form>
|
||||
<div class="filter-actions">
|
||||
<van-button @click="resetFilter">重置</van-button>
|
||||
<van-button type="primary" @click="applyFilter">确定</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-action-sheet>
|
||||
|
||||
<!-- 日期选择器 -->
|
||||
<van-calendar
|
||||
:model-value="showDatePicker"
|
||||
@update:model-value="showDatePicker = $event"
|
||||
type="range"
|
||||
@confirm="onDateConfirm"
|
||||
:max-date="new Date()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import CustomTabPane from '@/components/ui/CustomTabPane.vue'
|
||||
import CustomTabs from '@/components/ui/CustomTabs.vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { httpPost, httpGet } from '@/utils/http'
|
||||
import { checkSession } from '@/store/cache'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const finished = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const logList = ref([])
|
||||
const activeType = ref('all')
|
||||
const showFilter = ref(false)
|
||||
const showDatePicker = ref(false)
|
||||
const filterType = ref('all')
|
||||
const dateRange = ref({
|
||||
start: '',
|
||||
end: '',
|
||||
})
|
||||
|
||||
// 统计数据
|
||||
const stats = ref({
|
||||
@@ -158,123 +102,105 @@ const stats = ref({
|
||||
})
|
||||
|
||||
// 分页参数
|
||||
const pageParams = ref({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
type: 'all',
|
||||
})
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
onMounted(() => {
|
||||
fetchStats()
|
||||
onLoad()
|
||||
checkSession()
|
||||
.then(() => {
|
||||
fetchStats()
|
||||
fetchData()
|
||||
})
|
||||
.catch(() => {})
|
||||
})
|
||||
|
||||
// 获取统计数据
|
||||
const fetchStats = () => {
|
||||
// 这里应该调用实际的API
|
||||
// httpGet('/api/user/power/stats').then(res => {
|
||||
// stats.value = res.data
|
||||
// })
|
||||
|
||||
// 临时使用模拟数据
|
||||
stats.value = {
|
||||
total: Math.floor(Math.random() * 10000),
|
||||
today: Math.floor(Math.random() * 100),
|
||||
balance: Math.floor(Math.random() * 1000),
|
||||
}
|
||||
// 调用后端统计API
|
||||
httpGet('/api/powerLog/stats')
|
||||
.then((res) => {
|
||||
if (res.data) {
|
||||
stats.value = {
|
||||
total: res.data.total || 0,
|
||||
today: res.data.today || 0,
|
||||
balance: res.data.balance || 0,
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('获取统计数据失败:', e)
|
||||
// 使用默认值
|
||||
stats.value = {
|
||||
total: 0,
|
||||
today: 0,
|
||||
balance: 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 加载日志列表
|
||||
// 获取数据
|
||||
const fetchData = () => {
|
||||
loading.value = true
|
||||
httpPost('/api/powerLog/list', {
|
||||
model: '', // 移除筛选参数
|
||||
date: [], // 移除筛选参数
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
})
|
||||
.then((res) => {
|
||||
const items = res.data.items || []
|
||||
if (items.length === 0) {
|
||||
finished.value = true
|
||||
return
|
||||
}
|
||||
if (page.value === 1) {
|
||||
logList.value = items
|
||||
} else {
|
||||
logList.value.push(...res.data.items)
|
||||
}
|
||||
total.value = res.data.total
|
||||
// 判断是否加载完成
|
||||
if (logList.value.length >= total.value) {
|
||||
finished.value = true
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
ElMessage.error('获取数据失败:' + e.message)
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
})
|
||||
}
|
||||
|
||||
// 加载更多
|
||||
const onLoad = () => {
|
||||
if (finished.value) return
|
||||
|
||||
loading.value = true
|
||||
|
||||
// 模拟API调用
|
||||
setTimeout(() => {
|
||||
const mockData = generateMockData(pageParams.value.page, pageParams.value.limit)
|
||||
|
||||
if (pageParams.value.page === 1) {
|
||||
logList.value = mockData
|
||||
} else {
|
||||
logList.value.push(...mockData)
|
||||
}
|
||||
|
||||
loading.value = false
|
||||
pageParams.value.page++
|
||||
|
||||
// 模拟数据加载完成
|
||||
if (pageParams.value.page > 5) {
|
||||
finished.value = true
|
||||
}
|
||||
}, 1000)
|
||||
page.value++
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 下拉刷新
|
||||
const onRefresh = () => {
|
||||
finished.value = false
|
||||
pageParams.value.page = 1
|
||||
page.value = 1
|
||||
refreshing.value = true
|
||||
|
||||
setTimeout(() => {
|
||||
logList.value = generateMockData(1, pageParams.value.limit)
|
||||
refreshing.value = false
|
||||
pageParams.value.page = 2
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// 类型切换
|
||||
const onTypeChange = (type) => {
|
||||
pageParams.value.type = type
|
||||
pageParams.value.page = 1
|
||||
finished.value = false
|
||||
logList.value = []
|
||||
onLoad()
|
||||
}
|
||||
|
||||
// 生成模拟数据
|
||||
const generateMockData = (page, limit) => {
|
||||
const types = ['chat', 'image', 'music', 'video']
|
||||
const titles = {
|
||||
chat: ['GPT-4对话', 'Claude对话', '智能助手'],
|
||||
image: ['MidJourney生成', 'Stable Diffusion', 'DALL-E创作'],
|
||||
music: ['Suno音乐创作', '音频生成'],
|
||||
video: ['视频生成', 'Luma创作'],
|
||||
}
|
||||
|
||||
const data = []
|
||||
const startIndex = (page - 1) * limit
|
||||
|
||||
for (let i = 0; i < limit; i++) {
|
||||
const id = startIndex + i + 1
|
||||
const type = types[Math.floor(Math.random() * types.length)]
|
||||
const title = titles[type][Math.floor(Math.random() * titles[type].length)]
|
||||
|
||||
// 如果有类型筛选且不匹配,跳过
|
||||
if (pageParams.value.type !== 'all' && type !== pageParams.value.type) {
|
||||
continue
|
||||
}
|
||||
|
||||
data.push({
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
cost: Math.floor(Math.random() * 50) + 1,
|
||||
remark: Math.random() > 0.5 ? '消费详情:使用高级模型进行AI创作,效果优质' : '',
|
||||
created_at: new Date(Date.now() - Math.random() * 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
return data
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 获取类型图标
|
||||
const getTypeIcon = (type) => {
|
||||
const icons = {
|
||||
chat: 'icon-chat',
|
||||
image: 'icon-mj',
|
||||
music: 'icon-music',
|
||||
video: 'icon-video',
|
||||
1: 'icon-recharge', // 充值
|
||||
2: 'icon-chat', // 消费
|
||||
3: 'icon-withdraw-log', // 退款
|
||||
4: 'icon-yaoqm', // 邀请
|
||||
5: 'icon-redeem', // 兑换
|
||||
6: 'icon-present', // 赠送
|
||||
7: 'icon-linggan', // 签到
|
||||
}
|
||||
return icons[type] || 'icon-chat'
|
||||
}
|
||||
@@ -282,17 +208,34 @@ const getTypeIcon = (type) => {
|
||||
// 获取类型颜色
|
||||
const getTypeColor = (type) => {
|
||||
const colors = {
|
||||
chat: '#1989fa',
|
||||
image: '#8B5CF6',
|
||||
music: '#ee0a24',
|
||||
video: '#07c160',
|
||||
1: '#07c160', // 充值 - 绿色
|
||||
2: '#1989fa', // 消费 - 蓝色
|
||||
3: '#ff976a', // 退款 - 橙色
|
||||
4: '#8B5CF6', // 邀请 - 紫色
|
||||
5: '#ee0a24', // 兑换 - 红色
|
||||
6: '#07c160', // 赠送 - 绿色
|
||||
7: '#1989fa', // 签到 - 蓝色
|
||||
}
|
||||
return colors[type] || '#1989fa'
|
||||
}
|
||||
|
||||
// 获取类型标题
|
||||
const getTypeTitle = (type) => {
|
||||
const titles = {
|
||||
1: '充值',
|
||||
2: '消费',
|
||||
3: '退款',
|
||||
4: '邀请奖励',
|
||||
5: '兑换',
|
||||
6: '系统赠送',
|
||||
7: '每日签到',
|
||||
}
|
||||
return titles[type] || '其他'
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timeStr) => {
|
||||
const date = new Date(timeStr)
|
||||
const formatTime = (timestamp) => {
|
||||
const date = new Date(timestamp * 1000)
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
|
||||
@@ -310,32 +253,7 @@ const formatTime = (timeStr) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 日期选择确认
|
||||
const onDateConfirm = (values) => {
|
||||
const [start, end] = values
|
||||
dateRange.value = {
|
||||
start: start.toLocaleDateString(),
|
||||
end: end.toLocaleDateString(),
|
||||
}
|
||||
showDatePicker.value = false
|
||||
}
|
||||
|
||||
// 重置筛选
|
||||
const resetFilter = () => {
|
||||
filterType.value = 'all'
|
||||
dateRange.value = { start: '', end: '' }
|
||||
}
|
||||
|
||||
// 应用筛选
|
||||
const applyFilter = () => {
|
||||
activeType.value = filterType.value
|
||||
pageParams.value.type = filterType.value
|
||||
pageParams.value.page = 1
|
||||
finished.value = false
|
||||
logList.value = []
|
||||
showFilter.value = false
|
||||
onLoad()
|
||||
}
|
||||
// 移除 showFilter, showDatePicker, query.date, onDateButtonClick, onDateConfirm, resetFilter, applyFilter 相关逻辑
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -344,8 +262,6 @@ const applyFilter = () => {
|
||||
background: var(--van-background);
|
||||
|
||||
.power-content {
|
||||
padding-top: 46px;
|
||||
|
||||
.stats-overview {
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, var(--van-primary-color), #8b5cf6);
|
||||
@@ -377,9 +293,16 @@ const applyFilter = () => {
|
||||
.filter-bar {
|
||||
background: var(--van-background);
|
||||
border-bottom: 1px solid var(--van-border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
|
||||
.filter-actions {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
:deep(.van-tabs__nav) {
|
||||
padding: 0 16px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.van-tab) {
|
||||
@@ -439,6 +362,10 @@ const applyFilter = () => {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #ee0a24;
|
||||
|
||||
&.income {
|
||||
color: #07c160;
|
||||
}
|
||||
}
|
||||
|
||||
.cost-unit {
|
||||
@@ -460,6 +387,21 @@ const applyFilter = () => {
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.log-balance {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--van-gray-6);
|
||||
|
||||
.balance-label {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.balance-value {
|
||||
font-weight: 500;
|
||||
color: var(--van-text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
<span class="setting-value">v{{ appVersion }}</span>
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-cell title="帮助中心" is-link @click="router.push('/mobile/help')">
|
||||
<van-cell title="帮助中心" is-link @click="showAbout = true">
|
||||
<template #icon>
|
||||
<i class="iconfont icon-help setting-icon"></i>
|
||||
</template>
|
||||
|
||||
@@ -1,756 +0,0 @@
|
||||
<template>
|
||||
<div class="tools-page">
|
||||
<div class="tools-content">
|
||||
<!-- 工具分类 -->
|
||||
<CustomTabs
|
||||
:model-value="activeCategory"
|
||||
@update:model-value="activeCategory = $event"
|
||||
@tab-click="onCategoryChange"
|
||||
>
|
||||
<CustomTabPane name="all" label="全部" />
|
||||
<CustomTabPane name="office" label="办公工具" />
|
||||
<CustomTabPane name="creative" label="创意工具" />
|
||||
<CustomTabPane name="study" label="学习工具" />
|
||||
<CustomTabPane name="life" label="生活工具" />
|
||||
</CustomTabs>
|
||||
|
||||
<!-- 工具列表 -->
|
||||
<div class="tools-list">
|
||||
<div
|
||||
v-for="tool in filteredTools"
|
||||
:key="tool.key"
|
||||
class="tool-item"
|
||||
@click="openTool(tool)"
|
||||
>
|
||||
<div class="tool-header">
|
||||
<div class="tool-icon" :style="{ backgroundColor: tool.color }">
|
||||
<i class="iconfont" :class="tool.icon"></i>
|
||||
</div>
|
||||
<div class="tool-info">
|
||||
<div class="tool-name">{{ tool.name }}</div>
|
||||
<div class="tool-desc">{{ tool.desc }}</div>
|
||||
</div>
|
||||
<div class="tool-status">
|
||||
<van-tag :type="tool.status === 'available' ? 'success' : 'warning'" size="medium">
|
||||
{{ tool.status === 'available' ? '可用' : '开发中' }}
|
||||
</van-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-features" v-if="tool.features">
|
||||
<van-tag
|
||||
v-for="feature in tool.features"
|
||||
:key="feature"
|
||||
size="small"
|
||||
plain
|
||||
class="feature-tag"
|
||||
>
|
||||
{{ feature }}
|
||||
</van-tag>
|
||||
</div>
|
||||
|
||||
<div class="tool-stats" v-if="tool.stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">使用次数:</span>
|
||||
<span class="stat-value">{{ tool.stats.usageCount }}次</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">好评率:</span>
|
||||
<span class="stat-value">{{ tool.stats.rating }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<van-empty v-if="filteredTools.length === 0" description="该分类暂无工具" />
|
||||
</div>
|
||||
|
||||
<!-- 推荐工具 -->
|
||||
<div class="recommend-section" v-if="activeCategory === 'all'">
|
||||
<h3 class="section-title">推荐工具</h3>
|
||||
<van-swipe :autoplay="3000" class="recommend-swipe">
|
||||
<van-swipe-item v-for="tool in recommendTools" :key="tool.key">
|
||||
<div class="recommend-card" @click="openTool(tool)">
|
||||
<div class="recommend-bg" :style="{ backgroundColor: tool.color }">
|
||||
<i class="iconfont" :class="tool.icon"></i>
|
||||
</div>
|
||||
<div class="recommend-content">
|
||||
<h4 class="recommend-title">{{ tool.name }}</h4>
|
||||
<p class="recommend-desc">{{ tool.desc }}</p>
|
||||
<van-button size="small" type="primary" plain round> 立即使用 </van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-swipe-item>
|
||||
</van-swipe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 工具详情弹窗 -->
|
||||
<van-action-sheet
|
||||
:model-value="showToolDetail"
|
||||
@update:model-value="showToolDetail = $event"
|
||||
:title="selectedTool && selectedTool.name"
|
||||
>
|
||||
<div class="tool-detail" v-if="selectedTool">
|
||||
<div class="detail-header">
|
||||
<div class="detail-icon" :style="{ backgroundColor: selectedTool.color }">
|
||||
<i class="iconfont" :class="selectedTool.icon"></i>
|
||||
</div>
|
||||
<div class="detail-info">
|
||||
<h3 class="detail-name">{{ selectedTool.name }}</h3>
|
||||
<p class="detail-desc">{{ selectedTool.fullDesc || selectedTool.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-features" v-if="selectedTool.detailFeatures">
|
||||
<h4 class="features-title">功能特点</h4>
|
||||
<ul class="features-list">
|
||||
<li v-for="feature in selectedTool.detailFeatures" :key="feature">
|
||||
<van-icon name="checked" color="#07c160" />
|
||||
{{ feature }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="detail-usage" v-if="selectedTool.usage">
|
||||
<h4 class="usage-title">使用说明</h4>
|
||||
<p class="usage-text">{{ selectedTool.usage }}</p>
|
||||
</div>
|
||||
|
||||
<div class="detail-actions">
|
||||
<van-button
|
||||
type="primary"
|
||||
size="large"
|
||||
round
|
||||
block
|
||||
:disabled="selectedTool.status !== 'available'"
|
||||
@click="useTool(selectedTool)"
|
||||
>
|
||||
{{ selectedTool.status === 'available' ? '开始使用' : '开发中' }}
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-action-sheet>
|
||||
|
||||
<!-- 思维导图工具 -->
|
||||
<van-action-sheet
|
||||
:model-value="showMindMap"
|
||||
@update:model-value="showMindMap = $event"
|
||||
title="思维导图"
|
||||
:close-on-click-overlay="false"
|
||||
>
|
||||
<div class="mindmap-container">
|
||||
<div class="mindmap-toolbar">
|
||||
<van-button size="small" @click="createNewMap">新建</van-button>
|
||||
<van-button size="small" @click="saveMap">保存</van-button>
|
||||
<van-button size="small" @click="exportMap">导出</van-button>
|
||||
<van-button size="small" @click="closeMindMap">关闭</van-button>
|
||||
</div>
|
||||
<div class="mindmap-canvas" ref="mindmapCanvas">
|
||||
<!-- 这里会渲染思维导图 -->
|
||||
<div class="canvas-placeholder">
|
||||
<i class="iconfont icon-mind"></i>
|
||||
<p>思维导图工具</p>
|
||||
<p class="placeholder-desc">功能开发中,敬请期待</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</van-action-sheet>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import CustomTabPane from '@/components/ui/CustomTabPane.vue'
|
||||
import CustomTabs from '@/components/ui/CustomTabs.vue'
|
||||
import { showNotify } from 'vant'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const activeCategory = ref('all')
|
||||
const selectedTool = ref(null)
|
||||
const showToolDetail = ref(false)
|
||||
const showMindMap = ref(false)
|
||||
const mindmapCanvas = ref()
|
||||
|
||||
// 工具列表配置
|
||||
const tools = ref([
|
||||
{
|
||||
key: 'mindmap',
|
||||
name: '思维导图',
|
||||
desc: '智能生成思维导图,整理思路更清晰',
|
||||
fullDesc:
|
||||
'基于AI技术的智能思维导图生成工具,可以根据文本内容自动生成结构化的思维导图,支持多种导出格式。',
|
||||
icon: 'icon-mind',
|
||||
color: '#3B82F6',
|
||||
category: 'office',
|
||||
status: 'available',
|
||||
features: ['自动生成', '多种模板', '智能布局'],
|
||||
detailFeatures: [
|
||||
'支持文本自动转思维导图',
|
||||
'提供多种精美模板',
|
||||
'智能节点布局算法',
|
||||
'支持导出多种格式',
|
||||
'支持在线协作编辑',
|
||||
],
|
||||
usage:
|
||||
'输入您的文本内容,AI会自动分析并生成对应的思维导图结构。您可以对生成的导图进行编辑、美化和导出。',
|
||||
stats: {
|
||||
usageCount: 1256,
|
||||
rating: 96,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'summary',
|
||||
name: '文档总结',
|
||||
desc: '快速提取文档要点,生成精准摘要',
|
||||
fullDesc: '智能文档总结工具,能够快速分析长文档并提取关键信息,生成简洁明了的摘要。',
|
||||
icon: 'icon-doc',
|
||||
color: '#10B981',
|
||||
category: 'office',
|
||||
status: 'available',
|
||||
features: ['关键词提取', '智能摘要', '多语言支持'],
|
||||
detailFeatures: [
|
||||
'支持多种文档格式',
|
||||
'智能关键词提取',
|
||||
'可控制摘要长度',
|
||||
'支持批量处理',
|
||||
'多语言文档支持',
|
||||
],
|
||||
usage: '上传或粘贴文档内容,选择摘要长度和类型,AI会自动生成文档摘要。',
|
||||
stats: {
|
||||
usageCount: 2341,
|
||||
rating: 94,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'translation',
|
||||
name: '智能翻译',
|
||||
desc: '高质量多语言翻译,支持专业术语',
|
||||
fullDesc: '基于先进AI模型的多语言翻译工具,支持100+语言互译,特别适合专业文档翻译。',
|
||||
icon: 'icon-translate',
|
||||
color: '#8B5CF6',
|
||||
category: 'office',
|
||||
status: 'available',
|
||||
features: ['100+语言', '专业术语', '上下文理解'],
|
||||
detailFeatures: [
|
||||
'支持100多种语言互译',
|
||||
'专业术语库支持',
|
||||
'上下文语境理解',
|
||||
'批量文档翻译',
|
||||
'翻译质量评估',
|
||||
],
|
||||
usage: '选择源语言和目标语言,输入需要翻译的内容,AI会提供高质量的翻译结果。',
|
||||
stats: {
|
||||
usageCount: 5678,
|
||||
rating: 98,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'poster',
|
||||
name: '海报设计',
|
||||
desc: '一键生成专业海报,多种风格可选',
|
||||
fullDesc: 'AI驱动的海报设计工具,提供丰富的模板和素材,轻松制作专业级海报。',
|
||||
icon: 'icon-design',
|
||||
color: '#F59E0B',
|
||||
category: 'creative',
|
||||
status: 'available',
|
||||
features: ['模板丰富', '一键生成', '高清输出'],
|
||||
detailFeatures: [
|
||||
'500+精美模板',
|
||||
'智能配色方案',
|
||||
'自动排版布局',
|
||||
'高清无水印导出',
|
||||
'支持自定义尺寸',
|
||||
],
|
||||
usage: '选择海报类型和风格,输入文案内容,AI会自动生成专业海报设计。',
|
||||
stats: {
|
||||
usageCount: 3456,
|
||||
rating: 95,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'logo',
|
||||
name: 'Logo 设计',
|
||||
desc: 'AI 生成独特Logo,商用级品质',
|
||||
fullDesc: '专业的AI Logo设计工具,根据您的品牌理念生成独特的Logo设计方案。',
|
||||
icon: 'icon-logo',
|
||||
color: '#EF4444',
|
||||
category: 'creative',
|
||||
status: 'available',
|
||||
features: ['品牌风格', '矢量格式', '商用授权'],
|
||||
detailFeatures: [
|
||||
'多种设计风格选择',
|
||||
'矢量格式输出',
|
||||
'商用版权授权',
|
||||
'配色方案推荐',
|
||||
'标准化尺寸规范',
|
||||
],
|
||||
usage: '描述您的品牌特点和期望风格,AI会生成多个Logo设计方案供您选择。',
|
||||
stats: {
|
||||
usageCount: 2234,
|
||||
rating: 93,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'study-plan',
|
||||
name: '学习计划',
|
||||
desc: '个性化学习路径规划,提升学习效率',
|
||||
fullDesc: '基于AI的个性化学习计划制定工具,根据您的学习目标和时间安排制定最优学习路径。',
|
||||
icon: 'icon-study',
|
||||
color: '#06B6D4',
|
||||
category: 'study',
|
||||
status: 'available',
|
||||
features: ['个性化', '进度跟踪', '智能调整'],
|
||||
detailFeatures: [
|
||||
'个性化学习路径',
|
||||
'学习进度跟踪',
|
||||
'智能计划调整',
|
||||
'学习效果评估',
|
||||
'多领域知识覆盖',
|
||||
],
|
||||
usage: '输入您的学习目标、可用时间和当前水平,AI会为您制定详细的学习计划。',
|
||||
stats: {
|
||||
usageCount: 1890,
|
||||
rating: 97,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'recipe',
|
||||
name: '智能食谱',
|
||||
desc: '根据食材推荐美食,营养搭配建议',
|
||||
fullDesc: '智能食谱推荐系统,根据现有食材推荐美食制作方法,提供营养搭配建议。',
|
||||
icon: 'icon-food',
|
||||
color: '#F97316',
|
||||
category: 'life',
|
||||
status: 'development',
|
||||
features: ['食材识别', '营养分析', '制作指导'],
|
||||
detailFeatures: [
|
||||
'食材智能识别',
|
||||
'营养成分分析',
|
||||
'详细制作步骤',
|
||||
'口味偏好适配',
|
||||
'热量控制建议',
|
||||
],
|
||||
usage: '拍照或输入现有食材,AI会推荐适合的菜谱并提供详细制作指导。',
|
||||
stats: {
|
||||
usageCount: 567,
|
||||
rating: 89,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'workout',
|
||||
name: '运动计划',
|
||||
desc: '定制化健身方案,科学训练指导',
|
||||
fullDesc: '个性化运动健身计划制定工具,根据身体状况和目标制定科学的训练方案。',
|
||||
icon: 'icon-sport',
|
||||
color: '#EC4899',
|
||||
category: 'life',
|
||||
status: 'development',
|
||||
features: ['个性定制', '科学指导', '进度跟踪'],
|
||||
detailFeatures: [
|
||||
'个性化训练计划',
|
||||
'科学运动指导',
|
||||
'训练进度跟踪',
|
||||
'饮食建议搭配',
|
||||
'健康数据分析',
|
||||
],
|
||||
usage: '输入您的身体状况、运动目标和时间安排,AI会制定适合的运动计划。',
|
||||
stats: {
|
||||
usageCount: 234,
|
||||
rating: 91,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// 推荐工具(取前3个可用的)
|
||||
const recommendTools = computed(() => {
|
||||
return tools.value.filter((tool) => tool.status === 'available').slice(0, 3)
|
||||
})
|
||||
|
||||
// 根据分类筛选工具
|
||||
const filteredTools = computed(() => {
|
||||
if (activeCategory.value === 'all') {
|
||||
return tools.value
|
||||
}
|
||||
return tools.value.filter((tool) => tool.category === activeCategory.value)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// 检查URL参数,如果有指定工具则直接打开
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const toolKey = urlParams.get('tool')
|
||||
if (toolKey) {
|
||||
const tool = tools.value.find((t) => t.key === toolKey)
|
||||
if (tool) {
|
||||
openTool(tool)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 分类切换
|
||||
const onCategoryChange = (category) => {
|
||||
// 可以在这里添加数据加载逻辑
|
||||
}
|
||||
|
||||
// 打开工具
|
||||
const openTool = (tool) => {
|
||||
selectedTool.value = tool
|
||||
|
||||
// 特殊工具直接打开对应界面
|
||||
if (tool.key === 'mindmap') {
|
||||
showMindMap.value = true
|
||||
} else {
|
||||
showToolDetail.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 使用工具
|
||||
const useTool = (tool) => {
|
||||
showToolDetail.value = false
|
||||
|
||||
if (tool.status !== 'available') {
|
||||
showNotify({ type: 'warning', message: '该工具还在开发中,敬请期待' })
|
||||
return
|
||||
}
|
||||
|
||||
// 根据工具类型跳转到对应页面或打开功能界面
|
||||
switch (tool.key) {
|
||||
case 'mindmap':
|
||||
showMindMap.value = true
|
||||
break
|
||||
case 'summary':
|
||||
case 'translation':
|
||||
case 'poster':
|
||||
case 'logo':
|
||||
case 'study-plan':
|
||||
showNotify({ type: 'primary', message: `正在启动${tool.name}工具...` })
|
||||
// 这里可以跳转到具体的工具页面
|
||||
break
|
||||
default:
|
||||
showNotify({ type: 'warning', message: '功能开发中' })
|
||||
}
|
||||
}
|
||||
|
||||
// 思维导图相关方法
|
||||
const createNewMap = () => {
|
||||
showNotify({ type: 'primary', message: '创建新的思维导图' })
|
||||
}
|
||||
|
||||
const saveMap = () => {
|
||||
showNotify({ type: 'success', message: '思维导图已保存' })
|
||||
}
|
||||
|
||||
const exportMap = () => {
|
||||
showNotify({ type: 'primary', message: '导出思维导图' })
|
||||
}
|
||||
|
||||
const closeMindMap = () => {
|
||||
showMindMap.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tools-page {
|
||||
min-height: 100vh;
|
||||
background: var(--van-background);
|
||||
|
||||
.tools-content {
|
||||
padding-top: 46px;
|
||||
|
||||
:deep(.van-tabs__nav) {
|
||||
background: var(--van-background);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.tools-list {
|
||||
padding: 16px;
|
||||
|
||||
.tool-item {
|
||||
background: var(--van-cell-background);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.tool-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.tool-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
|
||||
.iconfont {
|
||||
font-size: 22px;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-info {
|
||||
flex: 1;
|
||||
|
||||
.tool-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--van-text-color);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tool-desc {
|
||||
font-size: 13px;
|
||||
color: var(--van-gray-6);
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-status {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-features {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.feature-tag {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--van-gray-6);
|
||||
|
||||
.stat-label {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: var(--van-text-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recommend-section {
|
||||
padding: 0 16px 16px;
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--van-text-color);
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.recommend-swipe {
|
||||
height: 160px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
|
||||
.recommend-card {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, var(--van-primary-color), #8b5cf6);
|
||||
cursor: pointer;
|
||||
|
||||
.recommend-bg {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
|
||||
.iconfont {
|
||||
font-size: 40px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
.recommend-content {
|
||||
flex: 1;
|
||||
color: white;
|
||||
|
||||
.recommend-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.recommend-desc {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
margin: 0 0 16px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tool-detail {
|
||||
padding: 20px;
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.detail-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 16px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.iconfont {
|
||||
font-size: 28px;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-info {
|
||||
flex: 1;
|
||||
|
||||
.detail-name {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--van-text-color);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.detail-desc {
|
||||
font-size: 14px;
|
||||
color: var(--van-gray-6);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detail-features,
|
||||
.detail-usage {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.features-title,
|
||||
.usage-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--van-text-color);
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.features-list {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: var(--van-text-color);
|
||||
margin-bottom: 8px;
|
||||
|
||||
.van-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.usage-text {
|
||||
font-size: 14px;
|
||||
color: var(--van-gray-6);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.mindmap-container {
|
||||
height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.mindmap-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: var(--van-background-2);
|
||||
border-bottom: 1px solid var(--van-border-color);
|
||||
}
|
||||
|
||||
.mindmap-canvas {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
background: var(--van-background);
|
||||
|
||||
.canvas-placeholder {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
color: var(--van-gray-6);
|
||||
|
||||
.iconfont {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--van-gray-5);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
|
||||
&.placeholder-desc {
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 深色主题优化
|
||||
:deep(.van-theme-dark) {
|
||||
.tools-page {
|
||||
.tool-item {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
110
web/src/views/mobile/components/AppCard.vue
Normal file
110
web/src/views/mobile/components/AppCard.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<van-cell class="app-cell">
|
||||
<div class="app-card">
|
||||
<div class="app-info">
|
||||
<div class="app-image">
|
||||
<van-image :src="app.icon" round />
|
||||
</div>
|
||||
<div class="app-detail">
|
||||
<div class="app-title">{{ app.name }}</div>
|
||||
<div class="app-desc">{{ app.hello_msg }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-actions">
|
||||
<van-button
|
||||
size="small"
|
||||
type="primary"
|
||||
class="action-btn"
|
||||
@click="$emit('use-role', app.id)"
|
||||
>开始对话</van-button
|
||||
>
|
||||
<van-button
|
||||
size="small"
|
||||
:type="hasRole ? 'danger' : 'success'"
|
||||
class="action-btn"
|
||||
@click="$emit('update-role', app, hasRole ? 'remove' : 'add')"
|
||||
>
|
||||
{{ hasRole ? '移出工作台' : '添加到工作台' }}
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-cell>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
app: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
hasRole: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
defineEmits(['use-role', 'update-role'])
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-cell {
|
||||
padding: 0;
|
||||
margin-bottom: 15px;
|
||||
|
||||
.app-card {
|
||||
background: var(--van-cell-background);
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
|
||||
|
||||
.app-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
|
||||
.app-image {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
margin-right: 15px;
|
||||
|
||||
:deep(.van-image) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.app-detail {
|
||||
flex: 1;
|
||||
|
||||
.app-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 5px;
|
||||
color: var(--van-text-color);
|
||||
}
|
||||
|
||||
.app-desc {
|
||||
font-size: 13px;
|
||||
color: var(--van-gray-6);
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.app-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
border-radius: 20px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
74
web/src/views/mobile/components/EmptyState.vue
Normal file
74
web/src/views/mobile/components/EmptyState.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="empty-state">
|
||||
<van-empty :image="getImage()" :description="description" :image-size="imageSize">
|
||||
<template #bottom>
|
||||
<slot name="action">
|
||||
<van-button
|
||||
v-if="showAction"
|
||||
round
|
||||
type="primary"
|
||||
class="action-btn"
|
||||
@click="$emit('action')"
|
||||
>
|
||||
{{ actionText }}
|
||||
</van-button>
|
||||
</slot>
|
||||
</template>
|
||||
</van-empty>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'search', // search, error, network, default
|
||||
validator: (value) => ['search', 'error', 'network', 'default'].includes(value),
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '暂无数据',
|
||||
},
|
||||
imageSize: {
|
||||
type: [String, Number],
|
||||
default: 120,
|
||||
},
|
||||
showAction: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
actionText: {
|
||||
type: String,
|
||||
default: '刷新',
|
||||
},
|
||||
})
|
||||
|
||||
defineEmits(['action'])
|
||||
|
||||
// 根据类型获取对应的图标
|
||||
const getImage = () => {
|
||||
const imageMap = {
|
||||
search: 'search',
|
||||
error: 'error',
|
||||
network: 'network',
|
||||
default: 'default',
|
||||
}
|
||||
return imageMap[props.type] || 'search'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.empty-state {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
|
||||
.action-btn {
|
||||
margin-top: 16px;
|
||||
min-width: 120px;
|
||||
height: 36px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="mobile-sd">
|
||||
<van-form @submit="generate">
|
||||
<van-cell-group inset>
|
||||
<van-cell-group class="px-3 pt-3 pb-4">
|
||||
<div>
|
||||
<van-field
|
||||
v-model="selectedModel"
|
||||
|
||||
@@ -376,10 +376,10 @@ const params = ref({
|
||||
model: models[0].value,
|
||||
chaos: 0,
|
||||
stylize: 0,
|
||||
seed: 0,
|
||||
seed: -1,
|
||||
img_arr: [],
|
||||
raw: false,
|
||||
iw: 0,
|
||||
iw: 0.7,
|
||||
prompt: '',
|
||||
neg_prompt: '',
|
||||
tile: false,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="mobile-sd">
|
||||
<van-form @submit="generate">
|
||||
<van-cell-group inset>
|
||||
<van-cell-group class="px-3 pt-3 pb-4">
|
||||
<div>
|
||||
<van-field
|
||||
v-model="params.sampler"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="img-wall container">
|
||||
<div class="img-wall p-3">
|
||||
<div class="content">
|
||||
<van-tabs v-model:active="activeName" animated sticky>
|
||||
<van-tab title="MJ" name="mj">
|
||||
<CustomTabs v-model="activeName" @tab-click="handleTabClick">
|
||||
<TabPane name="mj" label="Midjourney">
|
||||
<van-list
|
||||
v-model:error="data['mj'].error"
|
||||
v-model:loading="data['mj'].loading"
|
||||
@@ -22,8 +22,8 @@
|
||||
</div>
|
||||
</van-cell>
|
||||
</van-list>
|
||||
</van-tab>
|
||||
<van-tab title="SD" name="sd">
|
||||
</TabPane>
|
||||
<TabPane name="sd" label="Stable Diffusion">
|
||||
<van-list
|
||||
v-model:error="data['sd'].error"
|
||||
v-model:loading="data['sd'].loading"
|
||||
@@ -42,8 +42,8 @@
|
||||
</div>
|
||||
</van-cell>
|
||||
</van-list>
|
||||
</van-tab>
|
||||
<van-tab title="DALL" name="dall">
|
||||
</TabPane>
|
||||
<TabPane name="dall" label="DALL">
|
||||
<van-list
|
||||
v-model:error="data['dall'].error"
|
||||
v-model:loading="data['dall'].loading"
|
||||
@@ -62,8 +62,8 @@
|
||||
</div>
|
||||
</van-cell>
|
||||
</van-list>
|
||||
</van-tab>
|
||||
</van-tabs>
|
||||
</TabPane>
|
||||
</CustomTabs>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -83,6 +83,8 @@ import Clipboard from 'clipboard'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { showConfirmDialog, showFailToast, showImagePreview, showNotify } from 'vant'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import CustomTabs from '@/components/ui/CustomTabs.vue'
|
||||
import TabPane from '@/components/ui/CustomTabPane.vue'
|
||||
|
||||
const activeName = ref('mj')
|
||||
const data = ref({
|
||||
@@ -117,6 +119,13 @@ const data = ref({
|
||||
|
||||
const prompt = ref('')
|
||||
const clipboard = ref(null)
|
||||
|
||||
// 处理 tab 点击事件
|
||||
const handleTabClick = (tabName, index) => {
|
||||
// 可以在这里添加额外的 tab 切换逻辑
|
||||
console.log('Tab clicked:', tabName, index)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
clipboard.value = new Clipboard('.copy-prompt-wall')
|
||||
clipboard.value.on('success', () => {
|
||||
|
||||
693
web/src/views/mobile/pages/JimengCreate.vue
Normal file
693
web/src/views/mobile/pages/JimengCreate.vue
Normal file
@@ -0,0 +1,693 @@
|
||||
<template>
|
||||
<div class="mobile-jimeng-create">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<van-nav-bar title="即梦AI" left-arrow @click-left="goBack" fixed placeholder />
|
||||
</div>
|
||||
|
||||
<!-- 功能分类选择 -->
|
||||
<div class="category-section">
|
||||
<van-tabs v-model="activeCategory" @change="onCategoryChange">
|
||||
<van-tab title="图像生成" name="image_generation">
|
||||
<div class="tab-content">
|
||||
<!-- 生成模式切换 -->
|
||||
<van-cell title="生成模式">
|
||||
<template #value>
|
||||
<van-switch v-model="useImageInput" size="24" @change="onInputModeChange" />
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-cell title="图生图人像写真" :value="useImageInput ? '开启' : '关闭'" />
|
||||
|
||||
<!-- 文生图 -->
|
||||
<div v-if="activeFunction === 'text_to_image'" class="function-panel">
|
||||
<van-field
|
||||
v-model="currentPrompt"
|
||||
label="提示词"
|
||||
type="textarea"
|
||||
placeholder="请输入图片描述,越详细越好"
|
||||
rows="4"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
/>
|
||||
|
||||
<van-field
|
||||
v-model="textToImageParams.size"
|
||||
label="图片尺寸"
|
||||
readonly
|
||||
is-link
|
||||
@click="showSizePicker = true"
|
||||
/>
|
||||
|
||||
<van-cell title="创意度">
|
||||
<template #value>
|
||||
<van-slider
|
||||
v-model="textToImageParams.scale"
|
||||
:min="1"
|
||||
:max="10"
|
||||
:step="0.5"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</template>
|
||||
</van-cell>
|
||||
|
||||
<van-cell title="智能优化提示词">
|
||||
<template #right-icon>
|
||||
<van-switch v-model="textToImageParams.use_pre_llm" size="24" />
|
||||
</template>
|
||||
</van-cell>
|
||||
</div>
|
||||
|
||||
<!-- 图生图 -->
|
||||
<div v-if="activeFunction === 'image_to_image'" class="function-panel">
|
||||
<van-uploader
|
||||
v-model="imageToImageParams.image_input"
|
||||
:max-count="1"
|
||||
:after-read="onImageUpload"
|
||||
accept=".jpg,.png,.jpeg"
|
||||
>
|
||||
<van-button icon="plus" type="primary" block> 上传图片 </van-button>
|
||||
</van-uploader>
|
||||
|
||||
<van-field
|
||||
v-model="currentPrompt"
|
||||
label="提示词"
|
||||
type="textarea"
|
||||
placeholder="描述你想要的图片效果"
|
||||
rows="4"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
/>
|
||||
|
||||
<van-field
|
||||
v-model="imageToImageParams.size"
|
||||
label="图片尺寸"
|
||||
readonly
|
||||
is-link
|
||||
@click="showSizePicker = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</van-tab>
|
||||
|
||||
<van-tab title="图像编辑" name="image_editing">
|
||||
<div class="tab-content">
|
||||
<!-- 图像编辑 -->
|
||||
<div v-if="activeFunction === 'image_edit'" class="function-panel">
|
||||
<van-uploader
|
||||
v-model="imageEditParams.image_urls"
|
||||
:max-count="1"
|
||||
:after-read="onImageUpload"
|
||||
accept=".jpg,.png,.jpeg"
|
||||
>
|
||||
<van-button icon="plus" type="primary" block> 上传图片 </van-button>
|
||||
</van-uploader>
|
||||
|
||||
<van-field
|
||||
v-model="currentPrompt"
|
||||
label="编辑提示词"
|
||||
type="textarea"
|
||||
placeholder="描述你想要的编辑效果"
|
||||
rows="4"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
/>
|
||||
|
||||
<van-cell title="编辑强度">
|
||||
<template #value>
|
||||
<van-slider
|
||||
v-model="imageEditParams.scale"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.1"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</template>
|
||||
</van-cell>
|
||||
</div>
|
||||
|
||||
<!-- 图像特效 -->
|
||||
<div v-if="activeFunction === 'image_effects'" class="function-panel">
|
||||
<van-uploader
|
||||
v-model="imageEffectsParams.image_input1"
|
||||
:max-count="1"
|
||||
:after-read="onImageUpload"
|
||||
accept=".jpg,.png,.jpeg"
|
||||
>
|
||||
<van-button icon="plus" type="primary" block> 上传图片 </van-button>
|
||||
</van-uploader>
|
||||
|
||||
<van-field
|
||||
v-model="imageEffectsParams.template_id"
|
||||
label="特效模板"
|
||||
readonly
|
||||
is-link
|
||||
@click="showTemplatePicker = true"
|
||||
/>
|
||||
|
||||
<van-field
|
||||
v-model="imageEffectsParams.size"
|
||||
label="输出尺寸"
|
||||
readonly
|
||||
is-link
|
||||
@click="showSizePicker = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</van-tab>
|
||||
|
||||
<van-tab title="视频生成" name="video_generation">
|
||||
<div class="tab-content">
|
||||
<!-- 生成模式切换 -->
|
||||
<van-cell title="生成模式">
|
||||
<template #value>
|
||||
<van-switch v-model="useImageInput" size="24" @change="onInputModeChange" />
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-cell title="图生视频" :value="useImageInput ? '开启' : '关闭'" />
|
||||
|
||||
<!-- 文生视频 -->
|
||||
<div v-if="activeFunction === 'text_to_video'" class="function-panel">
|
||||
<van-field
|
||||
v-model="currentPrompt"
|
||||
label="提示词"
|
||||
type="textarea"
|
||||
placeholder="描述你想要的视频内容"
|
||||
rows="4"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
/>
|
||||
|
||||
<van-field
|
||||
v-model="textToVideoParams.aspect_ratio"
|
||||
label="视频比例"
|
||||
readonly
|
||||
is-link
|
||||
@click="showAspectRatioPicker = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 图生视频 -->
|
||||
<div v-if="activeFunction === 'image_to_video'" class="function-panel">
|
||||
<van-uploader
|
||||
v-model="imageToVideoParams.image_urls"
|
||||
:max-count="2"
|
||||
:after-read="onImageUpload"
|
||||
accept=".jpg,.png,.jpeg"
|
||||
multiple
|
||||
>
|
||||
<van-button icon="plus" type="primary" block> 上传图片 </van-button>
|
||||
</van-uploader>
|
||||
|
||||
<van-field
|
||||
v-model="currentPrompt"
|
||||
label="提示词"
|
||||
type="textarea"
|
||||
placeholder="描述你想要的视频效果"
|
||||
rows="4"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
/>
|
||||
|
||||
<van-field
|
||||
v-model="imageToVideoParams.aspect_ratio"
|
||||
label="视频比例"
|
||||
readonly
|
||||
is-link
|
||||
@click="showAspectRatioPicker = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</van-tab>
|
||||
</van-tabs>
|
||||
</div>
|
||||
|
||||
<!-- 生成按钮 -->
|
||||
<div class="submit-section">
|
||||
<van-button type="primary" size="large" @click="submitTask" :loading="submitting" block>
|
||||
立即生成 ({{ currentPowerCost }}算力)
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<!-- 作品列表 -->
|
||||
<div class="works-list">
|
||||
<van-list
|
||||
v-model:loading="listLoading"
|
||||
:finished="listFinished"
|
||||
finished-text="没有更多了"
|
||||
@load="loadMore"
|
||||
>
|
||||
<div v-for="item in currentList" :key="item.id" class="work-item">
|
||||
<van-card
|
||||
:title="getFunctionName(item.type)"
|
||||
:desc="item.prompt"
|
||||
:thumb="item.img_url || item.video_url"
|
||||
>
|
||||
<template #tags>
|
||||
<van-tag :type="getTaskType(item.type)" size="small">
|
||||
{{ getFunctionName(item.type) }}
|
||||
</van-tag>
|
||||
<van-tag v-if="item.power" type="warning" size="small">
|
||||
{{ item.power }}算力
|
||||
</van-tag>
|
||||
</template>
|
||||
<template #footer>
|
||||
<van-button v-if="item.status === 'completed'" size="small" @click="playMedia(item)">
|
||||
{{ item.type.includes('video') ? '播放' : '查看' }}
|
||||
</van-button>
|
||||
<van-button
|
||||
v-if="item.status === 'completed'"
|
||||
size="small"
|
||||
@click="downloadFile(item)"
|
||||
:loading="item.downloading"
|
||||
>
|
||||
下载
|
||||
</van-button>
|
||||
<van-button v-if="item.status === 'failed'" size="small" @click="retryTask(item.id)">
|
||||
重试
|
||||
</van-button>
|
||||
<van-button size="small" type="danger" @click="removeJob(item)"> 删除 </van-button>
|
||||
</template>
|
||||
</van-card>
|
||||
</div>
|
||||
</van-list>
|
||||
</div>
|
||||
|
||||
<!-- 各种选择器弹窗 -->
|
||||
<van-popup v-model:show="showSizePicker" position="bottom">
|
||||
<van-picker
|
||||
:columns="imageSizeOptions"
|
||||
@confirm="onSizeConfirm"
|
||||
@cancel="showSizePicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<van-popup v-model:show="showAspectRatioPicker" position="bottom">
|
||||
<van-picker
|
||||
:columns="videoAspectRatioOptions"
|
||||
@confirm="onAspectRatioConfirm"
|
||||
@cancel="showAspectRatioPicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<van-popup v-model:show="showTemplatePicker" position="bottom">
|
||||
<van-picker
|
||||
:columns="imageEffectsTemplateOptions"
|
||||
@confirm="onTemplateConfirm"
|
||||
@cancel="showTemplatePicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<!-- 媒体预览弹窗 -->
|
||||
<van-popup
|
||||
v-model:show="showMediaDialog"
|
||||
position="center"
|
||||
:style="{ width: '90%', height: '60%' }"
|
||||
>
|
||||
<div class="media-preview">
|
||||
<img
|
||||
v-if="currentMediaUrl && !currentMediaUrl.includes('video')"
|
||||
:src="currentMediaUrl"
|
||||
style="width: 100%; height: 100%; object-fit: contain"
|
||||
/>
|
||||
<video
|
||||
v-else-if="currentMediaUrl"
|
||||
:src="currentMediaUrl"
|
||||
controls
|
||||
autoplay
|
||||
style="width: 100%; height: 100%"
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</div>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast, showDialog } from 'vant'
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import { checkSession } from '@/store/cache'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 响应式数据
|
||||
const activeCategory = ref('image_generation')
|
||||
const useImageInput = ref(false)
|
||||
const submitting = ref(false)
|
||||
const listLoading = ref(false)
|
||||
const listFinished = ref(false)
|
||||
const currentList = ref([])
|
||||
const showMediaDialog = ref(false)
|
||||
const currentMediaUrl = ref('')
|
||||
|
||||
// 选择器相关
|
||||
const showSizePicker = ref(false)
|
||||
const showAspectRatioPicker = ref(false)
|
||||
const showTemplatePicker = ref(false)
|
||||
|
||||
// 当前提示词
|
||||
const currentPrompt = ref('')
|
||||
|
||||
// 功能参数
|
||||
const textToImageParams = ref({
|
||||
size: '1024x1024',
|
||||
scale: 7.5,
|
||||
use_pre_llm: false,
|
||||
})
|
||||
|
||||
const imageToImageParams = ref({
|
||||
image_input: [],
|
||||
size: '1024x1024',
|
||||
})
|
||||
|
||||
const imageEditParams = ref({
|
||||
image_urls: [],
|
||||
scale: 0.5,
|
||||
})
|
||||
|
||||
const imageEffectsParams = ref({
|
||||
image_input1: [],
|
||||
template_id: '',
|
||||
size: '1024x1024',
|
||||
})
|
||||
|
||||
const textToVideoParams = ref({
|
||||
aspect_ratio: '16:9',
|
||||
})
|
||||
|
||||
const imageToVideoParams = ref({
|
||||
image_urls: [],
|
||||
aspect_ratio: '16:9',
|
||||
})
|
||||
|
||||
// 选项数据
|
||||
const imageSizeOptions = ['512x512', '768x768', '1024x1024', '1024x1536', '1536x1024']
|
||||
|
||||
const videoAspectRatioOptions = ['16:9', '9:16', '1:1', '4:3']
|
||||
|
||||
const imageEffectsTemplateOptions = [
|
||||
'acrylic_ornaments',
|
||||
'angel_figurine',
|
||||
'felt_3d_polaroid',
|
||||
'watercolor_illustration',
|
||||
]
|
||||
|
||||
// 页面数据
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const currentPowerCost = ref(0)
|
||||
const taskPulling = ref(true)
|
||||
const tastPullHandler = ref(null)
|
||||
|
||||
// 计算属性
|
||||
const activeFunction = computed(() => {
|
||||
if (activeCategory.value === 'image_generation') {
|
||||
return useImageInput.value ? 'image_to_image' : 'text_to_image'
|
||||
} else if (activeCategory.value === 'image_editing') {
|
||||
return 'image_edit' // 可以根据需要添加更多编辑功能
|
||||
} else if (activeCategory.value === 'video_generation') {
|
||||
return useImageInput.value ? 'image_to_video' : 'text_to_video'
|
||||
}
|
||||
return 'text_to_image'
|
||||
})
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
checkSession()
|
||||
.then(() => {
|
||||
fetchData(1)
|
||||
tastPullHandler.value = setInterval(() => {
|
||||
if (taskPulling.value) {
|
||||
fetchData(1)
|
||||
}
|
||||
}, 5000)
|
||||
})
|
||||
.catch(() => {})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (tastPullHandler.value) {
|
||||
clearInterval(tastPullHandler.value)
|
||||
}
|
||||
})
|
||||
|
||||
// 方法
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
const onCategoryChange = (name) => {
|
||||
activeCategory.value = name
|
||||
useImageInput.value = false
|
||||
}
|
||||
|
||||
const onInputModeChange = () => {
|
||||
// 重置相关参数
|
||||
currentPrompt.value = ''
|
||||
}
|
||||
|
||||
const onImageUpload = (file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file.file, file.name)
|
||||
showToast({ message: '正在上传图片...', duration: 0 })
|
||||
|
||||
httpPost('/api/upload', formData)
|
||||
.then((res) => {
|
||||
showToast('图片上传成功')
|
||||
return res.data.url
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast('图片上传失败:' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const submitTask = () => {
|
||||
if (!currentPrompt.value.trim()) {
|
||||
showToast('请输入提示词')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
const params = {
|
||||
type: activeFunction.value,
|
||||
prompt: currentPrompt.value,
|
||||
}
|
||||
|
||||
// 根据功能类型添加相应参数
|
||||
if (activeFunction.value === 'text_to_image') {
|
||||
Object.assign(params, textToImageParams.value)
|
||||
} else if (activeFunction.value === 'image_to_image') {
|
||||
Object.assign(params, imageToImageParams.value)
|
||||
} else if (activeFunction.value === 'image_edit') {
|
||||
Object.assign(params, imageEditParams.value)
|
||||
} else if (activeFunction.value === 'image_effects') {
|
||||
Object.assign(params, imageEffectsParams.value)
|
||||
} else if (activeFunction.value === 'text_to_video') {
|
||||
Object.assign(params, textToVideoParams.value)
|
||||
} else if (activeFunction.value === 'image_to_video') {
|
||||
Object.assign(params, imageToVideoParams.value)
|
||||
}
|
||||
|
||||
httpPost('/api/jimeng/create', params)
|
||||
.then(() => {
|
||||
fetchData(1)
|
||||
taskPulling.value = true
|
||||
showToast('创建任务成功')
|
||||
currentPrompt.value = ''
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast('创建任务失败:' + e.message)
|
||||
})
|
||||
.finally(() => {
|
||||
submitting.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const fetchData = (_page) => {
|
||||
if (_page) {
|
||||
page.value = _page
|
||||
}
|
||||
listLoading.value = true
|
||||
httpGet('/api/jimeng/list', { page: page.value, page_size: pageSize.value })
|
||||
.then((res) => {
|
||||
total.value = res.data.total
|
||||
let needPull = false
|
||||
const items = []
|
||||
for (let v of res.data.items) {
|
||||
if (v.status === 'in_queue' || v.status === 'generating') {
|
||||
needPull = true
|
||||
}
|
||||
items.push(v)
|
||||
}
|
||||
listLoading.value = false
|
||||
taskPulling.value = needPull
|
||||
|
||||
if (page.value === 1) {
|
||||
currentList.value = items
|
||||
} else {
|
||||
currentList.value.push(...items)
|
||||
}
|
||||
|
||||
if (items.length < pageSize.value) {
|
||||
listFinished.value = true
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
listLoading.value = false
|
||||
showToast('获取作品列表失败:' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
page.value++
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const playMedia = (item) => {
|
||||
currentMediaUrl.value = item.img_url || item.video_url
|
||||
showMediaDialog.value = true
|
||||
}
|
||||
|
||||
const downloadFile = (item) => {
|
||||
item.downloading = true
|
||||
const link = document.createElement('a')
|
||||
link.href = item.img_url || item.video_url
|
||||
link.download = item.title || 'file'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
item.downloading = false
|
||||
showToast('开始下载')
|
||||
}
|
||||
|
||||
const retryTask = (id) => {
|
||||
httpPost('/api/jimeng/retry', { id })
|
||||
.then(() => {
|
||||
showToast('重试任务成功')
|
||||
fetchData(1)
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast('重试任务失败:' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const removeJob = (item) => {
|
||||
showDialog({
|
||||
title: '确认删除',
|
||||
message: '此操作将会删除任务相关文件,继续操作吗?',
|
||||
showCancelButton: true,
|
||||
})
|
||||
.then(() => {
|
||||
httpGet('/api/jimeng/remove', { id: item.id })
|
||||
.then(() => {
|
||||
showToast('任务删除成功')
|
||||
fetchData(1)
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast('任务删除失败:' + e.message)
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
// 工具方法
|
||||
const getFunctionName = (type) => {
|
||||
const nameMap = {
|
||||
text_to_image: '文生图',
|
||||
image_to_image: '图生图',
|
||||
image_edit: '图像编辑',
|
||||
image_effects: '图像特效',
|
||||
text_to_video: '文生视频',
|
||||
image_to_video: '图生视频',
|
||||
}
|
||||
return nameMap[type] || type
|
||||
}
|
||||
|
||||
const getTaskType = (type) => {
|
||||
return type.includes('video') ? 'warning' : 'primary'
|
||||
}
|
||||
|
||||
// 选择器确认方法
|
||||
const onSizeConfirm = (value) => {
|
||||
if (activeFunction.value === 'text_to_image') {
|
||||
textToImageParams.value.size = value
|
||||
} else if (activeFunction.value === 'image_to_image') {
|
||||
imageToImageParams.value.size = value
|
||||
} else if (activeFunction.value === 'image_effects') {
|
||||
imageEffectsParams.value.size = value
|
||||
}
|
||||
showSizePicker.value = false
|
||||
}
|
||||
|
||||
const onAspectRatioConfirm = (value) => {
|
||||
if (activeFunction.value === 'text_to_video') {
|
||||
textToVideoParams.value.aspect_ratio = value
|
||||
} else if (activeFunction.value === 'image_to_video') {
|
||||
imageToVideoParams.value.aspect_ratio = value
|
||||
}
|
||||
showAspectRatioPicker.value = false
|
||||
}
|
||||
|
||||
const onTemplateConfirm = (value) => {
|
||||
imageEffectsParams.value.template_id = value
|
||||
showTemplatePicker.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mobile-jimeng-create {
|
||||
min-height: 100vh;
|
||||
background: #f7f8fa;
|
||||
padding-bottom: 20px;
|
||||
|
||||
.page-header {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.category-section {
|
||||
background: #fff;
|
||||
margin: 12px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
.tab-content {
|
||||
padding: 16px;
|
||||
|
||||
.function-panel {
|
||||
.van-uploader {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.submit-section {
|
||||
margin: 12px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.works-list {
|
||||
margin: 12px;
|
||||
|
||||
.work-item {
|
||||
margin-bottom: 12px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.media-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
689
web/src/views/mobile/pages/SunoCreate.vue
Normal file
689
web/src/views/mobile/pages/SunoCreate.vue
Normal file
@@ -0,0 +1,689 @@
|
||||
<template>
|
||||
<div class="mobile-suno-create">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<van-nav-bar title="音乐创作" left-arrow @click-left="goBack" fixed placeholder />
|
||||
</div>
|
||||
|
||||
<!-- 创作表单 -->
|
||||
<div class="create-form">
|
||||
<!-- 模式切换 -->
|
||||
<div class="mode-switch">
|
||||
<van-cell-group>
|
||||
<van-cell title="创作模式">
|
||||
<template #right-icon>
|
||||
<van-switch v-model="custom" size="24" @change="onModeChange" />
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-cell title="自定义模式" :value="custom ? '开启' : '关闭'" />
|
||||
</van-cell-group>
|
||||
</div>
|
||||
|
||||
<!-- 模型选择 -->
|
||||
<div class="model-select">
|
||||
<van-field
|
||||
v-model="selectedModelLabel"
|
||||
label="模型"
|
||||
readonly
|
||||
is-link
|
||||
@click="showModelPicker = true"
|
||||
placeholder="请选择模型"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 纯音乐开关 -->
|
||||
<div class="pure-music">
|
||||
<van-cell title="纯音乐">
|
||||
<template #right-icon>
|
||||
<van-switch v-model="data.instrumental" size="24" />
|
||||
</template>
|
||||
</van-cell>
|
||||
</div>
|
||||
|
||||
<!-- 自定义模式内容 -->
|
||||
<div v-if="custom">
|
||||
<!-- 歌词输入 -->
|
||||
<div v-if="!data.instrumental" class="lyrics-section">
|
||||
<van-field
|
||||
v-model="data.lyrics"
|
||||
label="歌词"
|
||||
type="textarea"
|
||||
placeholder="请在这里输入你自己写的歌词..."
|
||||
rows="6"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
/>
|
||||
<van-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="createLyric"
|
||||
:loading="isGenerating"
|
||||
block
|
||||
class="mt-2"
|
||||
>
|
||||
生成歌词
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<!-- 音乐风格 -->
|
||||
<div class="style-section">
|
||||
<van-field
|
||||
v-model="data.tags"
|
||||
label="音乐风格"
|
||||
type="textarea"
|
||||
placeholder="请输入音乐风格,多个风格之间用英文逗号隔开..."
|
||||
rows="3"
|
||||
maxlength="120"
|
||||
show-word-limit
|
||||
/>
|
||||
<!-- 风格标签选择 -->
|
||||
<div class="style-tags">
|
||||
<van-tag
|
||||
v-for="tag in tags"
|
||||
:key="tag.value"
|
||||
type="primary"
|
||||
plain
|
||||
size="medium"
|
||||
@click="selectTag(tag)"
|
||||
class="mr-2 mb-2"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</van-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 歌曲名称 -->
|
||||
<div class="title-section">
|
||||
<van-field
|
||||
v-model="data.title"
|
||||
label="歌曲名称"
|
||||
placeholder="请输入歌曲名称..."
|
||||
maxlength="100"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 简单模式内容 -->
|
||||
<div v-else>
|
||||
<van-field
|
||||
v-model="data.prompt"
|
||||
label="歌曲描述"
|
||||
type="textarea"
|
||||
placeholder="例如:一首关于爱情的摇滚歌曲..."
|
||||
rows="6"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 续写歌曲 -->
|
||||
<div v-if="refSong" class="ref-song">
|
||||
<van-cell title="续写歌曲">
|
||||
<template #value>
|
||||
<van-button type="danger" size="small" @click="removeRefSong"> 移除 </van-button>
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-cell title="歌曲名称" :value="refSong.title" />
|
||||
<van-field
|
||||
v-model="refSong.extend_secs"
|
||||
label="续写开始时间(秒)"
|
||||
type="number"
|
||||
placeholder="从第几秒开始续写"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 上传音乐 -->
|
||||
<div class="upload-section">
|
||||
<div class="upload-area">
|
||||
<van-uploader
|
||||
v-model="uploadFiles"
|
||||
:max-count="1"
|
||||
:after-read="uploadAudio"
|
||||
accept=".wav,.mp3"
|
||||
:preview-size="80"
|
||||
:preview-image="false"
|
||||
>
|
||||
<div class="upload-placeholder">
|
||||
<van-icon name="plus" size="24" />
|
||||
<span>上传音乐文件</span>
|
||||
<small>支持 .wav, .mp3 格式</small>
|
||||
</div>
|
||||
</van-uploader>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 生成按钮 -->
|
||||
<div class="submit-section">
|
||||
<van-button type="primary" size="large" @click="create" :loading="loading" block>
|
||||
{{ btnText }}
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 作品列表 -->
|
||||
<div class="works-list">
|
||||
<van-list
|
||||
v-model:loading="listLoading"
|
||||
:finished="listFinished"
|
||||
finished-text="没有更多了"
|
||||
@load="loadMore"
|
||||
>
|
||||
<div v-for="item in list" :key="item.id" class="work-item">
|
||||
<van-card
|
||||
:title="item.title || '未命名歌曲'"
|
||||
:desc="item.tags || item.prompt"
|
||||
:thumb="item.cover_url"
|
||||
>
|
||||
<template #tags>
|
||||
<van-tag v-if="item.major_model_version" type="primary">
|
||||
{{ item.major_model_version }}
|
||||
</van-tag>
|
||||
<van-tag v-if="item.type === 4" type="success">用户上传</van-tag>
|
||||
<van-tag v-if="item.type === 3" type="warning">完整歌曲</van-tag>
|
||||
</template>
|
||||
<template #footer>
|
||||
<van-button v-if="item.progress === 100" size="small" @click="play(item)">
|
||||
播放
|
||||
</van-button>
|
||||
<van-button
|
||||
v-if="item.progress === 100"
|
||||
size="small"
|
||||
@click="download(item)"
|
||||
:loading="item.downloading"
|
||||
>
|
||||
下载
|
||||
</van-button>
|
||||
<van-button size="small" type="danger" @click="removeJob(item)"> 删除 </van-button>
|
||||
</template>
|
||||
</van-card>
|
||||
</div>
|
||||
</van-list>
|
||||
</div>
|
||||
|
||||
<!-- 模型选择弹窗 -->
|
||||
<van-popup v-model:show="showModelPicker" position="bottom" round>
|
||||
<van-picker
|
||||
:columns="modelOptions"
|
||||
@confirm="onModelConfirm"
|
||||
@cancel="showModelPicker = false"
|
||||
title="选择模型"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<!-- 音乐播放器 -->
|
||||
<van-popup v-model:show="showPlayer" position="bottom" round :style="{ height: '40%' }">
|
||||
<div class="player-content">
|
||||
<div class="player-header">
|
||||
<h3>正在播放</h3>
|
||||
<van-icon name="cross" @click="showPlayer = false" />
|
||||
</div>
|
||||
<audio v-if="currentAudio" :src="currentAudio" controls autoplay class="w-full" />
|
||||
</div>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast, showDialog } from 'vant'
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import { checkSession } from '@/store/cache'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 响应式数据
|
||||
const custom = ref(false)
|
||||
const data = ref({
|
||||
model: 'chirp-auk',
|
||||
tags: '',
|
||||
lyrics: '',
|
||||
prompt: '',
|
||||
title: '',
|
||||
instrumental: false,
|
||||
ref_task_id: '',
|
||||
extend_secs: 0,
|
||||
ref_song_id: '',
|
||||
})
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const listLoading = ref(false)
|
||||
const listFinished = ref(false)
|
||||
const btnText = ref('开始创作')
|
||||
const refSong = ref(null)
|
||||
const showModelPicker = ref(false)
|
||||
const showPlayer = ref(false)
|
||||
const currentAudio = ref('')
|
||||
const uploadFiles = ref([])
|
||||
const isGenerating = ref(false)
|
||||
|
||||
// 模型选项
|
||||
const models = ref([
|
||||
{ label: 'v3.0', value: 'chirp-v3-0' },
|
||||
{ label: 'v3.5', value: 'chirp-v3-5' },
|
||||
{ label: 'v4.0', value: 'chirp-v4' },
|
||||
{ label: 'v4.5', value: 'chirp-auk' },
|
||||
])
|
||||
|
||||
const modelOptions = models.value.map((item) => item.label)
|
||||
|
||||
// 计算当前选中的模型标签
|
||||
const selectedModelLabel = computed(() => {
|
||||
const selectedModel = models.value.find((item) => item.value === data.value.model)
|
||||
return selectedModel ? selectedModel.label : ''
|
||||
})
|
||||
|
||||
// 风格标签
|
||||
const tags = ref([
|
||||
{ label: '女声', value: 'female vocals' },
|
||||
{ label: '男声', value: 'male vocals' },
|
||||
{ label: '流行', value: 'pop' },
|
||||
{ label: '摇滚', value: 'rock' },
|
||||
{ label: '电音', value: 'electronic' },
|
||||
{ label: '钢琴', value: 'piano' },
|
||||
{ label: '吉他', value: 'guitar' },
|
||||
{ label: '嘻哈', value: 'hip hop' },
|
||||
])
|
||||
|
||||
// 页面数据
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const taskPulling = ref(true)
|
||||
const tastPullHandler = ref(null)
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
checkSession()
|
||||
.then(() => {
|
||||
fetchData(1)
|
||||
tastPullHandler.value = setInterval(() => {
|
||||
if (taskPulling.value) {
|
||||
fetchData(1)
|
||||
}
|
||||
}, 5000)
|
||||
})
|
||||
.catch(() => {})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (tastPullHandler.value) {
|
||||
clearInterval(tastPullHandler.value)
|
||||
}
|
||||
})
|
||||
|
||||
// 方法
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
const onModeChange = () => {
|
||||
if (!custom.value) {
|
||||
removeRefSong()
|
||||
}
|
||||
}
|
||||
|
||||
const onModelConfirm = (value) => {
|
||||
const selectedModel = models.value.find((item) => item.label === value)
|
||||
if (selectedModel) {
|
||||
data.value.model = selectedModel.value
|
||||
}
|
||||
showModelPicker.value = false
|
||||
}
|
||||
|
||||
const selectTag = (tag) => {
|
||||
if (data.value.tags.length + tag.value.length >= 119) {
|
||||
showToast('标签长度超出限制')
|
||||
return
|
||||
}
|
||||
const currentTags = data.value.tags.split(',').filter((t) => t.trim())
|
||||
if (!currentTags.includes(tag.value)) {
|
||||
currentTags.push(tag.value)
|
||||
data.value.tags = currentTags.join(',')
|
||||
}
|
||||
}
|
||||
|
||||
const createLyric = () => {
|
||||
if (data.value.lyrics === '') {
|
||||
showToast('请输入歌词描述')
|
||||
return
|
||||
}
|
||||
isGenerating.value = true
|
||||
httpPost('/api/prompt/lyric', { prompt: data.value.lyrics })
|
||||
.then((res) => {
|
||||
const lines = res.data.split('\n')
|
||||
data.value.title = lines.shift().replace(/\*/g, '')
|
||||
lines.shift()
|
||||
data.value.lyrics = lines.join('\n')
|
||||
showToast('歌词生成成功')
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast('歌词生成失败:' + e.message)
|
||||
})
|
||||
.finally(() => {
|
||||
isGenerating.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const uploadAudio = (file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file.file, file.name)
|
||||
showToast({ message: '正在上传文件...', duration: 0 })
|
||||
|
||||
httpPost('/api/upload', formData)
|
||||
.then((res) => {
|
||||
httpPost('/api/suno/create', {
|
||||
audio_url: res.data.url,
|
||||
title: res.data.name,
|
||||
type: 4,
|
||||
})
|
||||
.then(() => {
|
||||
fetchData(1)
|
||||
showToast('歌曲上传成功')
|
||||
removeRefSong()
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast('歌曲上传失败:' + e.message)
|
||||
})
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast('文件上传失败:' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const create = () => {
|
||||
data.value.type = custom.value ? 2 : 1
|
||||
data.value.ref_task_id = refSong.value ? refSong.value.task_id : ''
|
||||
data.value.ref_song_id = refSong.value ? refSong.value.song_id : ''
|
||||
data.value.extend_secs = refSong.value ? refSong.value.extend_secs : 0
|
||||
|
||||
if (refSong.value) {
|
||||
if (data.value.extend_secs > refSong.value.duration) {
|
||||
showToast('续写开始时间不能超过原歌曲长度')
|
||||
return
|
||||
}
|
||||
} else if (custom.value) {
|
||||
if (data.value.lyrics === '') {
|
||||
showToast('请输入歌词')
|
||||
return
|
||||
}
|
||||
if (data.value.title === '') {
|
||||
showToast('请输入歌曲标题')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (data.value.prompt === '') {
|
||||
showToast('请输入歌曲描述')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
httpPost('/api/suno/create', data.value)
|
||||
.then(() => {
|
||||
fetchData(1)
|
||||
taskPulling.value = true
|
||||
showToast('创建任务成功')
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast('创建任务失败:' + e.message)
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const fetchData = (_page) => {
|
||||
if (_page) {
|
||||
page.value = _page
|
||||
}
|
||||
listLoading.value = true
|
||||
httpGet('/api/suno/list', { page: page.value, page_size: pageSize.value })
|
||||
.then((res) => {
|
||||
total.value = res.data.total
|
||||
let needPull = false
|
||||
const items = []
|
||||
for (let v of res.data.items) {
|
||||
if (v.progress === 100) {
|
||||
v.major_model_version = v['raw_data']['major_model_version']
|
||||
}
|
||||
if (v.progress === 0 || v.progress === 102) {
|
||||
needPull = true
|
||||
}
|
||||
items.push(v)
|
||||
}
|
||||
listLoading.value = false
|
||||
taskPulling.value = needPull
|
||||
|
||||
if (page.value === 1) {
|
||||
list.value = items
|
||||
} else {
|
||||
list.value.push(...items)
|
||||
}
|
||||
|
||||
if (items.length < pageSize.value) {
|
||||
listFinished.value = true
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
listLoading.value = false
|
||||
showToast('获取作品列表失败:' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
page.value++
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const play = (item) => {
|
||||
currentAudio.value = item.audio_url
|
||||
showPlayer.value = true
|
||||
}
|
||||
|
||||
const download = (item) => {
|
||||
item.downloading = true
|
||||
const link = document.createElement('a')
|
||||
link.href = item.audio_url
|
||||
link.download = item.title || 'song.mp3'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
item.downloading = false
|
||||
showToast('开始下载')
|
||||
}
|
||||
|
||||
const removeJob = (item) => {
|
||||
showDialog({
|
||||
title: '确认删除',
|
||||
message: '此操作将会删除任务相关文件,继续操作吗?',
|
||||
showCancelButton: true,
|
||||
})
|
||||
.then(() => {
|
||||
httpGet('/api/suno/remove', { id: item.id })
|
||||
.then(() => {
|
||||
showToast('任务删除成功')
|
||||
fetchData(1)
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast('任务删除失败:' + e.message)
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const removeRefSong = () => {
|
||||
refSong.value = null
|
||||
btnText.value = '开始创作'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mobile-suno-create {
|
||||
min-height: 100vh;
|
||||
background: #f7f8fa;
|
||||
padding-bottom: 20px;
|
||||
|
||||
.page-header {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.create-form {
|
||||
background: #fff;
|
||||
margin: 12px;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
|
||||
.mode-switch {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.model-select {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pure-music {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.lyrics-section,
|
||||
.style-section,
|
||||
.title-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.style-tags {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ref-song {
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.upload-area {
|
||||
.upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 120px;
|
||||
background: #f8f9fa;
|
||||
border: 2px dashed #dee2e6;
|
||||
border-radius: 12px;
|
||||
color: #6c757d;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--van-primary-color);
|
||||
background: #f0f8ff;
|
||||
}
|
||||
|
||||
.van-icon {
|
||||
margin-bottom: 8px;
|
||||
color: var(--van-primary-color);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.submit-section {
|
||||
margin-top: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.works-list {
|
||||
margin: 12px;
|
||||
|
||||
.work-item {
|
||||
margin-bottom: 12px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.player-content {
|
||||
padding: 20px;
|
||||
|
||||
.player-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.van-icon {
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
audio {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 深色主题适配
|
||||
:deep(.van-theme-dark) {
|
||||
.mobile-suno-create {
|
||||
background: #1a1a1a;
|
||||
|
||||
.create-form {
|
||||
background: #2a2a2a;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
|
||||
|
||||
.ref-song {
|
||||
background: #333;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
.upload-area .upload-placeholder {
|
||||
background: #333;
|
||||
border-color: #555;
|
||||
|
||||
&:hover {
|
||||
background: #2a2a2a;
|
||||
border-color: var(--van-primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.works-list .work-item {
|
||||
background: #2a2a2a;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
792
web/src/views/mobile/pages/VideoCreate.vue
Normal file
792
web/src/views/mobile/pages/VideoCreate.vue
Normal file
@@ -0,0 +1,792 @@
|
||||
<template>
|
||||
<div class="mobile-video-create">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<van-nav-bar title="视频生成" left-arrow @click-left="goBack" fixed placeholder />
|
||||
</div>
|
||||
|
||||
<!-- 视频类型切换 -->
|
||||
<div class="video-type-tabs">
|
||||
<van-tabs v-model="activeVideoType" @change="onVideoTypeChange">
|
||||
<van-tab title="Luma视频" name="luma">
|
||||
<div class="tab-content">
|
||||
<!-- Luma 视频参数 -->
|
||||
<div class="params-container">
|
||||
<!-- 提示词输入 -->
|
||||
<van-field
|
||||
v-model="lumaParams.prompt"
|
||||
label="提示词"
|
||||
type="textarea"
|
||||
placeholder="请在此输入视频提示词,用逗号分割"
|
||||
rows="4"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
/>
|
||||
|
||||
<!-- 提示词生成按钮 -->
|
||||
<van-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="generatePrompt"
|
||||
:loading="isGenerating"
|
||||
block
|
||||
class="mt-2"
|
||||
>
|
||||
生成AI视频提示词
|
||||
</van-button>
|
||||
|
||||
<!-- 图片辅助生成开关 -->
|
||||
<van-cell title="使用图片辅助生成">
|
||||
<template #right-icon>
|
||||
<van-switch v-model="lumaUseImageMode" size="24" @change="toggleLumaImageMode" />
|
||||
</template>
|
||||
</van-cell>
|
||||
|
||||
<!-- 图片上传区域 -->
|
||||
<div v-if="lumaUseImageMode" class="image-upload-section">
|
||||
<div class="image-upload-row">
|
||||
<div class="image-upload-item">
|
||||
<van-uploader
|
||||
v-model="lumaStartImage"
|
||||
:max-count="1"
|
||||
:after-read="uploadLumaStartImage"
|
||||
accept=".jpg,.png,.jpeg"
|
||||
>
|
||||
<div class="upload-placeholder">
|
||||
<van-icon name="plus" />
|
||||
<span>起始帧</span>
|
||||
</div>
|
||||
</van-uploader>
|
||||
</div>
|
||||
<div class="image-upload-item">
|
||||
<van-uploader
|
||||
v-model="lumaEndImage"
|
||||
:max-count="1"
|
||||
:after-read="uploadLumaEndImage"
|
||||
accept=".jpg,.png,.jpeg"
|
||||
>
|
||||
<div class="upload-placeholder">
|
||||
<van-icon name="plus" />
|
||||
<span>结束帧</span>
|
||||
</div>
|
||||
</van-uploader>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Luma 特有参数 -->
|
||||
<van-cell title="循环参考图">
|
||||
<template #right-icon>
|
||||
<van-switch v-model="lumaParams.loop" size="24" />
|
||||
</template>
|
||||
</van-cell>
|
||||
|
||||
<van-cell title="提示词优化">
|
||||
<template #right-icon>
|
||||
<van-switch v-model="lumaParams.expand_prompt" size="24" />
|
||||
</template>
|
||||
</van-cell>
|
||||
|
||||
<!-- 算力显示 -->
|
||||
<van-cell title="当前可用算力" :value="`${availablePower}`" />
|
||||
|
||||
<!-- 生成按钮 -->
|
||||
<van-button
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="createLumaVideo"
|
||||
:loading="generating"
|
||||
block
|
||||
class="mt-4"
|
||||
>
|
||||
立即生成 ({{ lumaPowerCost }}算力)
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-tab>
|
||||
|
||||
<van-tab title="可灵视频" name="keling">
|
||||
<div class="tab-content">
|
||||
<!-- KeLing 视频参数 -->
|
||||
<div class="params-container">
|
||||
<!-- 画面比例 -->
|
||||
<van-field
|
||||
v-model="kelingParams.aspect_ratio"
|
||||
label="画面比例"
|
||||
readonly
|
||||
is-link
|
||||
@click="showAspectRatioPicker = true"
|
||||
/>
|
||||
|
||||
<!-- 模型选择 -->
|
||||
<van-field
|
||||
v-model="kelingParams.model"
|
||||
label="模型选择"
|
||||
readonly
|
||||
is-link
|
||||
@click="showModelPicker = true"
|
||||
/>
|
||||
|
||||
<!-- 视频时长 -->
|
||||
<van-field
|
||||
v-model="kelingParams.duration"
|
||||
label="视频时长"
|
||||
readonly
|
||||
is-link
|
||||
@click="showDurationPicker = true"
|
||||
/>
|
||||
|
||||
<!-- 生成模式 -->
|
||||
<van-field
|
||||
v-model="kelingParams.mode"
|
||||
label="生成模式"
|
||||
readonly
|
||||
is-link
|
||||
@click="showModePicker = true"
|
||||
/>
|
||||
|
||||
<!-- 创意程度 -->
|
||||
<van-cell title="创意程度">
|
||||
<template #value>
|
||||
<van-slider
|
||||
v-model="kelingParams.cfg_scale"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.1"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</template>
|
||||
</van-cell>
|
||||
|
||||
<!-- 运镜控制 -->
|
||||
<van-field
|
||||
v-model="kelingParams.camera_control.type"
|
||||
label="运镜控制"
|
||||
readonly
|
||||
is-link
|
||||
@click="showCameraControlPicker = true"
|
||||
/>
|
||||
|
||||
<!-- 图片辅助生成开关 -->
|
||||
<van-cell title="使用图片辅助生成">
|
||||
<template #right-icon>
|
||||
<van-switch
|
||||
v-model="kelingUseImageMode"
|
||||
size="24"
|
||||
@change="toggleKelingImageMode"
|
||||
/>
|
||||
</template>
|
||||
</van-cell>
|
||||
|
||||
<!-- 图片上传区域 -->
|
||||
<div v-if="kelingUseImageMode" class="image-upload-section">
|
||||
<div class="image-upload-row">
|
||||
<div class="image-upload-item">
|
||||
<van-uploader
|
||||
v-model="kelingStartImage"
|
||||
:max-count="1"
|
||||
:after-read="uploadKelingStartImage"
|
||||
accept=".jpg,.png,.jpeg"
|
||||
>
|
||||
<div class="upload-placeholder">
|
||||
<van-icon name="plus" />
|
||||
<span>起始帧</span>
|
||||
</div>
|
||||
</van-uploader>
|
||||
</div>
|
||||
<div class="image-upload-item">
|
||||
<van-uploader
|
||||
v-model="kelingEndImage"
|
||||
:max-count="1"
|
||||
:after-read="uploadKelingEndImage"
|
||||
accept=".jpg,.png,.jpeg"
|
||||
>
|
||||
<div class="upload-placeholder">
|
||||
<van-icon name="plus" />
|
||||
<span>结束帧</span>
|
||||
</div>
|
||||
</van-uploader>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示词输入 -->
|
||||
<van-field
|
||||
v-model="kelingParams.prompt"
|
||||
label="提示词"
|
||||
type="textarea"
|
||||
:placeholder="kelingUseImageMode ? '描述视频画面细节' : '请在此输入视频提示词'"
|
||||
rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
|
||||
<!-- 提示词生成按钮 -->
|
||||
<van-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="generatePrompt"
|
||||
:loading="isGenerating"
|
||||
block
|
||||
class="mt-2"
|
||||
>
|
||||
生成专业视频提示词
|
||||
</van-button>
|
||||
|
||||
<!-- 排除内容 -->
|
||||
<van-field
|
||||
v-model="kelingParams.negative_prompt"
|
||||
label="不希望出现的内容"
|
||||
type="textarea"
|
||||
placeholder="请在此输入你不希望出现在视频上的内容"
|
||||
rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
|
||||
<!-- 算力显示 -->
|
||||
<van-cell title="当前可用算力" :value="`${availablePower}`" />
|
||||
|
||||
<!-- 生成按钮 -->
|
||||
<van-button
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="createKelingVideo"
|
||||
:loading="generating"
|
||||
block
|
||||
class="mt-4"
|
||||
>
|
||||
立即生成 ({{ kelingPowerCost }}算力)
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-tab>
|
||||
</van-tabs>
|
||||
</div>
|
||||
|
||||
<!-- 作品列表 -->
|
||||
<div class="works-list">
|
||||
<van-list
|
||||
v-model:loading="listLoading"
|
||||
:finished="listFinished"
|
||||
finished-text="没有更多了"
|
||||
@load="loadMore"
|
||||
>
|
||||
<div v-for="item in currentList" :key="item.id" class="work-item">
|
||||
<van-card :title="item.title || '未命名视频'" :desc="item.prompt" :thumb="item.cover_url">
|
||||
<template #tags>
|
||||
<van-tag v-if="item.raw_data?.task_type" type="primary">
|
||||
{{ item.raw_data.task_type }}
|
||||
</van-tag>
|
||||
<van-tag v-if="item.raw_data?.model" type="success">
|
||||
{{ item.raw_data.model }}
|
||||
</van-tag>
|
||||
<van-tag v-if="item.raw_data?.duration" type="warning">
|
||||
{{ item.raw_data.duration }}秒
|
||||
</van-tag>
|
||||
</template>
|
||||
<template #footer>
|
||||
<van-button v-if="item.progress === 100" size="small" @click="playVideo(item)">
|
||||
播放
|
||||
</van-button>
|
||||
<van-button
|
||||
v-if="item.progress === 100"
|
||||
size="small"
|
||||
@click="downloadVideo(item)"
|
||||
:loading="item.downloading"
|
||||
>
|
||||
下载
|
||||
</van-button>
|
||||
<van-button size="small" type="danger" @click="removeJob(item)"> 删除 </van-button>
|
||||
</template>
|
||||
</van-card>
|
||||
</div>
|
||||
</van-list>
|
||||
</div>
|
||||
|
||||
<!-- 各种选择器弹窗 -->
|
||||
<van-popup v-model:show="showAspectRatioPicker" position="bottom">
|
||||
<van-picker
|
||||
:columns="aspectRatioOptions"
|
||||
@confirm="onAspectRatioConfirm"
|
||||
@cancel="showAspectRatioPicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<van-popup v-model:show="showModelPicker" position="bottom">
|
||||
<van-picker
|
||||
:columns="modelOptions"
|
||||
@confirm="onModelConfirm"
|
||||
@cancel="showModelPicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<van-popup v-model:show="showDurationPicker" position="bottom">
|
||||
<van-picker
|
||||
:columns="durationOptions"
|
||||
@confirm="onDurationConfirm"
|
||||
@cancel="showDurationPicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<van-popup v-model:show="showModePicker" position="bottom">
|
||||
<van-picker
|
||||
:columns="modeOptions"
|
||||
@confirm="onModeConfirm"
|
||||
@cancel="showModePicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<van-popup v-model:show="showCameraControlPicker" position="bottom">
|
||||
<van-picker
|
||||
:columns="cameraControlOptions"
|
||||
@confirm="onCameraControlConfirm"
|
||||
@cancel="showCameraControlPicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<!-- 视频预览弹窗 -->
|
||||
<van-popup
|
||||
v-model:show="showVideoDialog"
|
||||
position="center"
|
||||
:style="{ width: '90%', height: '60%' }"
|
||||
>
|
||||
<div class="video-preview">
|
||||
<video
|
||||
v-if="currentVideoUrl"
|
||||
:src="currentVideoUrl"
|
||||
controls
|
||||
autoplay
|
||||
style="width: 100%; height: 100%"
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</div>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast, showDialog } from 'vant'
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import { checkSession } from '@/store/cache'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 响应式数据
|
||||
const activeVideoType = ref('luma')
|
||||
const loading = ref(false)
|
||||
const generating = ref(false)
|
||||
const isGenerating = ref(false)
|
||||
const listLoading = ref(false)
|
||||
const listFinished = ref(false)
|
||||
const currentList = ref([])
|
||||
const showVideoDialog = ref(false)
|
||||
const currentVideoUrl = ref('')
|
||||
|
||||
// Luma 参数
|
||||
const lumaParams = ref({
|
||||
prompt: '',
|
||||
image: '',
|
||||
image_tail: '',
|
||||
loop: false,
|
||||
expand_prompt: false,
|
||||
})
|
||||
const lumaUseImageMode = ref(false)
|
||||
const lumaStartImage = ref([])
|
||||
const lumaEndImage = ref([])
|
||||
|
||||
// KeLing 参数
|
||||
const kelingParams = ref({
|
||||
aspect_ratio: '16:9',
|
||||
model: 'v1.5',
|
||||
duration: '5',
|
||||
mode: 'std',
|
||||
cfg_scale: 0.5,
|
||||
prompt: '',
|
||||
negative_prompt: '',
|
||||
image: '',
|
||||
image_tail: '',
|
||||
camera_control: {
|
||||
type: '',
|
||||
config: {
|
||||
horizontal: 0,
|
||||
vertical: 0,
|
||||
pan: 0,
|
||||
tilt: 0,
|
||||
roll: 0,
|
||||
zoom: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
const kelingUseImageMode = ref(false)
|
||||
const kelingStartImage = ref([])
|
||||
const kelingEndImage = ref([])
|
||||
|
||||
// 选择器相关
|
||||
const showAspectRatioPicker = ref(false)
|
||||
const showModelPicker = ref(false)
|
||||
const showDurationPicker = ref(false)
|
||||
const showModePicker = ref(false)
|
||||
const showCameraControlPicker = ref(false)
|
||||
|
||||
// 选项数据
|
||||
const aspectRatioOptions = ['16:9', '9:16', '1:1', '4:3']
|
||||
const modelOptions = ['v1.0', 'v1.5']
|
||||
const durationOptions = ['5', '10']
|
||||
const modeOptions = ['std', 'pro']
|
||||
const cameraControlOptions = [
|
||||
'',
|
||||
'simple',
|
||||
'down_back',
|
||||
'forward_up',
|
||||
'right_turn_forward',
|
||||
'left_turn_forward',
|
||||
]
|
||||
|
||||
// 页面数据
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const availablePower = ref(0)
|
||||
const lumaPowerCost = ref(0)
|
||||
const kelingPowerCost = ref(0)
|
||||
const taskPulling = ref(true)
|
||||
const tastPullHandler = ref(null)
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
checkSession()
|
||||
.then(() => {
|
||||
fetchData(1)
|
||||
fetchUserPower()
|
||||
tastPullHandler.value = setInterval(() => {
|
||||
if (taskPulling.value) {
|
||||
fetchData(1)
|
||||
}
|
||||
}, 5000)
|
||||
})
|
||||
.catch(() => {})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (tastPullHandler.value) {
|
||||
clearInterval(tastPullHandler.value)
|
||||
}
|
||||
})
|
||||
|
||||
// 方法
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
const onVideoTypeChange = (name) => {
|
||||
activeVideoType.value = name
|
||||
}
|
||||
|
||||
const generatePrompt = () => {
|
||||
isGenerating.value = true
|
||||
// TODO: 实现提示词生成逻辑
|
||||
setTimeout(() => {
|
||||
isGenerating.value = false
|
||||
showToast('提示词生成功能开发中')
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const toggleLumaImageMode = () => {
|
||||
if (!lumaUseImageMode.value) {
|
||||
lumaParams.value.image = ''
|
||||
lumaParams.value.image_tail = ''
|
||||
lumaStartImage.value = []
|
||||
lumaEndImage.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const toggleKelingImageMode = () => {
|
||||
if (!kelingUseImageMode.value) {
|
||||
kelingParams.value.image = ''
|
||||
kelingParams.value.image_tail = ''
|
||||
kelingStartImage.value = []
|
||||
kelingEndImage.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const uploadLumaStartImage = (file) => {
|
||||
uploadImage(file, (url) => {
|
||||
lumaParams.value.image = url
|
||||
})
|
||||
}
|
||||
|
||||
const uploadLumaEndImage = (file) => {
|
||||
uploadImage(file, (url) => {
|
||||
lumaParams.value.image_tail = url
|
||||
})
|
||||
}
|
||||
|
||||
const uploadKelingStartImage = (file) => {
|
||||
uploadImage(file, (url) => {
|
||||
kelingParams.value.image = url
|
||||
})
|
||||
}
|
||||
|
||||
const uploadKelingEndImage = (file) => {
|
||||
uploadImage(file, (url) => {
|
||||
kelingParams.value.image_tail = url
|
||||
})
|
||||
}
|
||||
|
||||
const uploadImage = (file, callback) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file.file, file.name)
|
||||
showToast({ message: '正在上传图片...', duration: 0 })
|
||||
|
||||
httpPost('/api/upload', formData)
|
||||
.then((res) => {
|
||||
callback(res.data.url)
|
||||
showToast('图片上传成功')
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast('图片上传失败:' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const createLumaVideo = () => {
|
||||
if (!lumaParams.value.prompt.trim()) {
|
||||
showToast('请输入视频提示词')
|
||||
return
|
||||
}
|
||||
|
||||
generating.value = true
|
||||
const params = {
|
||||
...lumaParams.value,
|
||||
task_type: 'luma',
|
||||
}
|
||||
|
||||
httpPost('/api/video/create', params)
|
||||
.then(() => {
|
||||
fetchData(1)
|
||||
taskPulling.value = true
|
||||
showToast('创建任务成功')
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast('创建任务失败:' + e.message)
|
||||
})
|
||||
.finally(() => {
|
||||
generating.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const createKelingVideo = () => {
|
||||
if (!kelingParams.value.prompt.trim()) {
|
||||
showToast('请输入视频提示词')
|
||||
return
|
||||
}
|
||||
|
||||
generating.value = true
|
||||
const params = {
|
||||
...kelingParams.value,
|
||||
task_type: 'keling',
|
||||
}
|
||||
|
||||
httpPost('/api/video/create', params)
|
||||
.then(() => {
|
||||
fetchData(1)
|
||||
taskPulling.value = true
|
||||
showToast('创建任务成功')
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast('创建任务失败:' + e.message)
|
||||
})
|
||||
.finally(() => {
|
||||
generating.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const fetchData = (_page) => {
|
||||
if (_page) {
|
||||
page.value = _page
|
||||
}
|
||||
listLoading.value = true
|
||||
httpGet('/api/video/list', { page: page.value, page_size: pageSize.value })
|
||||
.then((res) => {
|
||||
total.value = res.data.total
|
||||
let needPull = false
|
||||
const items = []
|
||||
for (let v of res.data.items) {
|
||||
if (v.progress === 0 || v.progress === 102) {
|
||||
needPull = true
|
||||
}
|
||||
items.push(v)
|
||||
}
|
||||
listLoading.value = false
|
||||
taskPulling.value = needPull
|
||||
|
||||
if (page.value === 1) {
|
||||
currentList.value = items
|
||||
} else {
|
||||
currentList.value.push(...items)
|
||||
}
|
||||
|
||||
if (items.length < pageSize.value) {
|
||||
listFinished.value = true
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
listLoading.value = false
|
||||
showToast('获取作品列表失败:' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const fetchUserPower = () => {
|
||||
httpGet('/api/user/power')
|
||||
.then((res) => {
|
||||
availablePower.value = res.data.power || 0
|
||||
lumaPowerCost.value = 10 // 示例值
|
||||
kelingPowerCost.value = 15 // 示例值
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
page.value++
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const playVideo = (item) => {
|
||||
currentVideoUrl.value = item.video_url
|
||||
showVideoDialog.value = true
|
||||
}
|
||||
|
||||
const downloadVideo = (item) => {
|
||||
item.downloading = true
|
||||
const link = document.createElement('a')
|
||||
link.href = item.video_url
|
||||
link.download = item.title || 'video.mp4'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
item.downloading = false
|
||||
showToast('开始下载')
|
||||
}
|
||||
|
||||
const removeJob = (item) => {
|
||||
showDialog({
|
||||
title: '确认删除',
|
||||
message: '此操作将会删除任务相关文件,继续操作吗?',
|
||||
showCancelButton: true,
|
||||
})
|
||||
.then(() => {
|
||||
httpGet('/api/video/remove', { id: item.id })
|
||||
.then(() => {
|
||||
showToast('任务删除成功')
|
||||
fetchData(1)
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast('任务删除失败:' + e.message)
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
// 选择器确认方法
|
||||
const onAspectRatioConfirm = (value) => {
|
||||
kelingParams.value.aspect_ratio = value
|
||||
showAspectRatioPicker.value = false
|
||||
}
|
||||
|
||||
const onModelConfirm = (value) => {
|
||||
kelingParams.value.model = value
|
||||
showModelPicker.value = false
|
||||
}
|
||||
|
||||
const onDurationConfirm = (value) => {
|
||||
kelingParams.value.duration = value
|
||||
showDurationPicker.value = false
|
||||
}
|
||||
|
||||
const onModeConfirm = (value) => {
|
||||
kelingParams.value.mode = value
|
||||
showModePicker.value = false
|
||||
}
|
||||
|
||||
const onCameraControlConfirm = (value) => {
|
||||
kelingParams.value.camera_control.type = value
|
||||
showCameraControlPicker.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mobile-video-create {
|
||||
min-height: 100vh;
|
||||
background: #f7f8fa;
|
||||
padding-bottom: 20px;
|
||||
|
||||
.page-header {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.video-type-tabs {
|
||||
background: #fff;
|
||||
margin: 12px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
.tab-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.params-container {
|
||||
.image-upload-section {
|
||||
margin: 16px 0;
|
||||
|
||||
.image-upload-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
.image-upload-item {
|
||||
flex: 1;
|
||||
|
||||
.upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100px;
|
||||
background: #f5f5f5;
|
||||
border: 2px dashed #ddd;
|
||||
border-radius: 8px;
|
||||
color: #999;
|
||||
|
||||
.van-icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.works-list {
|
||||
margin: 12px;
|
||||
|
||||
.work-item {
|
||||
margin-bottom: 12px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.video-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user