完成移动端邀请页面功能

This commit is contained in:
RockYang
2025-08-06 09:57:14 +08:00
parent 8d2519d5a1
commit bb6e90d50a
35 changed files with 3335 additions and 3233 deletions

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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"`

View 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"` // 邀请链接
}

View 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"` // 奖励算力
}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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')

View File

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

View File

@@ -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,

View File

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

View File

@@ -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;

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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);
}
}
}

View File

@@ -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>

View File

@@ -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) => {

View File

@@ -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',

View File

@@ -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>

View File

@@ -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>

View File

@@ -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: '作品展示',

View File

@@ -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;
}
}
}
// 深色主题优化

View File

@@ -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;

View File

@@ -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);
}
}
}
}
}

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View File

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

View File

@@ -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,

View File

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

View File

@@ -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', () => {

View 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>

View 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>

View 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>