调整移动端页面UI布局

This commit is contained in:
GeekMaster
2025-08-04 12:08:42 +08:00
parent f7cf992598
commit e994060e93
28 changed files with 1393 additions and 1686 deletions

View File

@@ -1,5 +1,13 @@
# 更新日志
## v4.2.6
- 功能优化:优化移动端首页 UI增加返回首页按钮
- 功能优化:优化移动端聊天页面 UI增加返回首页按钮
- 功能优化:优化移动端登录页面 UI增加返回首页按钮
- 功能优化:优化移动端注册页面 UI增加返回首页按钮
- 功能优化:优化移动端找回密码页面 UI增加返回首页按钮
## v4.2.5
- 功能优化:在代码右下角增加复制代码功能按钮,增加收起和展开代码功能

View File

@@ -2,9 +2,6 @@
.mobile-chat-list {
.content {
padding-top: 46px;
padding-bottom: 60px;
.van-list {
.van-cell__value {
.chat-list-item {

View File

@@ -2,15 +2,13 @@
<div class="foot-container">
<div class="footer">
<div>
<span>{{ copyRight }}</span>
</div>
<div v-if="!license?.de_copy">
<a :href="gitURL" target="_blank">
{{ title }} -
{{ version }}
</a>
</div>
<div v-if="icp">
<div>
<span class="mr-2">{{ copyRight }}</span>
<a href="https://beian.miit.gov.cn" target="_blank">{{ icp }}</a>
</div>
</div>
@@ -70,7 +68,7 @@ getLicenseInfo()
margin-top: -4px;
.footer {
max-width: 400px;
// max-width: 400px;
text-align: center;
font-size: 14px;
padding: 20px;

View File

@@ -0,0 +1,45 @@
<template>
<div v-if="active" class="custom-tab-pane">
<slot></slot>
</div>
</template>
<script setup>
import { computed, inject, useSlots } from 'vue'
const props = defineProps({
label: {
type: String,
required: false,
},
name: {
type: String,
required: true,
},
})
const slots = useSlots()
// 从父组件注入当前激活的 tab
const currentTab = inject('currentTab', '')
const active = computed(() => {
return currentTab.value === props.name
})
// 向父组件提供当前 pane 的信息,优先使用 labelSlot
const parentRegisterPane = inject('registerPane', () => {})
// 立即注册,不要等到 onMounted
parentRegisterPane({
name: props.name,
label: props.label || '', // 如果没有传 label 则使用空字符串
labelSlot: slots.label,
})
</script>
<style scoped>
.custom-tab-pane {
width: 100%;
}
</style>

View File

@@ -0,0 +1,143 @@
<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="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"
v-for="(tab, index) in panes"
:key="tab.name"
:class="{ '!text-purple-600': modelValue === tab.name }"
@click="handleTabClick(tab.name, index)"
ref="tabItems"
>
<component v-if="tab.labelSlot" :is="{ render: () => tab.labelSlot() }" />
<template v-else>
{{ tab.label }}
</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>
</div>
</div>
</template>
<script setup>
import { computed, nextTick, onMounted, provide, ref, watch } from 'vue'
const props = defineProps({
modelValue: {
type: String,
required: true,
},
})
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',
})
// 提供当前激活的 tab 给子组件
provide(
'currentTab',
computed(() => props.modelValue)
)
// 提供注册 pane 的方法给子组件
provide('registerPane', (pane) => {
// 检查是否已经存在相同 name 的 pane避免重复注册
const existingIndex = panes.value.findIndex((p) => p.name === pane.name)
if (existingIndex === -1) {
// 不存在则添加
panes.value.push(pane)
} else {
// 存在则更新
panes.value[existingIndex] = pane
}
})
const handleTabClick = (tabName, index) => {
emit('update:modelValue', tabName)
emit('tab-click', tabName, index)
updateIndicator(index)
}
const updateIndicator = 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`,
}
}
}
}
// 监听 modelValue 变化,更新指示器位置
watch(
() => props.modelValue,
(newValue) => {
const activeIndex = panes.value.findIndex((pane) => pane.name === newValue)
if (activeIndex !== -1) {
updateIndicator(activeIndex)
}
}
)
onMounted(() => {
// 初始化指示器位置
nextTick(() => {
const activeIndex = panes.value.findIndex((pane) => pane.name === props.modelValue)
if (activeIndex !== -1) {
updateIndicator(activeIndex)
}
})
})
</script>
<style scoped>
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
}
/* 确保标签页容器有足够的内边距 */
.overflow-x-auto {
padding: 0 4px;
}
/* 优化标签页间距 */
.flex-shrink-0 {
margin-right: 4px;
}
.flex-shrink-0:last-child {
margin-right: 0;
}
</style>

View File

@@ -59,7 +59,9 @@ import {
ShareSheet,
Slider,
Sticky,
Swipe,
SwipeCell,
SwipeItem,
Switch,
Tab,
Tabbar,
@@ -101,6 +103,8 @@ app.use(DropdownMenu)
app.use(Icon)
app.use(DropdownItem)
app.use(Sticky)
app.use(Swipe)
app.use(SwipeItem)
app.use(SwipeCell)
app.use(Dialog)
app.use(ShareSheet)

View File

@@ -123,26 +123,12 @@ const routes = [
meta: { title: '导出会话记录' },
component: () => import('@/views/ChatExport.vue'),
},
{
name: 'login',
path: '/login',
meta: { title: '用户登录' },
component: () => import('@/views/Login.vue'),
},
{
name: 'login-callback',
path: '/login/callback',
meta: { title: '用户登录' },
component: () => import('@/views/LoginCallback.vue'),
},
{
name: 'register',
path: '/register',
meta: { title: '用户注册' },
component: () => import('@/views/Register.vue'),
},
{
name: 'resetpassword',
path: '/resetpassword',

View File

@@ -8,7 +8,7 @@
<img :src="logo" class="logo" alt="Geek-AI" />
</div>
<div class="menu-item">
<span v-if="!license?.de_copy">
<span v-if="!license || !license.de_copy">
<el-tooltip class="box-item" content="部署文档" placement="bottom">
<a :href="docsURL" class="link-button mr-3" target="_blank">
<i class="iconfont icon-book"></i>
@@ -28,7 +28,7 @@
<span v-if="!isLogin">
<el-button
@click="router.push('/login')"
@click="showLoginDialog = true"
class="btn-go animate__animated animate__pulse animate__infinite"
round
>登录/注册</el-button
@@ -79,6 +79,18 @@
<footer-bar />
<!-- 登录弹窗 -->
<el-dialog v-model="showLoginDialog" width="500px" @close="showLoginDialog = false">
<template #header>
<div class="text-center text-xl" style="color: var(--theme-text-color-primary)">
登录解锁更多功能
</div>
</template>
<div class="p-4 pt-2 pb-2">
<LoginDialog @success="loginSuccess" @hide="showLoginDialog = false" />
</div>
</el-dialog>
<!-- 网站公告对话框 -->
<el-dialog v-model="showNotice" :show-close="true" class="notice-dialog" title="网站公告">
<div class="notice">
@@ -96,6 +108,7 @@
<script setup>
import FooterBar from '@/components/FooterBar.vue'
import LoginDialog from '@/components/LoginDialog.vue'
import ThemeChange from '@/components/ThemeChange.vue'
import { checkSession, getLicenseInfo, getSystemInfo } from '@/store/cache'
import { removeUserToken } from '@/store/session'
@@ -113,6 +126,7 @@ const logo = ref('')
const license = ref({ de_copy: true })
const isLogin = ref(false)
const showLoginDialog = ref(false)
const docsURL = ref(import.meta.env.VITE_DOCS_URL)
const githubURL = ref(import.meta.env.VITE_GITHUB_URL)
const giteeURL = ref(import.meta.env.VITE_GITEE_URL)
@@ -185,7 +199,12 @@ onMounted(() => {
const logout = () => {
removeUserToken()
router.push('/login')
isLogin.value = false
}
const loginSuccess = () => {
isLogin.value = true
showLoginDialog.value = false
}
// 不再显示公告

View File

@@ -1,377 +0,0 @@
<template>
<div class="flex-center loginPage">
<div class="left">
<div class="login-box">
<AccountTop>
<template #default>
<div class="wechatLog flex-center" v-if="wechatLoginURL !== ''">
<a :href="wechatLoginURL" @click="setRoute(router.currentRoute.value.path)">
<i class="iconfont icon-wechat"></i>使用微信登录
</a>
</div>
</template>
</AccountTop>
<div class="input-form">
<el-form ref="ruleFormRef" :model="ruleForm" :rules="rules">
<el-form-item label="" prop="username">
<div class="form-title">账号</div>
<el-input
v-model="ruleForm.username"
size="large"
placeholder="请输入账号"
@keyup="handleKeyup"
/>
</el-form-item>
<el-form-item label="" prop="password">
<div class="flex-between w100">
<div class="form-title">密码</div>
<div class="form-forget text-color-primary" @click="router.push('/resetpassword')">
忘记密码
</div>
</div>
<el-input
size="large"
v-model="ruleForm.password"
placeholder="请输入密码"
show-password
autocomplete="off"
@keyup="handleKeyup"
/>
</el-form-item>
<el-form-item label="" prop="agreement" :class="{ 'agreement-error': agreementError }">
<div class="agreement-box" :class="{ shake: isShaking }">
<el-checkbox v-model="ruleForm.agreement" @change="handleAgreementChange">
我已阅读并同意
<span class="agreement-link" @click.stop.prevent="openAgreement"
>用户协议</span
>
<span class="agreement-link" @click.stop.prevent="openPrivacy">隐私政策</span>
</el-checkbox>
</div>
</el-form-item>
<el-form-item>
<el-button class="login-btn" size="large" type="primary" @click="login"
>登录</el-button
>
</el-form-item>
</el-form>
</div>
</div>
</div>
<account-bg />
<captcha v-if="enableVerify" @success="doLogin" ref="captchaRef" />
</div>
</template>
<script setup>
import AccountBg from '@/components/AccountBg.vue'
import { checkSession, getLicenseInfo, getSystemInfo } from '@/store/cache'
import { setUserToken } from '@/store/session'
import { useSharedStore } from '@/store/sharedata'
import { setRoute } from '@/store/system'
import { showMessageError } from '@/utils/dialog'
import { httpGet, httpPost } from '@/utils/http'
import { ElMessageBox } from 'element-plus'
import MarkdownIt from 'markdown-it'
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import AccountTop from '@/components/AccountTop.vue'
import Captcha from '@/components/Captcha.vue'
const router = useRouter()
const title = ref('')
const logo = ref('')
const licenseConfig = ref({})
const wechatLoginURL = ref('')
const enableVerify = ref(false)
const captchaRef = ref(null)
const ruleFormRef = ref(null)
const ruleForm = reactive({
username: import.meta.env.VITE_USER,
password: import.meta.env.VITE_PASS,
agreement: false,
})
const rules = {
username: [{ required: true, trigger: 'blur', message: '请输入账号' }],
password: [{ required: true, trigger: 'blur', message: '请输入密码' }],
agreement: [{ required: true, trigger: 'change', message: '请同意用户协议' }],
}
const agreementContent = ref('')
const privacyContent = ref('')
// 初始化markdown解析器
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
})
onMounted(() => {
// 检查URL中是否存在token参数
const urlParams = new URLSearchParams(window.location.search)
const token = urlParams.get('token')
if (token) {
setUserToken(token)
store.setIsLogin(true)
router.push('/chat')
return
}
// 获取系统配置
getSystemInfo()
.then((res) => {
logo.value = res.data.logo
title.value = res.data.title
enableVerify.value = res.data['enabled_verify']
})
.catch((e) => {
showMessageError('获取系统配置失败:' + e.message)
title.value = 'Geek-AI'
})
// 获取用户协议
httpGet('/api/config/get?key=agreement')
.then((res) => {
if (res.data && res.data.content) {
agreementContent.value = res.data.content
} else {
agreementContent.value =
'用户在使用本服务前应当阅读并同意本协议。本协议内容包括协议正文及所有本平台已经发布的或将来可能发布的各类规则。所有规则为本协议不可分割的组成部分,与协议正文具有同等法律效力。'
}
})
.catch((e) => {
agreementContent.value =
'用户在使用本服务前应当阅读并同意本协议。本协议内容包括协议正文及所有本平台已经发布的或将来可能发布的各类规则。所有规则为本协议不可分割的组成部分,与协议正文具有同等法律效力。'
})
// 获取隐私政策
httpGet('/api/config/get?key=privacy')
.then((res) => {
if (res.data && res.data.content) {
privacyContent.value = res.data.content
} else {
privacyContent.value =
'我们非常重视用户的隐私和个人信息保护。您在使用我们的产品与服务时,我们可能会收集和使用您的相关信息。我们希望通过本《隐私政策》向您说明我们在收集和使用您相关信息时对应的处理规则。'
}
})
.catch((e) => {
privacyContent.value =
'我们非常重视用户的隐私和个人信息保护。您在使用我们的产品与服务时,我们可能会收集和使用您的相关信息。我们希望通过本《隐私政策》向您说明我们在收集和使用您相关信息时对应的处理规则。'
})
getLicenseInfo()
.then((res) => {
licenseConfig.value = res.data
})
.catch((e) => {
showMessageError('获取 License 配置:' + e.message)
})
checkSession()
.then(() => {
router.back()
})
.catch(() => {})
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.error(e)
})
})
const handleKeyup = (e) => {
if (e.key === 'Enter') {
login()
}
}
const login = async function () {
if (!ruleForm.agreement) {
agreementError.value = true
isShaking.value = true
setTimeout(() => {
isShaking.value = false
}, 500)
showMessageError('请先阅读并同意用户协议')
return
}
await ruleFormRef.value.validate(async (valid) => {
if (valid) {
if (enableVerify.value) {
captchaRef.value.loadCaptcha()
} else {
doLogin({})
}
}
})
}
const store = useSharedStore()
const doLogin = (verifyData) => {
httpPost('/api/user/login', {
username: ruleForm.username,
password: ruleForm.password,
key: verifyData.key,
dots: verifyData.dots,
x: verifyData.x,
})
.then((res) => {
setUserToken(res.data.token)
store.setIsLogin(true)
router.back()
})
.catch((e) => {
showMessageError('登录失败,' + e.message)
})
}
const agreementError = ref(false)
const isShaking = ref(false)
const handleAgreementChange = () => {
agreementError.value = !ruleForm.agreement
if (agreementError.value) {
isShaking.value = true
setTimeout(() => {
isShaking.value = false
}, 500)
}
}
const openAgreement = () => {
// 使用弹窗显示用户协议内容支持Markdown格式
ElMessageBox.alert(
`<div class="markdown-content">${md.render(agreementContent.value)}</div>`,
'用户协议',
{
confirmButtonText: '我已阅读',
dangerouslyUseHTMLString: true,
callback: () => {},
}
)
}
const openPrivacy = () => {
// 使用弹窗显示隐私政策内容支持Markdown格式
ElMessageBox.alert(
`<div class="markdown-content">${md.render(privacyContent.value)}</div>`,
'隐私政策',
{
confirmButtonText: '我已阅读',
dangerouslyUseHTMLString: true,
callback: () => {},
}
)
}
</script>
<style lang="scss" scoped>
@use '../assets/css/login.scss' as *;
.agreement-box {
margin-bottom: 10px;
transition: all 0.3s;
}
.agreement-link {
color: var(--el-color-primary);
cursor: pointer;
}
.agreement-error {
.el-checkbox {
.el-checkbox__input {
.el-checkbox__inner {
border-color: #f56c6c !important;
}
}
}
}
.shake {
animation: shake 0.5s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
}
@keyframes shake {
10%,
90% {
transform: translate3d(-1px, 0, 0);
}
20%,
80% {
transform: translate3d(2px, 0, 0);
}
30%,
50%,
70% {
transform: translate3d(-4px, 0, 0);
}
40%,
60% {
transform: translate3d(4px, 0, 0);
}
}
</style>
<style>
/* 全局样式用于Markdown内容显示 */
.markdown-content {
text-align: left;
max-height: 60vh;
overflow-y: auto;
padding: 10px;
}
.markdown-content h1 {
font-size: 1.5em;
margin-bottom: 15px;
}
.markdown-content h2 {
font-size: 1.3em;
margin: 15px 0 10px;
}
.markdown-content p {
margin-bottom: 10px;
line-height: 1.5;
}
.markdown-content ul,
.markdown-content ol {
padding-left: 20px;
margin-bottom: 10px;
}
.markdown-content blockquote {
border-left: 4px solid #ccc;
padding-left: 10px;
color: #666;
margin: 10px 0;
}
.markdown-content code {
background-color: #f0f0f0;
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
}
.markdown-content pre {
background-color: #f0f0f0;
padding: 10px;
border-radius: 5px;
overflow-x: auto;
margin: 10px 0;
}
</style>

View File

@@ -98,7 +98,7 @@ const doLogin = (userId) => {
type: 'error',
title: '登录失败',
callback: () => {
router.push('/login')
router.push('/')
},
})
})

View File

@@ -1,510 +0,0 @@
<template>
<div>
<div class="flex-center loginPage">
<div class="left" v-if="enableRegister">
<div class="login-box">
<AccountTop title="注册" />
<div class="input-form">
<el-form :model="data" class="form">
<el-tabs v-model="activeName">
<el-tab-pane label="手机注册" name="mobile" v-if="enableMobile">
<el-form-item>
<div class="form-title">手机号码</div>
<el-input
placeholder="请输入手机号码"
size="large"
v-model="data.mobile"
maxlength="11"
autocomplete="off"
>
</el-input>
</el-form-item>
<el-form-item>
<div class="form-title">验证码</div>
<div class="flex w100">
<el-input
placeholder="请输入验证码"
size="large"
maxlength="30"
class="code-input"
v-model="data.code"
autocomplete="off"
>
</el-input>
<send-msg size="large" :receiver="data.mobile" type="mobile" />
</div>
</el-form-item>
</el-tab-pane>
<el-tab-pane label="邮箱注册" name="email" v-if="enableEmail">
<el-form-item class="block">
<div class="form-title">邮箱</div>
<el-input
placeholder="请输入邮箱地址"
size="large"
v-model="data.email"
autocomplete="off"
>
</el-input>
</el-form-item>
<el-form-item class="block">
<div class="form-title">验证码</div>
<div class="flex w100">
<el-input
placeholder="请输入验证码"
size="large"
maxlength="30"
class="code-input"
v-model="data.code"
autocomplete="off"
>
</el-input>
<send-msg size="large" :receiver="data.email" type="email" />
</div>
</el-form-item>
</el-tab-pane>
<el-tab-pane label="用户名注册" name="username" v-if="enableUser">
<el-form-item class="block">
<div class="form-title">用户名</div>
<el-input
placeholder="请输入用户名"
size="large"
v-model="data.username"
autocomplete="off"
>
</el-input>
</el-form-item>
</el-tab-pane>
</el-tabs>
<el-form-item class="block">
<div class="form-title">密码</div>
<el-input
placeholder="请输入密码(8-16位)"
maxlength="16"
size="large"
v-model="data.password"
show-password
autocomplete="off"
>
</el-input>
</el-form-item>
<el-form-item class="block">
<div class="form-title">重复密码</div>
<el-input
placeholder="请再次输入密码(8-16位)"
size="large"
maxlength="16"
v-model="data.repass"
show-password
autocomplete="off"
>
</el-input>
</el-form-item>
<el-form-item class="block">
<div class="form-title">邀请码</div>
<el-input
placeholder="请输入邀请码(可选)"
size="large"
v-model="data.invite_code"
autocomplete="off"
>
</el-input>
</el-form-item>
<el-form-item
label=""
prop="agreement"
:class="{ 'agreement-error': agreementError }"
>
<div class="agreement-box" :class="{ shake: isShaking }">
<el-checkbox v-model="data.agreement" @change="handleAgreementChange">
我已阅读并同意
<span class="agreement-link" @click.stop.prevent="openAgreement"
>用户协议</span
>
<span class="agreement-link" @click.stop.prevent="openPrivacy"
>隐私政策</span
>
</el-checkbox>
</div>
</el-form-item>
<el-row class="btn-row" :gutter="20">
<el-col :span="24">
<el-button class="login-btn" type="primary" size="large" @click="submitRegister"
>注册</el-button
>
</el-col>
</el-row>
</el-form>
</div>
</div>
</div>
<div class="tip-result left" v-else>
<el-result icon="error" title="注册功能已关闭">
<template #sub-title>
<p>抱歉系统已关闭注册功能请联系管理员添加账号</p>
<div class="wechat-card">
<el-image :src="wxImg" />
</div>
<div class="mt-3">
<el-button type="primary" @click="router.push('/')"
><i class="iconfont icon-home mr-1"></i> 返回首页</el-button
>
</div>
</template>
</el-result>
</div>
<captcha v-if="enableVerify" @success="doSubmitRegister" ref="captchaRef" />
<account-bg />
</div>
</div>
</template>
<script setup>
import AccountBg from '@/components/AccountBg.vue'
import AccountTop from '@/components/AccountTop.vue'
import { ref } from 'vue'
import { httpGet, httpPost } from '@/utils/http'
import { ElMessage, ElMessageBox } from 'element-plus'
import MarkdownIt from 'markdown-it'
import { useRouter } from 'vue-router'
import Captcha from '@/components/Captcha.vue'
import SendMsg from '@/components/SendMsg.vue'
import { getLicenseInfo, getSystemInfo } from '@/store/cache'
import { setUserToken } from '@/store/session'
import { showMessageError, showMessageOK } from '@/utils/dialog'
import { arrayContains, isMobile } from '@/utils/libs'
import { validateEmail, validateMobile } from '@/utils/validate'
const router = useRouter()
const title = ref('')
const logo = ref('')
const data = ref({
username: '',
mobile: '',
email: '',
password: '',
code: '',
repass: '',
invite_code: router.currentRoute.value.query['invite_code'],
agreement: false,
})
const enableMobile = ref(false)
const enableEmail = ref(false)
const enableUser = ref(false)
const enableRegister = ref(true)
const activeName = ref('mobile')
const wxImg = ref('/images/wx.png')
const licenseConfig = ref({})
const enableVerify = ref(false)
const captchaRef = ref(null)
const agreementError = ref(false)
const isShaking = ref(false)
// 初始化markdown解析器
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
})
const agreementContent = ref('')
const privacyContent = ref('')
// 记录邀请码点击次数
if (data.value.invite_code) {
httpGet('/api/invite/hits', { code: data.value.invite_code })
}
getSystemInfo()
.then((res) => {
if (res.data) {
title.value = res.data.title
logo.value = res.data.logo
const registerWays = res.data['register_ways']
if (arrayContains(registerWays, 'username')) {
enableUser.value = true
activeName.value = 'username'
}
if (arrayContains(registerWays, 'email')) {
enableEmail.value = true
activeName.value = 'email'
}
if (arrayContains(registerWays, 'mobile')) {
enableMobile.value = true
activeName.value = 'mobile'
}
// 是否启用注册
enableRegister.value = res.data['enabled_register']
// 使用后台上传的客服微信二维码
if (res.data['wechat_card_url'] !== '') {
wxImg.value = res.data['wechat_card_url']
}
enableVerify.value = res.data['enabled_verify']
}
})
.catch((e) => {
ElMessage.error('获取系统配置失败:' + e.message)
})
// 获取用户协议
httpGet('/api/config/get?key=agreement')
.then((res) => {
if (res.data && res.data.content) {
agreementContent.value = res.data.content
} else {
agreementContent.value =
'# 用户协议\n\n用户在使用本服务前应当阅读并同意本协议。本协议内容包括协议正文及所有本平台已经发布的或将来可能发布的各类规则。所有规则为本协议不可分割的组成部分与协议正文具有同等法律效力。'
}
})
.catch((e) => {
console.warn(e)
agreementContent.value =
'# 用户协议\n\n用户在使用本服务前应当阅读并同意本协议。本协议内容包括协议正文及所有本平台已经发布的或将来可能发布的各类规则。所有规则为本协议不可分割的组成部分与协议正文具有同等法律效力。'
})
// 获取隐私政策
httpGet('/api/config/get?key=privacy')
.then((res) => {
if (res.data && res.data.content) {
privacyContent.value = res.data.content
} else {
privacyContent.value =
'# 隐私政策\n\n我们非常重视用户的隐私和个人信息保护。您在使用我们的产品与服务时我们可能会收集和使用您的相关信息。我们希望通过本《隐私政策》向您说明我们在收集和使用您相关信息时对应的处理规则。'
}
})
.catch((e) => {
console.warn(e)
privacyContent.value =
'# 隐私政策\n\n我们非常重视用户的隐私和个人信息保护。您在使用我们的产品与服务时我们可能会收集和使用您的相关信息。我们希望通过本《隐私政策》向您说明我们在收集和使用您相关信息时对应的处理规则。'
})
getLicenseInfo()
.then((res) => {
licenseConfig.value = res.data
})
.catch((e) => {
showMessageError('获取 License 配置:' + e.message)
})
// 注册操作
const submitRegister = () => {
if (activeName.value === 'username' && data.value.username === '') {
return showMessageError('请输入用户名')
}
if (activeName.value === 'mobile' && !validateMobile(data.value.mobile)) {
return showMessageError('请输入合法的手机号')
}
if (activeName.value === 'email' && !validateEmail(data.value.email)) {
return showMessageError('请输入合法的邮箱地址')
}
if (data.value.password.length < 8) {
return showMessageError('密码的长度为8-16个字符')
}
if (data.value.repass !== data.value.password) {
return showMessageError('两次输入密码不一致')
}
if ((activeName.value === 'mobile' || activeName.value === 'email') && data.value.code === '') {
return showMessageError('请输入验证码')
}
if (!data.value.agreement) {
agreementError.value = true
isShaking.value = true
setTimeout(() => {
isShaking.value = false
}, 500)
showMessageError('请先阅读并同意用户协议和隐私政策')
return
}
// 如果是用户名和密码登录,那么需要加载验证码
if (enableVerify.value && activeName.value === 'username') {
captchaRef.value.loadCaptcha()
} else {
doSubmitRegister({})
}
}
const doSubmitRegister = (verifyData) => {
data.value.key = verifyData.key
data.value.dots = verifyData.dots
data.value.x = verifyData.x
data.value.reg_way = activeName.value
httpPost('/api/user/register', data.value)
.then((res) => {
setUserToken(res.data.token)
showMessageOK('注册成功,即将跳转到对话主界面...')
if (isMobile()) {
router.push('/mobile/index')
} else {
router.push('/chat')
}
})
.catch((e) => {
showMessageError('注册失败,' + e.message)
})
}
const handleAgreementChange = () => {
agreementError.value = !data.value.agreement
}
const openAgreement = () => {
// 使用弹窗显示用户协议内容支持Markdown格式
ElMessageBox.alert(
`<div class="markdown-content">${md.render(agreementContent.value)}</div>`,
'用户协议',
{
confirmButtonText: '我已阅读',
dangerouslyUseHTMLString: true,
callback: () => {},
}
)
}
const openPrivacy = () => {
// 使用弹窗显示隐私政策内容支持Markdown格式
ElMessageBox.alert(
`<div class="markdown-content">${md.render(privacyContent.value)}</div>`,
'隐私政策',
{
confirmButtonText: '我已阅读',
dangerouslyUseHTMLString: true,
callback: () => {},
}
)
}
</script>
<style lang="scss" scoped>
@use '../assets/css/login.scss' as *;
:deep(.back) {
margin-bottom: 10px;
}
:deep(.orline) {
margin-bottom: 10px;
}
.wechat-card {
margin-top: 20px;
}
.agreement-box {
margin-bottom: 10px;
transition: all 0.3s;
}
.agreement-link {
color: var(--el-color-primary);
cursor: pointer;
}
.agreement-error {
.el-checkbox {
.el-checkbox__input {
.el-checkbox__inner {
border-color: #f56c6c !important;
}
}
}
}
.shake {
animation: shake 0.5s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
}
@keyframes shake {
10%,
90% {
transform: translate3d(-1px, 0, 0);
}
20%,
80% {
transform: translate3d(2px, 0, 0);
}
30%,
50%,
70% {
transform: translate3d(-4px, 0, 0);
}
40%,
60% {
transform: translate3d(4px, 0, 0);
}
}
</style>
<style>
/* 全局样式用于Markdown内容显示 */
.markdown-content {
text-align: left;
max-height: 60vh;
overflow-y: auto;
padding: 10px;
}
.markdown-content h1 {
font-size: 1.5em;
margin-bottom: 15px;
}
.markdown-content h2 {
font-size: 1.3em;
margin: 15px 0 10px;
}
.markdown-content p {
margin-bottom: 10px;
line-height: 1.5;
}
.markdown-content ul,
.markdown-content ol {
padding-left: 20px;
margin-bottom: 10px;
}
.markdown-content blockquote {
border-left: 4px solid #ccc;
padding-left: 10px;
color: #666;
margin: 10px 0;
}
.markdown-content code {
background-color: #f0f0f0;
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
}
.markdown-content pre {
background-color: #f0f0f0;
padding: 10px;
border-radius: 5px;
overflow-x: auto;
margin: 10px 0;
}
</style>

View File

@@ -1,10 +1,8 @@
<template>
<div class="apps-page">
<van-nav-bar title="全部应用" left-arrow @click-left="router.back()" />
<div class="apps-filter mb-8 pt-8" style="border: 1px solid #ccc">
<van-tabs v-model="activeTab" animated swipeable>
<van-tab title="全部分类">
<div class="apps-filter mb-8 px-3">
<CustomTabs :model-value="activeTab" @update:model-value="activeTab = $event">
<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">
@@ -40,8 +38,8 @@
</van-cell>
</van-list>
</div>
</van-tab>
<van-tab v-for="type in appTypes" :key="type.id" :title="type.name">
</CustomTabPane>
<CustomTabPane v-for="type in appTypes" :key="type.id" :name="type.id" :label="type.name">
<div class="app-list">
<van-list
v-model="loading"
@@ -82,13 +80,15 @@
</van-cell>
</van-list>
</div>
</van-tab>
</van-tabs>
</CustomTabPane>
</CustomTabs>
</div>
</div>
</template>
<script setup>
import CustomTabPane from '@/components/ui/CustomTabPane.vue'
import CustomTabs from '@/components/ui/CustomTabs.vue'
import { checkSession } from '@/store/cache'
import { httpGet, httpPost } from '@/utils/http'
import { arrayContains, removeArrayItem, showLoginDialog, substr } from '@/utils/libs'
@@ -151,16 +151,16 @@ const updateRole = (row, opt) => {
return showLoginDialog(router)
}
const title = ref('')
let actionTitle = ''
if (opt === 'add') {
title.value = '添加应用'
actionTitle = '添加应用'
const exists = arrayContains(roles.value, row.key)
if (exists) {
return
}
roles.value.push(row.key)
} else {
title.value = '移除应用'
actionTitle = '移除应用'
const exists = arrayContains(roles.value, row.key)
if (!exists) {
return
@@ -169,10 +169,10 @@ const updateRole = (row, opt) => {
}
httpPost('/api/app/update', { keys: roles.value })
.then(() => {
showNotify({ type: 'success', message: title.value + '成功!' })
showNotify({ type: 'success', message: actionTitle + '成功!' })
})
.catch((e) => {
showNotify({ type: 'danger', message: title.value + '失败:' + e.message })
showNotify({ type: 'danger', message: actionTitle + '失败:' + e.message })
})
}
@@ -194,15 +194,13 @@ const useRole = (roleId) => {
background-color: var(--van-background);
.apps-filter {
padding: 10px 0;
:deep(.van-tabs__nav) {
background: var(--van-background-2);
}
}
.app-list {
padding: 0 15px;
padding: 0;
.app-cell {
padding: 0;

View File

@@ -75,14 +75,15 @@
</template>
<script setup>
import { router } from '@/router'
import { checkSession } from '@/store/cache'
import { httpGet, httpPost } from '@/utils/http'
import { removeArrayItem, showLoginDialog } from '@/utils/libs'
import { showConfirmDialog, showFailToast, showSuccessToast } from 'vant'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const title = ref('会话列表')
const router = useRouter()
const chatName = ref('')
const chats = ref([])
const allChats = ref([])

View File

@@ -161,7 +161,7 @@ checkSession()
loginUser.value = user
})
.catch(() => {
router.push('/login')
router.push('/mobile/login')
})
const loadModels = () => {

View File

@@ -1,68 +1,287 @@
<template>
<div class="create-center">
<van-nav-bar title="AI 创作中心" fixed :safe-area-inset-top="true">
<template #left>
<div class="nav-left">
<i class="iconfont icon-mj"></i>
</div>
</template>
</van-nav-bar>
<div class="create-content">
<van-tabs
v-model:active="activeTab"
animated
sticky
:offset-top="44"
@change="onTabChange"
<div class="create-content px-3">
<CustomTabs
:model-value="activeTab"
@update:model-value="activeTab = $event"
@tab-click="onTabChange"
>
<van-tab title="MJ绘画" name="mj" v-if="activeMenu.mj">
<CustomTabPane name="mj" label="MJ绘画">
<div class="tab-content">
<image-mj />
</div>
</van-tab>
<van-tab title="SD绘画" name="sd" v-if="activeMenu.sd">
</CustomTabPane>
<CustomTabPane name="sd" label="SD绘画">
<div class="tab-content">
<image-sd />
</div>
</van-tab>
<van-tab title="DALL·E" name="dalle" v-if="activeMenu.dall">
</CustomTabPane>
<CustomTabPane name="dalle" label="DALL·E">
<div class="tab-content">
<image-dall />
</div>
</van-tab>
<van-tab title="音乐创作" name="suno" v-if="activeMenu.suno">
</CustomTabPane>
<CustomTabPane name="suno" label="音乐创作">
<div class="tab-content">
<suno-create />
</div>
</van-tab>
<van-tab title="视频生成" name="video" v-if="activeMenu.video">
</CustomTabPane>
<CustomTabPane name="video" label="视频生成">
<div class="tab-content">
<video-create />
</div>
</van-tab>
<van-tab title="即梦AI" name="jimeng" v-if="activeMenu.jimeng">
</CustomTabPane>
<CustomTabPane name="jimeng" label="即梦AI">
<div class="tab-content">
<jimeng-create />
</div>
</van-tab>
</van-tabs>
</CustomTabPane>
</CustomTabs>
</div>
</div>
</template>
<script setup>
import CustomTabPane from '@/components/ui/CustomTabPane.vue'
import CustomTabs from '@/components/ui/CustomTabs.vue'
import { httpGet } from '@/utils/http'
import ImageDall from '@/views/mobile/pages/ImageDall.vue'
import ImageMj from '@/views/mobile/pages/ImageMj.vue'
import ImageSd from '@/views/mobile/pages/ImageSd.vue'
import { onMounted, ref, watch } from 'vue'
import { Button, Field, Image, showNotify } from 'vant'
import { h, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
// 临时组件,实际项目中需要创建对应的移动端组件
const SunoCreate = { template: '<div class="placeholder">Suno音乐创作功能开发中...</div>' }
const VideoCreate = { template: '<div class="placeholder">视频生成功能开发中...</div>' }
const JimengCreate = { template: '<div class="placeholder">即梦AI功能开发中...</div>' }
// 创建缺失的移动端组件
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,
])
},
}
const route = useRoute()
const router = useRouter()
@@ -78,17 +297,21 @@ const activeMenu = ref({
})
// 监听路由参数变化
watch(() => route.query.tab, (newTab) => {
if (newTab && activeMenu.value[newTab]) {
activeTab.value = newTab
}
}, { immediate: true })
watch(
() => route.query.tab,
(newTab) => {
if (newTab && activeMenu.value[newTab]) {
activeTab.value = newTab
}
},
{ immediate: true }
)
// Tab切换处理
const onTabChange = (name) => {
router.replace({
path: route.path,
query: { ...route.query, tab: name }
router.replace({
path: route.path,
query: { ...route.query, tab: name },
})
}
@@ -97,27 +320,31 @@ onMounted(() => {
})
const fetchMenus = () => {
httpGet('/api/menu/list').then((res) => {
menus.value = res.data
activeMenu.value = {
mj: menus.value.some((item) => item.url === '/mj'),
sd: menus.value.some((item) => item.url === '/sd'),
dall: menus.value.some((item) => item.url === '/dalle'),
suno: menus.value.some((item) => item.url === '/suno'),
video: menus.value.some((item) => item.url === '/video'),
jimeng: menus.value.some((item) => item.url === '/jimeng'),
}
// 如果没有指定tab默认选择第一个可用的
if (!route.query.tab) {
const firstAvailable = Object.keys(activeMenu.value).find(key => activeMenu.value[key])
if (firstAvailable) {
activeTab.value = firstAvailable
httpGet('/api/menu/list')
.then((res) => {
console.log(res)
menus.value = res.data
activeMenu.value = {
mj: menus.value.some((item) => item.url === '/mj'),
sd: menus.value.some((item) => item.url === '/sd'),
dall: menus.value.some((item) => item.url === '/dalle'),
suno: menus.value.some((item) => item.url === '/suno'),
video: menus.value.some((item) => item.url === '/video'),
jimeng: menus.value.some((item) => item.url === '/jimeng'),
}
}
}).catch((e) => {
console.error('获取菜单失败:', e.message)
})
// 如果没有指定tab默认选择第一个可用的
if (!route.query.tab) {
const firstAvailable = Object.keys(activeMenu.value).find((key) => activeMenu.value[key])
if (firstAvailable) {
activeTab.value = firstAvailable
}
}
})
.catch((e) => {
console.error('获取菜单失败:', e.message)
})
}
</script>
@@ -129,7 +356,7 @@ const fetchMenus = () => {
.nav-left {
display: flex;
align-items: center;
.iconfont {
font-size: 20px;
color: var(--van-primary-color);
@@ -137,8 +364,6 @@ const fetchMenus = () => {
}
.create-content {
padding-top: 44px; // nav-bar height
:deep(.van-tabs__nav) {
background: var(--van-background);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
@@ -154,7 +379,7 @@ const fetchMenus = () => {
.tab-content {
min-height: calc(100vh - 88px);
.placeholder {
display: flex;
align-items: center;
@@ -163,7 +388,71 @@ const fetchMenus = () => {
color: var(--van-gray-6);
font-size: 16px;
}
// 新增组件样式
.suno-create,
.video-create,
.jimeng-create {
padding: 20px;
.create-header {
text-align: center;
margin-bottom: 24px;
h3 {
font-size: 20px;
font-weight: 600;
color: var(--van-text-color);
margin: 0 0 8px 0;
}
p {
font-size: 14px;
color: var(--van-gray-6);
margin: 0;
}
}
.create-form {
margin-bottom: 24px;
.van-field {
margin-bottom: 16px;
}
.van-button {
margin-top: 16px;
}
}
.result-area {
background: var(--van-cell-background);
border-radius: 12px;
padding: 16px;
text-align: center;
h4 {
font-size: 16px;
font-weight: 600;
color: var(--van-text-color);
margin: 0 0 12px 0;
}
audio,
video,
.van-image {
width: 100%;
max-width: 300px;
margin-bottom: 12px;
border-radius: 8px;
}
.van-button {
margin-top: 8px;
}
}
}
}
}
}
</style>
</style>

View File

@@ -1,13 +1,5 @@
<template>
<div class="discover-page">
<van-nav-bar title="发现" fixed :safe-area-inset-top="true">
<template #left>
<div class="nav-left">
<i class="iconfont icon-compass"></i>
</div>
</template>
</van-nav-bar>
<div class="discover-content">
<!-- 功能分类 -->
<div class="category-section">
@@ -28,8 +20,8 @@
<div class="category-section">
<h3 class="category-title">我的服务</h3>
<van-cell-group inset>
<van-cell
v-for="service in userServices"
<van-cell
v-for="service in userServices"
:key="service.key"
:title="service.name"
:value="service.desc"
@@ -48,8 +40,8 @@
<div class="category-section">
<h3 class="category-title">实用功能</h3>
<van-cell-group inset>
<van-cell
v-for="utility in utilities"
<van-cell
v-for="utility in utilities"
:key="utility.key"
:title="utility.name"
:value="utility.desc"
@@ -90,8 +82,8 @@
</template>
<script setup>
import { useRouter } from 'vue-router'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
@@ -99,28 +91,114 @@ const router = useRouter()
const aiTools = ref([
{ key: 'mj', name: 'MJ绘画', icon: 'icon-mj', color: '#8B5CF6', url: '/mobile/create?tab=mj' },
{ key: 'sd', name: 'SD绘画', icon: 'icon-sd', color: '#06B6D4', url: '/mobile/create?tab=sd' },
{ key: 'dalle', name: 'DALL·E', icon: 'icon-dalle', color: '#F59E0B', url: '/mobile/create?tab=dalle' },
{ key: 'suno', name: '音乐创作', icon: 'icon-music', color: '#EF4444', url: '/mobile/create?tab=suno' },
{ key: 'video', name: '视频生成', icon: 'icon-video', color: '#10B981', url: '/mobile/create?tab=video' },
{ key: 'jimeng', name: '即梦AI', icon: 'icon-jimeng', color: '#F97316', url: '/mobile/create?tab=jimeng' },
{ key: 'xmind', name: '思维导图', icon: 'icon-mind', color: '#3B82F6', url: '/mobile/tools?tab=xmind' },
{ key: 'apps', name: '应用中心', icon: 'icon-apps', color: '#EC4899', url: '/mobile/apps' }
{
key: 'dalle',
name: 'DALL·E',
icon: 'icon-dalle',
color: '#F59E0B',
url: '/mobile/create?tab=dalle',
},
{
key: 'suno',
name: '音乐创作',
icon: 'icon-music',
color: '#EF4444',
url: '/mobile/create?tab=suno',
},
{
key: 'video',
name: '视频生成',
icon: 'icon-video',
color: '#10B981',
url: '/mobile/create?tab=video',
},
{
key: 'jimeng',
name: '即梦AI',
icon: 'icon-jimeng',
color: '#F97316',
url: '/mobile/create?tab=jimeng',
},
{
key: 'xmind',
name: '思维导图',
icon: 'icon-mind',
color: '#3B82F6',
url: '/mobile/tools?tab=xmind',
},
{ key: 'apps', name: '应用中心', icon: 'icon-apps', color: '#EC4899', url: '/mobile/apps' },
])
// 用户服务
const userServices = ref([
{ key: 'member', name: '会员中心', desc: '充值升级享受更多权益', icon: 'icon-vip', color: '#FFD700', url: '/mobile/member' },
{ key: 'powerLog', name: '消费记录', desc: '查看算力使用详情', icon: 'icon-history', color: '#10B981', url: '/mobile/power-log' },
{ key: 'invite', name: '邀请好友', desc: '推广获取奖励', icon: 'icon-user-plus', color: '#F59E0B', url: '/mobile/invite' },
{ key: 'export', name: '导出对话', desc: '保存聊天记录', icon: 'icon-download', color: '#06B6D4', url: '/mobile/chat/export' }
{
key: 'member',
name: '会员中心',
desc: '充值升级享受更多权益',
icon: 'icon-vip',
color: '#FFD700',
url: '/mobile/member',
},
{
key: 'powerLog',
name: '消费记录',
desc: '查看算力使用详情',
icon: 'icon-history',
color: '#10B981',
url: '/mobile/power-log',
},
{
key: 'invite',
name: '邀请好友',
desc: '推广获取奖励',
icon: 'icon-user-plus',
color: '#F59E0B',
url: '/mobile/invite',
},
{
key: 'export',
name: '导出对话',
desc: '保存聊天记录',
icon: 'icon-download',
color: '#06B6D4',
url: '/mobile/chat/export',
},
])
// 实用功能
const utilities = ref([
{ key: 'imgWall', name: '作品展示', desc: '浏览精美AI作品', icon: 'icon-gallery', color: '#EC4899', url: '/mobile/imgWall' },
{ key: 'settings', name: '设置中心', desc: '个性化配置', icon: 'icon-setting', color: '#6B7280', url: '/mobile/settings' },
{ key: 'help', name: '帮助中心', desc: '使用指南和常见问题', icon: 'icon-help', color: '#8B5CF6', url: '/mobile/help' },
{ key: 'feedback', name: '意见反馈', desc: '提出建议和问题', icon: 'icon-message', color: '#EF4444', url: '/mobile/feedback' }
{
key: 'imgWall',
name: '作品展示',
desc: '浏览精美AI作品',
icon: 'icon-gallery',
color: '#EC4899',
url: '/mobile/imgWall',
},
{
key: 'settings',
name: '设置中心',
desc: '个性化配置',
icon: 'icon-setting',
color: '#6B7280',
url: '/mobile/settings',
},
{
key: 'help',
name: '帮助中心',
desc: '使用指南和常见问题',
icon: 'icon-help',
color: '#8B5CF6',
url: '/mobile/help',
},
{
key: 'feedback',
name: '意见反馈',
desc: '提出建议和问题',
icon: 'icon-message',
color: '#EF4444',
url: '/mobile/feedback',
},
])
// 推荐内容
@@ -130,29 +208,29 @@ const recommendations = ref([
title: '新功能发布',
desc: '体验最新AI创作工具',
image: '/images/recommend/new-features.jpg',
url: '/mobile/news'
url: '/mobile/news',
},
{
key: 'tutorials',
title: '使用教程',
desc: '快速上手AI创作',
image: '/images/recommend/tutorials.jpg',
url: '/mobile/tutorials'
url: '/mobile/tutorials',
},
{
key: 'gallery',
title: '精选作品',
desc: '欣赏优秀AI作品',
image: '/images/recommend/gallery.jpg',
url: '/mobile/imgWall'
url: '/mobile/imgWall',
},
{
key: 'community',
title: '用户社区',
desc: '交流创作心得',
image: '/images/recommend/community.jpg',
url: '/mobile/community'
}
url: '/mobile/community',
},
])
// 导航处理
@@ -173,7 +251,7 @@ const navigateTo = (url) => {
.nav-left {
display: flex;
align-items: center;
.iconfont {
font-size: 20px;
color: var(--van-primary-color);
@@ -181,8 +259,6 @@ const navigateTo = (url) => {
}
.discover-content {
padding: 54px 16px 60px; // nav-bar height + bottom padding
.category-section {
margin-bottom: 24px;
@@ -325,4 +401,4 @@ const navigateTo = (url) => {
}
}
}
</style>
</style>

View File

@@ -1,8 +1,5 @@
<template>
<div class="mobile-feedback">
<!-- 顶部导航 -->
<van-nav-bar title="意见反馈" left-arrow @click-left="router.back()" fixed placeholder />
<!-- 反馈表单 -->
<div class="feedback-content">
<!-- 反馈类型 -->

View File

@@ -1,11 +1,5 @@
<template>
<div class="help-page">
<van-nav-bar title="帮助中心" left-arrow @click-left="router.back()" fixed>
<template #right>
<van-icon name="search" @click="showSearch = true" />
</template>
</van-nav-bar>
<div class="help-content">
<!-- 搜索框 -->
<div class="search-section" v-if="showSearch">
@@ -107,7 +101,12 @@
<span class="online-status">在线</span>
</template>
</van-cell>
<van-cell title="意见反馈" icon="chat-o" is-link @click="router.push('/mobile/feedback')" />
<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>
@@ -160,7 +159,11 @@
</van-dialog>
<!-- 客服聊天 -->
<van-action-sheet v-model:show="showCustomerChat" title="在线客服" :close-on-click-overlay="false">
<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">
@@ -172,7 +175,7 @@
</div>
<van-button size="small" @click="showCustomerChat = false">结束</van-button>
</div>
<div class="chat-messages" ref="chatMessages">
<div
v-for="message in customerMessages"
@@ -184,7 +187,7 @@
<div class="message-time">{{ formatTime(message.time) }}</div>
</div>
</div>
<div class="chat-input">
<van-field
v-model="customerMessage"
@@ -226,28 +229,33 @@ const frequentFAQs = ref([
{
id: 1,
question: '如何获得算力?',
answer: '<p>您可以通过以下方式获得算力:</p><ul><li>注册即送算力</li><li>购买充值套餐</li><li>邀请好友注册</li><li>参与活动获得</li></ul>'
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>'
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>'
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>'
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>'
}
answer:
'<p>导出聊天记录步骤:</p><ol><li>进入对话页面</li><li>点击右上角菜单</li><li>选择"导出记录"</li><li>选择导出格式</li><li>确认导出</li></ol>',
},
])
// 功能指南
@@ -258,7 +266,7 @@ const guides = ref([
desc: '与AI智能对话',
icon: 'icon-chat',
color: '#1989fa',
content: 'AI对话使用指南详细内容...'
content: 'AI对话使用指南详细内容...',
},
{
id: 2,
@@ -266,7 +274,7 @@ const guides = ref([
desc: '生成精美图片',
icon: 'icon-mj',
color: '#8B5CF6',
content: 'AI绘画使用指南详细内容...'
content: 'AI绘画使用指南详细内容...',
},
{
id: 3,
@@ -274,7 +282,7 @@ const guides = ref([
desc: '创作美妙音乐',
icon: 'icon-music',
color: '#ee0a24',
content: 'AI音乐创作指南详细内容...'
content: 'AI音乐创作指南详细内容...',
},
{
id: 4,
@@ -282,8 +290,8 @@ const guides = ref([
desc: '制作精彩视频',
icon: 'icon-video',
color: '#07c160',
content: 'AI视频制作指南详细内容...'
}
content: 'AI视频制作指南详细内容...',
},
])
// 问题分类
@@ -292,7 +300,7 @@ const categories = ref([
{ 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 }
{ id: 5, name: '其他问题', icon: 'icon-help', count: 8 },
])
// 使用提示
@@ -301,20 +309,20 @@ const tips = ref([
id: 1,
title: '提高绘画质量',
content: '使用详细的描述词可以获得更好的绘画效果,建议加入风格、色彩、构图等关键词。',
icon: 'icon-bulb'
icon: 'icon-bulb',
},
{
id: 2,
title: '节省算力',
content: '合理使用不同模型简单问题使用GPT-3.5复杂任务使用GPT-4。',
icon: 'icon-flash'
icon: 'icon-flash',
},
{
id: 3,
title: '快速上手',
content: '查看应用中心的预设角色可以快速体验不同类型的AI对话。',
icon: 'icon-star'
}
icon: 'icon-star',
},
])
onMounted(() => {
@@ -324,8 +332,8 @@ onMounted(() => {
id: 1,
content: '您好欢迎使用我们的AI创作平台有什么可以帮助您的吗',
isUser: false,
time: new Date()
}
time: new Date(),
},
]
})
@@ -338,27 +346,25 @@ const onSearch = (keyword) => {
// 模拟搜索结果
const allContent = [
...frequentFAQs.value.map(faq => ({
...frequentFAQs.value.map((faq) => ({
id: faq.id,
title: faq.question,
content: faq.answer,
type: 'faq'
type: 'faq',
})),
...guides.value.map(guide => ({
...guides.value.map((guide) => ({
id: guide.id,
title: guide.title,
content: guide.content,
type: 'guide'
}))
type: 'guide',
})),
]
searchResults.value = allContent
.filter(item =>
item.title.includes(keyword) || item.content.includes(keyword)
)
.map(item => ({
.filter((item) => item.title.includes(keyword) || item.content.includes(keyword))
.map((item) => ({
...item,
snippet: getSearchSnippet(item.content, keyword)
snippet: getSearchSnippet(item.content, keyword),
}))
}
@@ -367,15 +373,15 @@ 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 ? '...' : '')
}
@@ -383,7 +389,7 @@ const getSearchSnippet = (content, keyword) => {
const openGuide = (guide) => {
selectedHelp.value = {
title: guide.title,
content: guide.content || '<p>该指南内容正在完善中,敬请期待。</p>'
content: guide.content || '<p>该指南内容正在完善中,敬请期待。</p>',
}
showHelpDetail.value = true
}
@@ -398,7 +404,7 @@ const openCategory = (category) => {
const openSearchResult = (result) => {
selectedHelp.value = {
title: result.title,
content: result.content
content: result.content,
}
showHelpDetail.value = true
}
@@ -414,7 +420,7 @@ const shareHelp = (help) => {
navigator.share({
title: help.title,
text: help.content.replace(/<[^>]*>/g, ''),
url: window.location.href
url: window.location.href,
})
} else {
showNotify({ type: 'primary', message: '该功能暂不支持' })
@@ -435,7 +441,7 @@ const sendCustomerMessage = () => {
id: Date.now(),
content: customerMessage.value,
isUser: true,
time: new Date()
time: new Date(),
})
const userMessage = customerMessage.value
@@ -451,7 +457,7 @@ const sendCustomerMessage = () => {
// 模拟客服回复
setTimeout(() => {
let reply = '感谢您的问题,我们会尽快为您处理。'
if (userMessage.includes('算力')) {
reply = '关于算力问题,您可以在会员中心购买算力套餐,或者通过邀请好友获得免费算力。'
} else if (userMessage.includes('绘画')) {
@@ -464,7 +470,7 @@ const sendCustomerMessage = () => {
id: Date.now(),
content: reply,
isUser: false,
time: new Date()
time: new Date(),
})
nextTick(() => {
@@ -478,9 +484,10 @@ const sendCustomerMessage = () => {
// 加入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'
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)
@@ -488,9 +495,9 @@ const joinQQGroup = () => {
// 格式化时间
const formatTime = (time) => {
return time.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
return time.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})
}
@@ -504,52 +511,53 @@ const onQRError = (e) => {
.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) {
:deep(ul),
:deep(ol) {
padding-left: 20px;
margin: 8px 0;
}
:deep(li) {
margin: 4px 0;
}
:deep(p) {
margin: 8px 0;
}
@@ -557,10 +565,10 @@ const onQRError = (e) => {
}
}
}
.guide-section {
margin-bottom: 24px;
.guide-item {
.guide-card {
background: var(--van-cell-background);
@@ -570,11 +578,11 @@ const onQRError = (e) => {
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;
@@ -583,20 +591,20 @@ const onQRError = (e) => {
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);
@@ -604,35 +612,35 @@ const onQRError = (e) => {
}
}
}
.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;
@@ -640,14 +648,14 @@ const onQRError = (e) => {
}
}
}
.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;
@@ -656,15 +664,15 @@ const onQRError = (e) => {
}
}
}
.tips-section {
.tips-swipe {
height: 140px;
border-radius: 12px;
overflow: hidden;
.tip-card {
background: linear-gradient(135deg, var(--van-primary-color), #8B5CF6);
background: linear-gradient(135deg, var(--van-primary-color), #8b5cf6);
color: white;
padding: 20px;
text-align: center;
@@ -672,22 +680,22 @@ const onQRError = (e) => {
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;
@@ -698,41 +706,42 @@ const onQRError = (e) => {
}
}
}
.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) {
: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;
@@ -740,49 +749,49 @@ const onQRError = (e) => {
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;
}
@@ -790,24 +799,24 @@ const onQRError = (e) => {
}
}
}
.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%;
@@ -817,7 +826,7 @@ const onQRError = (e) => {
font-size: 14px;
line-height: 1.4;
}
.message-time {
font-size: 11px;
color: var(--van-gray-5);
@@ -825,7 +834,7 @@ const onQRError = (e) => {
}
}
}
.chat-input {
padding: 16px;
border-top: 1px solid var(--van-border-color);
@@ -843,4 +852,4 @@ const onQRError = (e) => {
}
}
}
</style>
</style>

View File

@@ -1,7 +1,13 @@
<template>
<van-config-provider :theme="theme">
<div class="mobile-home">
<router-view />
<div class="page-content">
<router-view :key="routerViewKey" v-slot="{ Component }">
<transition name="move" mode="out-in">
<component :is="Component"></component>
</transition>
</router-view>
</div>
<van-tabbar route v-model="active" :safe-area-inset-bottom="true">
<van-tabbar-item to="/mobile/index" name="home" icon="home-o">
@@ -41,11 +47,23 @@
<script setup>
import { useSharedStore } from '@/store/sharedata'
import { ref, watch } from 'vue'
import { onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const active = ref('home')
const store = useSharedStore()
const theme = ref(store.theme)
const route = useRoute()
const router = useRouter()
const routerViewKey = ref(0)
// 监听路由变化,强制刷新组件
watch(
() => route.path,
() => {
routerViewKey.value += 1
}
)
watch(
() => store.theme,
@@ -53,30 +71,36 @@ watch(
theme.value = val
}
)
// 路由守卫
router.beforeEach((to, from, next) => {
// 可以在这里添加路由权限检查等逻辑
next()
})
onMounted(() => {
// 组件挂载时的初始化逻辑
})
</script>
<style lang="scss">
@use '../../assets/iconfont/iconfont.css' as *;
.mobile-home {
.container {
.van-nav-bar {
position: fixed;
width: 100%;
}
padding: 0 10px;
.page-content {
padding-bottom: 60px;
}
.van-tabbar {
box-shadow: 0 -2px 20px rgba(0, 0, 0, 0.1);
.van-tabbar-item {
.active-icon {
color: var(--van-primary-color) !important;
transform: scale(1.1);
transition: all 0.3s ease;
}
&--active {
.van-tabbar-item__text {
color: var(--van-primary-color);
@@ -84,7 +108,7 @@ watch(
}
}
}
.iconfont {
font-size: 20px;
transition: all 0.3s ease;
@@ -97,8 +121,25 @@ watch(
background: #1c1c1e;
}
.van-nav-bar {
position: fixed;
width: 100%;
// 路由切换动画
.move-enter-active,
.move-leave-active {
transition: all 0.3s ease;
}
.move-enter-from {
opacity: 0;
transform: translateX(100%);
}
.move-leave-to {
opacity: 0;
transform: translateX(-100%);
}
.move-enter-to,
.move-leave-from {
opacity: 1;
transform: translateX(0);
}
</style>

View File

@@ -1,20 +1,22 @@
<template>
<div class="mobile-image container">
<van-tabs v-model:active="activeName" class="my-tab" animated sticky>
<van-tab title="MJ" name="mj" v-if="activeMenu.mj">
<CustomTabs :model-value="activeName" @update:model-value="activeName = $event" class="my-tab">
<CustomTabPane name="mj" label="MJ" v-if="activeMenu.mj">
<image-mj />
</van-tab>
<van-tab title="SD" name="sd" v-if="activeMenu.sd">
</CustomTabPane>
<CustomTabPane name="sd" label="SD" v-if="activeMenu.sd">
<image-sd />
</van-tab>
<van-tab title="DALL" name="dall" v-if="activeMenu.dall">
</CustomTabPane>
<CustomTabPane name="dall" label="DALL" v-if="activeMenu.dall">
<image-dall />
</van-tab>
</van-tabs>
</CustomTabPane>
</CustomTabs>
</div>
</template>
<script setup>
import CustomTabPane from '@/components/ui/CustomTabPane.vue'
import CustomTabs from '@/components/ui/CustomTabs.vue'
import { httpGet } from '@/utils/http'
import ImageDall from '@/views/mobile/pages/ImageDall.vue'
import ImageMj from '@/views/mobile/pages/ImageMj.vue'

View File

@@ -1,7 +1,7 @@
<template>
<div class="index container">
<div class="index">
<div class="header">
<div class="user-greeting">
<div class="user-greeting px-3">
<div class="greeting-text">
<h2 class="title">{{ getGreeting() }}</h2>
<p class="subtitle">{{ title }}</p>
@@ -9,14 +9,14 @@
<div class="user-avatar" v-if="isLogin" @click="router.push('profile')">
<van-image :src="userAvatar" round width="40" height="40" />
</div>
<div class="login-btn" v-else @click="showLoginDialog(router)">
<div class="login-btn" v-else @click="router.push('login')">
<van-button size="small" type="primary" round>登录</van-button>
</div>
</div>
</div>
<!-- 快捷操作区 -->
<div class="quick-actions mb-6">
<div class="quick-actions mb-6 px-3">
<van-row :gutter="12">
<van-col :span="12">
<div class="action-card primary" @click="router.push('chat')">
@@ -24,7 +24,7 @@
<i class="iconfont icon-chat action-icon"></i>
<div class="action-text">
<div class="action-title">AI 对话</div>
<div class="action-desc">智能助手随时待命</div>
<div class="action-desc">智能助手对话</div>
</div>
</div>
</div>
@@ -44,11 +44,11 @@
</div>
<!-- 功能网格 -->
<div class="feature-section mb-6">
<div class="feature-section mb-6 px-3">
<div class="section-header">
<h3 class="section-title">AI 功能</h3>
</div>
<van-grid :column-num="4" :gutter="12" :border="false">
<van-grid :column-num="4" :border="false">
<van-grid-item
v-for="feature in features"
:key="feature.key"
@@ -68,12 +68,12 @@
</div>
<!-- 推荐应用 -->
<div class="apps-section">
<div class="apps-section px-3">
<div class="section-header">
<h3 class="section-title">推荐应用</h3>
<van-button
class="more-btn"
size="small"
<van-button
class="more-btn"
size="small"
icon="arrow"
type="primary"
plain
@@ -86,14 +86,9 @@
<div class="app-list">
<van-swipe :autoplay="3000" :show-indicators="false" class="app-swipe">
<van-swipe-item v-for="chunk in appChunks" :key="chunk[0]?.id">
<div class="app-row">
<div
v-for="item in chunk"
:key="item.id"
class="app-item"
@click="useRole(item.id)"
>
<van-swipe-item v-for="chunk in appChunks" :key="chunk[0] && chunk[0].id">
<div class="app-row px-3">
<div v-for="item in chunk" :key="item.id" class="app-item" @click="useRole(item.id)">
<div class="app-avatar">
<van-image :src="item.icon" round fit="cover" />
</div>
@@ -102,15 +97,17 @@
<div class="app-desc">{{ item.intro }}</div>
</div>
<div class="app-action">
<van-button
size="mini"
type="primary"
plain
<!-- <van-button
size="mini"
type="primary"
plain
round
@click.stop="updateRole(item, hasRole(item.key) ? 'remove' : 'add')"
>
{{ hasRole(item.key) ? '已添加' : '添加' }}
</van-button>
</van-button> -->
<van-button size="small" type="primary" round> 开始对话 </van-button>
</div>
</div>
</div>
@@ -118,33 +115,6 @@
</van-swipe>
</div>
</div>
<!-- 数据统计 -->
<div class="stats-section" v-if="isLogin">
<div class="section-header">
<h3 class="section-title">使用统计</h3>
</div>
<van-row :gutter="12">
<van-col :span="8">
<div class="stat-card">
<div class="stat-number">{{ userStats.conversations || 0 }}</div>
<div class="stat-label">对话次数</div>
</div>
</van-col>
<van-col :span="8">
<div class="stat-card">
<div class="stat-number">{{ userStats.images || 0 }}</div>
<div class="stat-label">生成图片</div>
</div>
</van-col>
<van-col :span="8">
<div class="stat-card">
<div class="stat-number">{{ userStats.power || 0 }}</div>
<div class="stat-label">剩余算力</div>
</div>
</van-col>
</van-row>
</div>
</div>
</template>
@@ -164,30 +134,61 @@ const apps = ref([])
const loading = ref(false)
const roles = ref([])
const userAvatar = ref('/images/avatar/default.jpg')
const userStats = ref({
conversations: 0,
images: 0,
power: 0
})
// 功能配置
const features = ref([
{ key: 'mj', name: 'MJ绘画', icon: 'icon-mj', color: '#8B5CF6', url: '/mobile/create?tab=mj' },
{ key: 'sd', name: 'SD绘画', icon: 'icon-sd', color: '#06B6D4', url: '/mobile/create?tab=sd' },
{ key: 'dalle', name: 'DALL·E', icon: 'icon-dalle', color: '#F59E0B', url: '/mobile/create?tab=dalle' },
{ key: 'suno', name: '音乐创作', icon: 'icon-music', color: '#EF4444', url: '/mobile/create?tab=suno' },
{ key: 'video', name: '视频生成', icon: 'icon-video', color: '#10B981', url: '/mobile/create?tab=video' },
{ key: 'jimeng', name: '即梦AI', icon: 'icon-jimeng', color: '#F97316', url: '/mobile/create?tab=jimeng' },
{ key: 'xmind', name: '思维导图', icon: 'icon-mind', color: '#3B82F6', url: '/mobile/tools?tab=xmind' },
{ key: 'imgWall', name: '作品展示', icon: 'icon-gallery', color: '#EC4899', url: '/mobile/imgWall' }
{
key: 'dalle',
name: 'DALL·E',
icon: 'icon-dalle',
color: '#F59E0B',
url: '/mobile/create?tab=dalle',
},
{
key: 'suno',
name: '音乐创作',
icon: 'icon-music',
color: '#EF4444',
url: '/mobile/create?tab=suno',
},
{
key: 'video',
name: '视频生成',
icon: 'icon-video',
color: '#10B981',
url: '/mobile/create?tab=video',
},
{
key: 'jimeng',
name: '即梦AI',
icon: 'icon-jimeng',
color: '#F97316',
url: '/mobile/create?tab=jimeng',
},
{
key: 'xmind',
name: '思维导图',
icon: 'icon-mind',
color: '#3B82F6',
url: '/mobile/tools?tab=xmind',
},
{
key: 'imgWall',
name: '作品展示',
icon: 'icon-gallery',
color: '#EC4899',
url: '/mobile/imgWall',
},
])
// 应用分组显示每行2个
const appChunks = computed(() => {
const chunks = []
const displayApps = apps.value.slice(0, 6) // 只显示前6个
for (let i = 0; i < displayApps.length; i += 2) {
chunks.push(displayApps.slice(i, i + 2))
const displayApps = apps.value.slice(0, 12) // 只显示前6个
for (let i = 0; i < displayApps.length; i += 4) {
chunks.push(displayApps.slice(i, i + 4))
}
return chunks
})
@@ -220,11 +221,9 @@ onMounted(() => {
isLogin.value = true
roles.value = user.chat_roles
userAvatar.value = user.avatar || '/images/avatar/default.jpg'
// 获取用户统计数据
fetchUserStats()
})
.catch(() => {})
fetchApps()
})
@@ -243,37 +242,21 @@ const fetchApps = () => {
})
}
const fetchUserStats = () => {
if (!isLogin.value) return
// 这里可以调用实际的统计接口
// httpGet('/api/user/stats').then(res => {
// userStats.value = res.data
// })
// 临时使用模拟数据
userStats.value = {
conversations: Math.floor(Math.random() * 100),
images: Math.floor(Math.random() * 50),
power: Math.floor(Math.random() * 1000)
}
}
const updateRole = (row, opt) => {
if (!isLogin.value) {
return showLoginDialog(router)
}
const title = ref('')
let actionTitle = ''
if (opt === 'add') {
title.value = '添加应用'
actionTitle = '添加应用'
const exists = arrayContains(roles.value, row.key)
if (exists) {
return
}
roles.value.push(row.key)
} else {
title.value = '移除应用'
actionTitle = '移除应用'
const exists = arrayContains(roles.value, row.key)
if (!exists) {
return
@@ -282,10 +265,10 @@ const updateRole = (row, opt) => {
}
httpPost('/api/app/update', { keys: roles.value })
.then(() => {
showNotify({ type: 'success', message: title.value + '成功!', duration: 1000 })
showNotify({ type: 'success', message: actionTitle + '成功!', duration: 1000 })
})
.catch((e) => {
showNotify({ type: 'danger', message: title.value + '失败:' + e.message })
showNotify({ type: 'danger', message: actionTitle + '失败:' + e.message })
})
}
@@ -306,8 +289,7 @@ const useRole = (roleId) => {
color: var(--van-text-color);
background: linear-gradient(135deg, var(--van-background), var(--van-background-2));
min-height: 100vh;
padding: 0 16px 60px;
padding: 0;
.header {
padding: 20px 0 16px;
position: sticky;
@@ -329,7 +311,7 @@ const useRole = (roleId) => {
font-weight: 700;
color: var(--van-text-color);
margin: 0 0 4px 0;
background: linear-gradient(135deg, var(--van-primary-color), #8B5CF6);
background: linear-gradient(135deg, var(--van-primary-color), #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@@ -364,7 +346,7 @@ const useRole = (roleId) => {
}
&.primary {
background: linear-gradient(135deg, var(--van-primary-color), #8B5CF6);
background: linear-gradient(135deg, var(--van-primary-color), #8b5cf6);
color: white;
.action-icon,
@@ -375,7 +357,7 @@ const useRole = (roleId) => {
}
&.secondary {
background: linear-gradient(135deg, #06B6D4, #10B981);
background: linear-gradient(135deg, #06b6d4, #10b981);
color: white;
.action-icon,
@@ -412,8 +394,7 @@ const useRole = (roleId) => {
}
.feature-section,
.apps-section,
.stats-section {
.apps-section {
.section-header {
display: flex;
justify-content: space-between;
@@ -535,28 +516,6 @@ const useRole = (roleId) => {
}
}
}
.stats-section {
.stat-card {
background: var(--van-cell-background);
border-radius: 12px;
padding: 16px;
text-align: center;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
.stat-number {
font-size: 24px;
font-weight: 700;
color: var(--van-primary-color);
margin-bottom: 4px;
}
.stat-label {
font-size: 12px;
color: var(--van-gray-6);
}
}
}
}
// 响应式调整
@@ -593,8 +552,7 @@ const useRole = (roleId) => {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.apps-section .app-swipe .app-row .app-item,
.stats-section .stat-card {
.apps-section .app-swipe .app-row .app-item {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
}
}

View File

@@ -1,7 +1,5 @@
<template>
<div class="invite-page">
<van-nav-bar title="邀请好友" left-arrow @click-left="router.back()" fixed />
<div class="invite-content">
<!-- 邀请头图 -->
<div class="invite-header">
@@ -100,11 +98,7 @@
</div>
<div class="code-value">{{ inviteCode }}</div>
<div class="code-link">
<van-field
v-model="inviteLink"
readonly
placeholder="邀请链接"
>
<van-field v-model="inviteLink" readonly placeholder="邀请链接">
<template #button>
<van-button size="small" type="primary" @click="copyInviteLink">
复制链接
@@ -123,7 +117,7 @@
{{ showAllRecords ? '收起' : '查看全部' }}
</van-button>
</div>
<div class="records-list">
<van-list
v-model:loading="recordsLoading"
@@ -131,11 +125,7 @@
finished-text="没有更多记录"
@load="loadInviteRecords"
>
<div
v-for="record in displayRecords"
:key="record.id"
class="record-item"
>
<div v-for="record in displayRecords" :key="record.id" class="record-item">
<div class="record-avatar">
<van-image :src="record.avatar" round width="40" height="40" />
</div>
@@ -149,8 +139,11 @@
</van-tag>
</div>
</div>
<van-empty v-if="!recordsLoading && inviteRecords.length === 0" description="暂无邀请记录" />
<van-empty
v-if="!recordsLoading && inviteRecords.length === 0"
description="暂无邀请记录"
/>
</van-list>
</div>
</div>
@@ -180,9 +173,8 @@
<script setup>
import { checkSession } from '@/store/cache'
import { httpGet } from '@/utils/http'
import { showLoginDialog } from '@/utils/libs'
import { showFailToast, showNotify, showSuccessToast } from 'vant'
import { showNotify, showSuccessToast } from 'vant'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -190,7 +182,7 @@ const router = useRouter()
const userStats = ref({
inviteCount: 0,
rewardTotal: 0,
todayInvite: 0
todayInvite: 0,
})
const inviteCode = ref('')
const inviteLink = ref('')
@@ -209,7 +201,7 @@ const rewardRules = ref([
desc: '好友通过邀请链接成功注册',
icon: 'icon-user-plus',
color: '#1989fa',
reward: 50
reward: 50,
},
{
id: 2,
@@ -217,7 +209,7 @@ const rewardRules = ref([
desc: '好友首次充值任意金额',
icon: 'icon-money',
color: '#07c160',
reward: 100
reward: 100,
},
{
id: 3,
@@ -225,8 +217,8 @@ const rewardRules = ref([
desc: '好友连续使用7天',
icon: 'icon-star',
color: '#ff9500',
reward: 200
}
reward: 200,
},
])
// 显示的记录根据showAllRecords决定
@@ -241,17 +233,16 @@ onMounted(() => {
const initPage = async () => {
try {
const user = await checkSession()
// 生成邀请码和链接
inviteCode.value = user.invite_code || generateInviteCode()
inviteLink.value = `${location.origin}/register?invite=${inviteCode.value}`
// 获取用户邀请统计
fetchInviteStats()
// 加载邀请记录
loadInviteRecords()
} catch (error) {
showLoginDialog(router)
}
@@ -266,27 +257,27 @@ const fetchInviteStats = () => {
// 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)
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
@@ -297,17 +288,17 @@ const loadInviteRecords = () => {
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()
created_at: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString(),
})
}
return records
}
@@ -324,7 +315,7 @@ const shareToWeChat = () => {
title: '邀请你使用AI创作平台',
desc: '强大的AI工具让创作更简单',
link: inviteLink.value,
imgUrl: `${location.origin}/images/share-logo.png`
imgUrl: `${location.origin}/images/share-logo.png`,
})
} else {
// 复制链接提示
@@ -383,7 +374,7 @@ const shareToFriends = () => {
navigator.share({
title: '邀请你使用AI创作平台',
text: '强大的AI工具让创作更简单',
url: inviteLink.value
url: inviteLink.value,
})
} else {
copyInviteLink()
@@ -400,16 +391,16 @@ const onImageError = (e) => {
.invite-page {
min-height: 100vh;
background: var(--van-background);
.invite-content {
padding-top: 46px;
.invite-header {
position: relative;
height: 200px;
overflow: hidden;
background: linear-gradient(135deg, var(--van-primary-color), #8B5CF6);
background: linear-gradient(135deg, var(--van-primary-color), #8b5cf6);
.header-bg {
position: absolute;
top: 0;
@@ -417,14 +408,14 @@ const onImageError = (e) => {
right: 0;
bottom: 0;
opacity: 0.3;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.header-content {
position: relative;
z-index: 2;
@@ -435,13 +426,13 @@ const onImageError = (e) => {
height: 100%;
color: white;
text-align: center;
.invite-title {
font-size: 24px;
font-weight: 700;
margin: 0 0 8px 0;
}
.invite-desc {
font-size: 14px;
opacity: 0.9;
@@ -449,40 +440,40 @@ const onImageError = (e) => {
}
}
}
.stats-section {
padding: 16px;
margin-top: -20px;
position: relative;
z-index: 3;
.stat-card {
background: var(--van-cell-background);
border-radius: 12px;
padding: 16px;
text-align: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
.stat-number {
font-size: 20px;
font-weight: 700;
color: var(--van-primary-color);
margin-bottom: 4px;
}
.stat-label {
font-size: 12px;
color: var(--van-gray-6);
}
}
}
.rules-section,
.invite-methods,
.invite-code-section,
.invite-records {
padding: 0 16px 16px;
.section-title {
font-size: 18px;
font-weight: 600;
@@ -490,7 +481,7 @@ const onImageError = (e) => {
margin: 0 0 16px 0;
}
}
.rules-list {
.rule-item {
display: flex;
@@ -500,7 +491,7 @@ const onImageError = (e) => {
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.rule-icon {
width: 40px;
height: 40px;
@@ -510,37 +501,37 @@ const onImageError = (e) => {
align-items: center;
justify-content: center;
margin-right: 12px;
.iconfont {
font-size: 20px;
}
}
.rule-content {
flex: 1;
.rule-title {
font-size: 15px;
font-weight: 600;
color: var(--van-text-color);
margin-bottom: 4px;
}
.rule-desc {
font-size: 13px;
color: var(--van-gray-6);
}
}
.rule-reward {
text-align: right;
.reward-value {
font-size: 16px;
font-weight: 600;
color: #07c160;
}
.reward-unit {
font-size: 12px;
color: var(--van-gray-6);
@@ -549,12 +540,12 @@ const onImageError = (e) => {
}
}
}
.methods-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
.method-item {
display: flex;
flex-direction: column;
@@ -565,11 +556,11 @@ const onImageError = (e) => {
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;
@@ -578,29 +569,29 @@ const onImageError = (e) => {
align-items: center;
justify-content: center;
margin-bottom: 8px;
&.wechat {
background: #07c160;
}
&.link {
background: #1989fa;
}
&.qr {
background: #8B5CF6;
background: #8b5cf6;
}
&.more {
background: #ff9500;
}
.iconfont {
font-size: 20px;
color: white;
}
}
.method-name {
font-size: 12px;
color: var(--van-text-color);
@@ -608,27 +599,27 @@ const onImageError = (e) => {
}
}
}
.invite-code-section {
.code-card {
background: var(--van-cell-background);
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.code-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.code-label {
font-size: 16px;
font-weight: 600;
color: var(--van-text-color);
}
}
.code-value {
font-size: 24px;
font-weight: 700;
@@ -642,7 +633,7 @@ const onImageError = (e) => {
}
}
}
.invite-records {
.records-header {
display: flex;
@@ -650,7 +641,7 @@ const onImageError = (e) => {
align-items: center;
margin-bottom: 16px;
}
.records-list {
.record-item {
display: flex;
@@ -660,21 +651,21 @@ const onImageError = (e) => {
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.record-avatar {
margin-right: 12px;
}
.record-info {
flex: 1;
.record-name {
font-size: 15px;
font-weight: 500;
color: var(--van-text-color);
margin-bottom: 4px;
}
.record-time {
font-size: 12px;
color: var(--van-gray-6);
@@ -684,11 +675,11 @@ const onImageError = (e) => {
}
}
}
.qr-content {
text-align: center;
padding: 20px;
.qr-code {
width: 200px;
height: 200px;
@@ -699,23 +690,23 @@ const onImageError = (e) => {
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);
@@ -736,4 +727,4 @@ const onImageError = (e) => {
}
}
}
</style>
</style>

View File

@@ -1,5 +1,13 @@
<template>
<div class="login flex w-full flex-col place-content-center h-lvh">
<!-- 返回首页链接 -->
<div class="back-home">
<el-button @click="goHome" type="text" class="back-btn">
<i class="iconfont icon-home"></i>
返回首页
</el-button>
</div>
<el-image :src="logo" class="w-1/2 mx-auto logo" />
<div class="title text-center text-3xl font-bold mt-8">{{ title }}</div>
<div class="w-full p-8">
@@ -22,6 +30,10 @@ const loginSuccess = () => {
router.back()
}
const goHome = () => {
router.push('/mobile/index')
}
onMounted(() => {
getSystemInfo().then((res) => {
title.value = res.data.title
@@ -34,6 +46,23 @@ onMounted(() => {
.login {
background: var(--theme-bg);
transition: all 0.3s ease;
position: relative;
.back-home {
position: absolute;
top: 20px;
left: 20px;
z-index: 10;
.back-btn {
color: var(--text-theme-color);
font-size: 14px;
.iconfont {
margin-right: 4px;
}
}
}
.logo {
background: #ffffff;

View File

@@ -1,11 +1,5 @@
<template>
<div class="member-center">
<van-nav-bar title="会员中心" left-arrow @click-left="router.back()" fixed>
<template #right>
<van-icon name="question-o" @click="showHelp = true" />
</template>
</van-nav-bar>
<div class="member-content">
<!-- 用户信息卡片 -->
<div class="user-card" v-if="isLogin">

View File

@@ -1,11 +1,5 @@
<template>
<div class="power-log">
<van-nav-bar title="算力日志" left-arrow @click-left="router.back()" fixed>
<template #right>
<van-icon name="filter-o" @click="showFilter = true" />
</template>
</van-nav-bar>
<div class="power-content">
<!-- 统计概览 -->
<div class="stats-overview">
@@ -35,20 +29,29 @@
<!-- 筛选栏 -->
<div class="filter-bar">
<van-tabs v-model:active="activeType" @change="onTypeChange" shrink>
<van-tab title="全部" name="all" />
<van-tab title="对话" name="chat" />
<van-tab title="绘画" name="image" />
<van-tab title="音乐" name="music" />
<van-tab title="视频" name="video" />
</van-tabs>
<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="log-list">
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-pull-refresh
:model-value="refreshing"
@update:model-value="refreshing = $event"
@refresh="onRefresh"
>
<van-list
v-model:loading="loading"
:model-value="loading"
@update:model-value="loading = $event"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
@@ -80,13 +83,21 @@
</div>
<!-- 筛选弹窗 -->
<van-action-sheet v-model:show="showFilter" title="筛选条件">
<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}` : '选择时间' }}
{{
dateRange.start && dateRange.end
? `${dateRange.start} ${dateRange.end}`
: '选择时间'
}}
</van-button>
</template>
</van-field>
@@ -110,7 +121,8 @@
<!-- 日期选择器 -->
<van-calendar
v-model:show="showDatePicker"
:model-value="showDatePicker"
@update:model-value="showDatePicker = $event"
type="range"
@confirm="onDateConfirm"
:max-date="new Date()"
@@ -119,8 +131,8 @@
</template>
<script setup>
import { httpGet } from '@/utils/http'
import { showFailToast } from 'vant'
import CustomTabPane from '@/components/ui/CustomTabPane.vue'
import CustomTabs from '@/components/ui/CustomTabs.vue'
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -135,21 +147,21 @@ const showDatePicker = ref(false)
const filterType = ref('all')
const dateRange = ref({
start: '',
end: ''
end: '',
})
// 统计数据
const stats = ref({
total: 0,
today: 0,
balance: 0
balance: 0,
})
// 分页参数
const pageParams = ref({
page: 1,
limit: 20,
type: 'all'
type: 'all',
})
onMounted(() => {
@@ -163,12 +175,12 @@ const fetchStats = () => {
// 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)
balance: Math.floor(Math.random() * 1000),
}
}
@@ -177,20 +189,20 @@ 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
@@ -203,7 +215,7 @@ const onRefresh = () => {
finished.value = false
pageParams.value.page = 1
refreshing.value = true
setTimeout(() => {
logList.value = generateMockData(1, pageParams.value.limit)
refreshing.value = false
@@ -227,32 +239,32 @@ const generateMockData = (page, limit) => {
chat: ['GPT-4对话', 'Claude对话', '智能助手'],
image: ['MidJourney生成', 'Stable Diffusion', 'DALL-E创作'],
music: ['Suno音乐创作', '音频生成'],
video: ['视频生成', 'Luma创作']
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()
created_at: new Date(Date.now() - Math.random() * 7 * 24 * 60 * 60 * 1000).toISOString(),
})
}
return data
}
@@ -262,7 +274,7 @@ const getTypeIcon = (type) => {
chat: 'icon-chat',
image: 'icon-mj',
music: 'icon-music',
video: 'icon-video'
video: 'icon-video',
}
return icons[type] || 'icon-chat'
}
@@ -273,7 +285,7 @@ const getTypeColor = (type) => {
chat: '#1989fa',
image: '#8B5CF6',
music: '#ee0a24',
video: '#07c160'
video: '#07c160',
}
return colors[type] || '#1989fa'
}
@@ -283,12 +295,15 @@ const formatTime = (timeStr) => {
const date = new Date(timeStr)
const now = new Date()
const diff = now - date
if (diff < 60000) { // 1分钟内
if (diff < 60000) {
// 1分钟内
return '刚刚'
} else if (diff < 3600000) { // 1小时内
} else if (diff < 3600000) {
// 1小时内
return `${Math.floor(diff / 60000)}分钟前`
} else if (diff < 86400000) { // 24小时内
} else if (diff < 86400000) {
// 24小时内
return `${Math.floor(diff / 3600000)}小时前`
} else {
return date.toLocaleDateString()
@@ -300,7 +315,7 @@ const onDateConfirm = (values) => {
const [start, end] = values
dateRange.value = {
start: start.toLocaleDateString(),
end: end.toLocaleDateString()
end: end.toLocaleDateString(),
}
showDatePicker.value = false
}
@@ -333,7 +348,7 @@ const applyFilter = () => {
.stats-overview {
padding: 16px;
background: linear-gradient(135deg, var(--van-primary-color), #8B5CF6);
background: linear-gradient(135deg, var(--van-primary-color), #8b5cf6);
.stats-card {
background: rgba(255, 255, 255, 0.1);
@@ -476,4 +491,4 @@ const applyFilter = () => {
}
}
}
</style>
</style>

View File

@@ -104,40 +104,40 @@
<div class="menu-section">
<h3 class="section-title">我的服务</h3>
<van-cell-group inset>
<van-cell
title="消费记录"
icon="notes-o"
is-link
<van-cell
title="消费记录"
icon="notes-o"
is-link
@click="router.push('/mobile/power-log')"
>
<template #icon>
<i class="iconfont icon-history menu-icon"></i>
</template>
</van-cell>
<van-cell
title="会员中心"
icon="diamond-o"
is-link
<van-cell
title="会员中心"
icon="diamond-o"
is-link
@click="router.push('/mobile/member')"
>
<template #icon>
<i class="iconfont icon-vip menu-icon"></i>
</template>
</van-cell>
<van-cell
title="邀请好友"
icon="friends-o"
is-link
<van-cell
title="邀请好友"
icon="friends-o"
is-link
@click="router.push('/mobile/invite')"
>
<template #icon>
<i class="iconfont icon-user-plus menu-icon"></i>
</template>
</van-cell>
<van-cell
title="聊天导出"
icon="down"
is-link
<van-cell
title="聊天导出"
icon="down"
is-link
@click="router.push('/mobile/chat/export')"
>
<template #icon>
@@ -147,32 +147,17 @@
</van-cell-group>
<van-cell-group inset>
<van-cell
title="帮助中心"
icon="question-o"
is-link
@click="router.push('/mobile/help')"
>
<van-cell title="帮助中心" icon="question-o" is-link @click="router.push('/mobile/help')">
<template #icon>
<i class="iconfont icon-help menu-icon"></i>
</template>
</van-cell>
<van-cell
title="意见反馈"
icon="chat-o"
is-link
@click="router.push('/mobile/feedback')"
>
<van-cell title="意见反馈" icon="chat-o" is-link @click="router.push('/mobile/feedback')">
<template #icon>
<i class="iconfont icon-message menu-icon"></i>
</template>
</van-cell>
<van-cell
title="关于我们"
icon="info-o"
is-link
@click="showAbout = true"
>
<van-cell title="关于我们" icon="info-o" is-link @click="showAbout = true">
<template #icon>
<i class="iconfont icon-info menu-icon"></i>
</template>
@@ -182,13 +167,7 @@
<!-- 退出登录 -->
<div class="logout-section" v-if="isLogin">
<van-button
size="large"
block
type="danger"
plain
@click="showLogoutConfirm = true"
>
<van-button size="large" block type="danger" plain @click="showLogoutConfirm = true">
退出登录
</van-button>
</div>
@@ -198,11 +177,15 @@
<p class="app-version">版本 v{{ appVersion }}</p>
<p class="copyright">© 2024 {{ title }}. All rights reserved.</p>
</div>
<!-- 底部安全间距 -->
<div class="bottom-safe-area"></div>
</div>
<!-- 修改密码弹窗 -->
<van-dialog
v-model:show="showPasswordDialog"
:model-value="showPasswordDialog"
@update:model-value="showPasswordDialog = $event"
title="修改密码"
show-cancel-button
@confirm="updatePass"
@@ -210,31 +193,31 @@
>
<van-form ref="passwordForm" @submit="updatePass">
<van-cell-group inset>
<van-field
v-model="pass.old"
<van-field
v-model="pass.old"
type="password"
label="旧密码"
placeholder="请输入旧密码"
placeholder="请输入旧密码"
required
:rules="[{ required: true, message: '请输入旧密码' }]"
/>
<van-field
v-model="pass.new"
type="password"
<van-field
v-model="pass.new"
type="password"
label="新密码"
placeholder="请输入新密码"
required
:rules="passwordRules"
/>
<van-field
v-model="pass.renew"
type="password"
<van-field
v-model="pass.renew"
type="password"
label="确认密码"
placeholder="请再次输入新密码"
required
:rules="[
{ required: true, message: '请再次输入新密码' },
{ validator: validateConfirmPassword }
{ validator: validateConfirmPassword },
]"
/>
</van-cell-group>
@@ -242,7 +225,11 @@
</van-dialog>
<!-- 设置弹窗 -->
<van-action-sheet v-model:show="showSettings" title="设置">
<van-action-sheet
:model-value="showSettings"
@update:model-value="showSettings = $event"
title="设置"
>
<div class="settings-content">
<van-cell-group>
<van-cell title="暗黑主题">
@@ -255,10 +242,7 @@
</van-cell>
<van-cell title="流式输出">
<template #right-icon>
<van-switch
v-model="stream"
@change="(val) => store.setChatStream(val)"
/>
<van-switch v-model="stream" @change="(val) => store.setChatStream(val)" />
</template>
</van-cell>
<van-cell title="消息通知">
@@ -276,7 +260,11 @@
</van-action-sheet>
<!-- 头像选择弹窗 -->
<van-action-sheet v-model:show="showAvatarOptions" title="更换头像">
<van-action-sheet
:model-value="showAvatarOptions"
@update:model-value="showAvatarOptions = $event"
title="更换头像"
>
<div class="avatar-options">
<van-cell title="拍照" icon="photograph" @click="selectAvatar('camera')" />
<van-cell title="从相册选择" icon="photo-o" @click="selectAvatar('album')" />
@@ -285,7 +273,12 @@
</van-action-sheet>
<!-- 关于我们弹窗 -->
<van-dialog v-model:show="showAbout" title="关于我们" :show-cancel-button="false">
<van-dialog
:model-value="showAbout"
@update:model-value="showAbout = $event"
title="关于我们"
:show-cancel-button="false"
>
<div class="about-content">
<div class="about-logo">
<img src="/images/logo.png" alt="Logo" />
@@ -303,7 +296,8 @@
<!-- 退出登录确认 -->
<van-dialog
v-model:show="showLogoutConfirm"
:model-value="showLogoutConfirm"
@update:model-value="showLogoutConfirm = $event"
title="退出登录"
message="确定要退出登录吗?"
show-cancel-button
@@ -317,8 +311,7 @@ import { checkSession, getSystemInfo } from '@/store/cache'
import { removeUserToken } from '@/store/session'
import { useSharedStore } from '@/store/sharedata'
import { httpGet, httpPost } from '@/utils/http'
import { dateFormat, showLoginDialog } from '@/utils/libs'
import { ElMessage } from 'element-plus'
import { showLoginDialog } from '@/utils/libs'
import { showFailToast, showLoadingToast, showNotify, showSuccessToast } from 'vant'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -369,7 +362,7 @@ const pass = ref({
// 密码验证规则
const passwordRules = [
{ required: true, message: '请输入新密码' },
{ min: 8, max: 16, message: '密码长度为8-16个字符' }
{ min: 8, max: 16, message: '密码长度为8-16个字符' },
]
// 计算属性
@@ -400,7 +393,7 @@ onMounted(() => {
isLogin.value = true
form.value = { ...form.value, ...user }
fileList.value[0].url = user.avatar || '/images/avatar/default.jpg'
// 获取用户详细信息
fetchUserProfile()
fetchUserStats()
@@ -454,12 +447,15 @@ const updatePass = () => {
updatePasswordAPI()
return
}
passwordForm.value.validate().then(() => {
updatePasswordAPI()
}).catch((errors) => {
console.log('表单验证失败:', errors)
})
passwordForm.value
.validate()
.then(() => {
updatePasswordAPI()
})
.catch((errors) => {
console.log('表单验证失败:', errors)
})
}
const updatePasswordAPI = () => {
@@ -496,7 +492,7 @@ const updatePasswordAPI = () => {
// 头像选择
const selectAvatar = (type) => {
showAvatarOptions.value = false
switch (type) {
case 'camera':
// 调用相机
@@ -547,7 +543,7 @@ const logout = function () {
isLogin.value = false
showSuccessToast('退出登录成功')
showLogoutConfirm.value = false
// 清除用户数据
form.value = {
id: 0,
@@ -570,12 +566,13 @@ const logout = function () {
.profile-page {
min-height: 100vh;
background: var(--van-background);
padding-bottom: 60px;
.profile-header {
position: relative;
height: 240px;
overflow: hidden;
background: linear-gradient(135deg, var(--van-primary-color), #8B5CF6);
background: linear-gradient(135deg, var(--van-primary-color), #8b5cf6);
.header-bg {
position: absolute;
@@ -652,10 +649,9 @@ const logout = function () {
}
.profile-content {
margin-top: -30px;
position: relative;
margin-top: 20px;
z-index: 3;
padding: 0 16px 60px;
padding: 0 16px 20px;
.status-cards {
margin-bottom: 24px;
@@ -760,7 +756,7 @@ const logout = function () {
}
&.share {
background: linear-gradient(135deg, #8B5CF6, #7c3aed);
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
}
&.settings {
@@ -824,6 +820,11 @@ const logout = function () {
margin: 0;
}
}
.bottom-safe-area {
height: 20px;
width: 100%;
}
}
// 弹窗样式
@@ -923,7 +924,7 @@ const logout = function () {
}
.profile-content {
padding: 0 12px 60px;
padding: 0 12px 20px;
.status-cards .status-card {
padding: 12px;

View File

@@ -1,7 +1,5 @@
<template>
<div class="settings-page">
<van-nav-bar title="设置" left-arrow @click-left="router.back()" fixed />
<div class="settings-content">
<!-- 个人设置 -->
<div class="setting-section">
@@ -39,10 +37,7 @@
<i class="iconfont icon-moon setting-icon"></i>
</template>
<template #right-icon>
<van-switch
v-model="darkMode"
@change="onThemeChange"
/>
<van-switch v-model="darkMode" @change="onThemeChange" />
</template>
</van-cell>
<van-cell title="流式输出">
@@ -50,10 +45,7 @@
<i class="iconfont icon-stream setting-icon"></i>
</template>
<template #right-icon>
<van-switch
v-model="streamOutput"
@change="onStreamChange"
/>
<van-switch v-model="streamOutput" @change="onStreamChange" />
</template>
</van-cell>
<van-cell title="消息通知">
@@ -184,21 +176,21 @@
>
<van-form>
<van-cell-group inset>
<van-field
v-model="passwordForm.old"
<van-field
v-model="passwordForm.old"
type="password"
label="旧密码"
placeholder="请输入旧密码"
placeholder="请输入旧密码"
/>
<van-field
v-model="passwordForm.new"
type="password"
<van-field
v-model="passwordForm.new"
type="password"
label="新密码"
placeholder="请输入新密码"
/>
<van-field
v-model="passwordForm.confirm"
type="password"
<van-field
v-model="passwordForm.confirm"
type="password"
label="确认密码"
placeholder="请再次输入新密码"
/>
@@ -217,11 +209,7 @@
@click="selectLanguage(lang)"
>
<template #right-icon>
<van-icon
v-if="currentLanguage.code === lang.code"
name="success"
color="#07c160"
/>
<van-icon v-if="currentLanguage.code === lang.code" name="success" color="#07c160" />
</template>
</van-cell>
</div>
@@ -239,11 +227,7 @@
@click="selectModel(model)"
>
<template #right-icon>
<van-icon
v-if="currentModel.code === model.code"
name="success"
color="#07c160"
/>
<van-icon v-if="currentModel.code === model.code" name="success" color="#07c160" />
</template>
</van-cell>
</div>
@@ -261,11 +245,7 @@
@click="selectSendMode(mode)"
>
<template #right-icon>
<van-icon
v-if="currentSendMode.code === mode.code"
name="success"
color="#07c160"
/>
<van-icon v-if="currentSendMode.code === mode.code" name="success" color="#07c160" />
</template>
</van-cell>
</div>
@@ -346,7 +326,7 @@ const showAbout = ref(false)
const passwordForm = ref({
old: '',
new: '',
confirm: ''
confirm: '',
})
// 语言选项
@@ -355,7 +335,7 @@ const languages = ref([
{ code: 'zh-TW', name: '繁體中文' },
{ code: 'en', name: 'English' },
{ code: 'ja', name: '日本語' },
{ code: 'ko', name: '한국어' }
{ code: 'ko', name: '한국어' },
])
const currentLanguage = ref(languages.value[0])
@@ -365,7 +345,7 @@ const models = ref([
{ code: 'gpt-4', name: 'GPT-4', desc: '最新的GPT-4模型性能强大' },
{ code: 'gpt-3.5', name: 'GPT-3.5', desc: '经典的GPT-3.5模型,速度快' },
{ code: 'claude', name: 'Claude', desc: '人工智能助手Claude' },
{ code: 'gemini', name: 'Gemini', desc: 'Google的Gemini模型' }
{ code: 'gemini', name: 'Gemini', desc: 'Google的Gemini模型' },
])
const currentModel = ref(models.value[0])
@@ -374,7 +354,7 @@ const currentModel = ref(models.value[0])
const sendModes = ref([
{ code: 'enter', name: 'Enter发送', desc: '按Enter键发送消息' },
{ code: 'ctrl+enter', name: 'Ctrl+Enter发送', desc: '按Ctrl+Enter发送消息' },
{ code: 'button', name: '仅按钮发送', desc: '只能点击发送按钮' }
{ code: 'button', name: '仅按钮发送', desc: '只能点击发送按钮' },
])
const currentSendMode = ref(sendModes.value[0])
@@ -389,26 +369,26 @@ const loadSettings = () => {
const savedSettings = localStorage.getItem('app-settings')
if (savedSettings) {
const settings = JSON.parse(savedSettings)
darkMode.value = settings.darkMode ?? (store.theme === 'dark')
darkMode.value = settings.darkMode ?? store.theme === 'dark'
streamOutput.value = settings.streamOutput ?? store.chatStream
notifications.value = settings.notifications ?? true
autoSave.value = settings.autoSave ?? true
saveHistory.value = settings.saveHistory ?? true
// 恢复语言设置
const savedLang = languages.value.find(lang => lang.code === settings.language)
const savedLang = languages.value.find((lang) => lang.code === settings.language)
if (savedLang) {
currentLanguage.value = savedLang
}
// 恢复模型设置
const savedModel = models.value.find(model => model.code === settings.model)
const savedModel = models.value.find((model) => model.code === settings.model)
if (savedModel) {
currentModel.value = savedModel
}
// 恢复发送方式设置
const savedSendMode = sendModes.value.find(mode => mode.code === settings.sendMode)
const savedSendMode = sendModes.value.find((mode) => mode.code === settings.sendMode)
if (savedSendMode) {
currentSendMode.value = savedSendMode
}
@@ -425,7 +405,7 @@ const saveSettings = () => {
saveHistory: saveHistory.value,
language: currentLanguage.value.code,
model: currentModel.value.code,
sendMode: currentSendMode.value.code
sendMode: currentSendMode.value.code,
}
localStorage.setItem('app-settings', JSON.stringify(settings))
}
@@ -480,7 +460,7 @@ const updatePassword = () => {
showNotify({ type: 'danger', message: '两次输入的密码不一致' })
return
}
// 这里应该调用API
showSuccessToast('密码修改成功')
showPasswordDialog.value = false
@@ -514,39 +494,39 @@ const checkUpdate = () => {
.settings-page {
min-height: 100vh;
background: var(--van-background);
.settings-content {
padding: 54px 16px 20px;
.setting-section {
margin-bottom: 24px;
.section-title {
font-size: 18px;
font-weight: 600;
color: var(--van-text-color);
margin: 0 0 16px 4px;
}
:deep(.van-cell-group) {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.van-cell {
padding: 16px;
.setting-icon {
font-size: 18px;
color: var(--van-primary-color);
margin-right: 12px;
}
.van-cell__title {
font-size: 15px;
font-weight: 500;
}
.setting-value {
font-size: 14px;
color: var(--van-gray-6);
@@ -555,21 +535,21 @@ const checkUpdate = () => {
}
}
}
.language-options,
.model-options,
.send-mode-options {
max-height: 400px;
overflow-y: auto;
:deep(.van-cell) {
padding: 16px 20px;
.van-cell__title {
font-size: 15px;
font-weight: 500;
}
.van-cell__label {
color: var(--van-gray-6);
font-size: 13px;
@@ -577,41 +557,41 @@ const checkUpdate = () => {
}
}
}
.about-content {
text-align: center;
padding: 20px;
.about-logo {
margin-bottom: 16px;
img {
width: 60px;
height: 60px;
border-radius: 12px;
}
}
h3 {
font-size: 20px;
font-weight: 600;
color: var(--van-text-color);
margin: 0 0 12px 0;
}
.about-desc {
font-size: 14px;
color: var(--van-gray-6);
line-height: 1.5;
margin: 0 0 20px 0;
}
.about-info {
p {
font-size: 13px;
color: var(--van-gray-7);
margin: 0 0 4px 0;
&:last-child {
margin: 0;
}
@@ -628,4 +608,4 @@ const checkUpdate = () => {
}
}
}
</style>
</style>

View File

@@ -1,21 +1,23 @@
<template>
<div class="tools-page">
<van-nav-bar title="AI 工具" left-arrow @click-left="router.back()" fixed />
<div class="tools-content">
<!-- 工具分类 -->
<van-tabs v-model:active="activeCategory" @change="onCategoryChange" sticky :offset-top="46">
<van-tab title="全部" name="all" />
<van-tab title="办公工具" name="office" />
<van-tab title="创意工具" name="creative" />
<van-tab title="学习工具" name="study" />
<van-tab title="生活工具" name="life" />
</van-tabs>
<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"
<div
v-for="tool in filteredTools"
:key="tool.key"
class="tool-item"
@click="openTool(tool)"
@@ -34,10 +36,10 @@
</van-tag>
</div>
</div>
<div class="tool-features" v-if="tool.features">
<van-tag
v-for="feature in tool.features"
<van-tag
v-for="feature in tool.features"
:key="feature"
size="small"
plain
@@ -46,7 +48,7 @@
{{ feature }}
</van-tag>
</div>
<div class="tool-stats" v-if="tool.stats">
<div class="stat-item">
<span class="stat-label">使用次数</span>
@@ -75,9 +77,7 @@
<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>
<van-button size="small" type="primary" plain round> 立即使用 </van-button>
</div>
</div>
</van-swipe-item>
@@ -86,7 +86,11 @@
</div>
<!-- 工具详情弹窗 -->
<van-action-sheet v-model:show="showToolDetail" :title="selectedTool?.name">
<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 }">
@@ -97,7 +101,7 @@
<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">
@@ -107,17 +111,17 @@
</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
<van-button
type="primary"
size="large"
round
block
:disabled="selectedTool.status !== 'available'"
@click="useTool(selectedTool)"
@@ -129,7 +133,12 @@
</van-action-sheet>
<!-- 思维导图工具 -->
<van-action-sheet v-model:show="showMindMap" title="思维导图" :close-on-click-overlay="false">
<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>
@@ -151,6 +160,8 @@
</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'
@@ -168,7 +179,8 @@ const tools = ref([
key: 'mindmap',
name: '思维导图',
desc: '智能生成思维导图,整理思路更清晰',
fullDesc: '基于AI技术的智能思维导图生成工具可以根据文本内容自动生成结构化的思维导图支持多种导出格式。',
fullDesc:
'基于AI技术的智能思维导图生成工具可以根据文本内容自动生成结构化的思维导图支持多种导出格式。',
icon: 'icon-mind',
color: '#3B82F6',
category: 'office',
@@ -179,13 +191,14 @@ const tools = ref([
'提供多种精美模板',
'智能节点布局算法',
'支持导出多种格式',
'支持在线协作编辑'
'支持在线协作编辑',
],
usage: '输入您的文本内容AI会自动分析并生成对应的思维导图结构。您可以对生成的导图进行编辑、美化和导出。',
usage:
'输入您的文本内容AI会自动分析并生成对应的思维导图结构。您可以对生成的导图进行编辑、美化和导出。',
stats: {
usageCount: 1256,
rating: 96
}
rating: 96,
},
},
{
key: 'summary',
@@ -202,13 +215,13 @@ const tools = ref([
'智能关键词提取',
'可控制摘要长度',
'支持批量处理',
'多语言文档支持'
'多语言文档支持',
],
usage: '上传或粘贴文档内容选择摘要长度和类型AI会自动生成文档摘要。',
stats: {
usageCount: 2341,
rating: 94
}
rating: 94,
},
},
{
key: 'translation',
@@ -225,13 +238,13 @@ const tools = ref([
'专业术语库支持',
'上下文语境理解',
'批量文档翻译',
'翻译质量评估'
'翻译质量评估',
],
usage: '选择源语言和目标语言输入需要翻译的内容AI会提供高质量的翻译结果。',
stats: {
usageCount: 5678,
rating: 98
}
rating: 98,
},
},
{
key: 'poster',
@@ -248,13 +261,13 @@ const tools = ref([
'智能配色方案',
'自动排版布局',
'高清无水印导出',
'支持自定义尺寸'
'支持自定义尺寸',
],
usage: '选择海报类型和风格输入文案内容AI会自动生成专业海报设计。',
stats: {
usageCount: 3456,
rating: 95
}
rating: 95,
},
},
{
key: 'logo',
@@ -271,13 +284,13 @@ const tools = ref([
'矢量格式输出',
'商用版权授权',
'配色方案推荐',
'标准化尺寸规范'
'标准化尺寸规范',
],
usage: '描述您的品牌特点和期望风格AI会生成多个Logo设计方案供您选择。',
stats: {
usageCount: 2234,
rating: 93
}
rating: 93,
},
},
{
key: 'study-plan',
@@ -294,13 +307,13 @@ const tools = ref([
'学习进度跟踪',
'智能计划调整',
'学习效果评估',
'多领域知识覆盖'
'多领域知识覆盖',
],
usage: '输入您的学习目标、可用时间和当前水平AI会为您制定详细的学习计划。',
stats: {
usageCount: 1890,
rating: 97
}
rating: 97,
},
},
{
key: 'recipe',
@@ -317,13 +330,13 @@ const tools = ref([
'营养成分分析',
'详细制作步骤',
'口味偏好适配',
'热量控制建议'
'热量控制建议',
],
usage: '拍照或输入现有食材AI会推荐适合的菜谱并提供详细制作指导。',
stats: {
usageCount: 567,
rating: 89
}
rating: 89,
},
},
{
key: 'workout',
@@ -340,19 +353,19 @@ const tools = ref([
'科学运动指导',
'训练进度跟踪',
'饮食建议搭配',
'健康数据分析'
'健康数据分析',
],
usage: '输入您的身体状况、运动目标和时间安排AI会制定适合的运动计划。',
stats: {
usageCount: 234,
rating: 91
}
}
rating: 91,
},
},
])
// 推荐工具取前3个可用的
const recommendTools = computed(() => {
return tools.value.filter(tool => tool.status === 'available').slice(0, 3)
return tools.value.filter((tool) => tool.status === 'available').slice(0, 3)
})
// 根据分类筛选工具
@@ -360,7 +373,7 @@ const filteredTools = computed(() => {
if (activeCategory.value === 'all') {
return tools.value
}
return tools.value.filter(tool => tool.category === activeCategory.value)
return tools.value.filter((tool) => tool.category === activeCategory.value)
})
onMounted(() => {
@@ -368,7 +381,7 @@ onMounted(() => {
const urlParams = new URLSearchParams(window.location.search)
const toolKey = urlParams.get('tool')
if (toolKey) {
const tool = tools.value.find(t => t.key === toolKey)
const tool = tools.value.find((t) => t.key === toolKey)
if (tool) {
openTool(tool)
}
@@ -383,7 +396,7 @@ const onCategoryChange = (category) => {
// 打开工具
const openTool = (tool) => {
selectedTool.value = tool
// 特殊工具直接打开对应界面
if (tool.key === 'mindmap') {
showMindMap.value = true
@@ -395,12 +408,12 @@ const openTool = (tool) => {
// 使用工具
const useTool = (tool) => {
showToolDetail.value = false
if (tool.status !== 'available') {
showNotify({ type: 'warning', message: '该工具还在开发中,敬请期待' })
return
}
// 根据工具类型跳转到对应页面或打开功能界面
switch (tool.key) {
case 'mindmap':
@@ -441,18 +454,18 @@ const closeMindMap = () => {
.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;
@@ -461,16 +474,16 @@ const closeMindMap = () => {
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;
@@ -479,56 +492,56 @@ const closeMindMap = () => {
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;
@@ -536,31 +549,31 @@ const closeMindMap = () => {
}
}
}
.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);
background: linear-gradient(135deg, var(--van-primary-color), #8b5cf6);
cursor: pointer;
.recommend-bg {
position: absolute;
top: 16px;
@@ -572,23 +585,23 @@ const closeMindMap = () => {
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;
@@ -600,14 +613,14 @@ const closeMindMap = () => {
}
}
}
.tool-detail {
padding: 20px;
.detail-header {
display: flex;
margin-bottom: 20px;
.detail-icon {
width: 60px;
height: 60px;
@@ -617,23 +630,23 @@ const closeMindMap = () => {
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);
@@ -642,11 +655,11 @@ const closeMindMap = () => {
}
}
}
.detail-features,
.detail-usage {
margin-bottom: 20px;
.features-title,
.usage-title {
font-size: 16px;
@@ -654,25 +667,25 @@ const closeMindMap = () => {
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);
@@ -680,17 +693,17 @@ const closeMindMap = () => {
margin: 0;
}
}
.detail-actions {
margin-top: 20px;
}
}
.mindmap-container {
height: 80vh;
display: flex;
flex-direction: column;
.mindmap-toolbar {
display: flex;
gap: 8px;
@@ -698,12 +711,12 @@ const closeMindMap = () => {
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%;
@@ -711,17 +724,17 @@ const closeMindMap = () => {
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;
@@ -740,4 +753,4 @@ const closeMindMap = () => {
}
}
}
</style>
</style>